diff --git a/.yarn/cache/@standardnotes-common-npm-1.48.3-83aea658e0-191d97879d.zip b/.yarn/cache/@standardnotes-common-npm-1.48.3-83aea658e0-191d97879d.zip new file mode 100644 index 000000000..0be3e2087 Binary files /dev/null and b/.yarn/cache/@standardnotes-common-npm-1.48.3-83aea658e0-191d97879d.zip differ diff --git a/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.59.9-3d09cd2e8f-bd2428e307.zip b/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.59.9-3d09cd2e8f-bd2428e307.zip new file mode 100644 index 000000000..bb68d99d5 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.59.9-3d09cd2e8f-bd2428e307.zip differ diff --git a/.yarn/cache/@typescript-eslint-parser-npm-5.59.9-3841845448-69b07d0a5b.zip b/.yarn/cache/@typescript-eslint-parser-npm-5.59.9-3841845448-69b07d0a5b.zip new file mode 100644 index 000000000..8d04274de Binary files /dev/null and b/.yarn/cache/@typescript-eslint-parser-npm-5.59.9-3841845448-69b07d0a5b.zip differ diff --git a/.yarn/cache/@typescript-eslint-scope-manager-npm-5.59.9-c9c714cb56-362c22662d.zip b/.yarn/cache/@typescript-eslint-scope-manager-npm-5.59.9-c9c714cb56-362c22662d.zip new file mode 100644 index 000000000..8aa15221a Binary files /dev/null and b/.yarn/cache/@typescript-eslint-scope-manager-npm-5.59.9-c9c714cb56-362c22662d.zip differ diff --git a/.yarn/cache/@typescript-eslint-type-utils-npm-5.59.9-fc3a85cbad-6bc2619c50.zip b/.yarn/cache/@typescript-eslint-type-utils-npm-5.59.9-fc3a85cbad-6bc2619c50.zip new file mode 100644 index 000000000..d3298a8aa Binary files /dev/null and b/.yarn/cache/@typescript-eslint-type-utils-npm-5.59.9-fc3a85cbad-6bc2619c50.zip differ diff --git a/.yarn/cache/@typescript-eslint-types-npm-5.59.9-9719f93248-283f8fee1e.zip b/.yarn/cache/@typescript-eslint-types-npm-5.59.9-9719f93248-283f8fee1e.zip new file mode 100644 index 000000000..89a15bfc0 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-types-npm-5.59.9-9719f93248-283f8fee1e.zip differ diff --git a/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.59.9-ec2ce6608c-c0c9b81f20.zip b/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.59.9-ec2ce6608c-c0c9b81f20.zip new file mode 100644 index 000000000..4b11235c7 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.59.9-ec2ce6608c-c0c9b81f20.zip differ diff --git a/.yarn/cache/@typescript-eslint-utils-npm-5.59.9-d1ab6a9f9a-22ec596288.zip b/.yarn/cache/@typescript-eslint-utils-npm-5.59.9-d1ab6a9f9a-22ec596288.zip new file mode 100644 index 000000000..da02c617d Binary files /dev/null and b/.yarn/cache/@typescript-eslint-utils-npm-5.59.9-d1ab6a9f9a-22ec596288.zip differ diff --git a/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.59.9-3e52021052-2909ce761f.zip b/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.59.9-3e52021052-2909ce761f.zip new file mode 100644 index 000000000..42288247e Binary files /dev/null and b/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.59.9-3e52021052-2909ce761f.zip differ diff --git a/package.json b/package.json index a96ce7c85..dd56b1ea8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "build:snjs": "yarn workspaces foreach -p --topological-dev --verbose -R --from @standardnotes/snjs run build", "build:services": "yarn workspaces foreach -pt --topological-dev --verbose -R --from @standardnotes/services run build", "build:api": "yarn workspaces foreach -pt --topological-dev --verbose -R --from @standardnotes/api run build", - "start:server:web": "lerna run start --scope=@standardnotes/web", "e2e": "lerna run start:test-server --scope=@standardnotes/snjs", "reset": "find . -type dir -name node_modules | xargs rm -rf && rm -rf yarn.lock && yarn install", "release:prod": "lerna version --conventional-commits --yes -m \"chore(release): publish\"", diff --git a/packages/api/package.json b/packages/api/package.json index ce0744df7..753d81407 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -36,7 +36,7 @@ "typescript": "*" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/domain-core": "^1.12.0", "@standardnotes/encryption": "workspace:*", "@standardnotes/models": "workspace:*", diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts index f9343fd0e..a1d529490 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts @@ -1,14 +1,14 @@ import { ErrorMessage } from '../../Error/ErrorMessage' import { ApiCallError } from '../../Error/ApiCallError' import { ApiVersion } from '../../Api/ApiVersion' -import { ApiEndpointParam } from '../../Request/ApiEndpointParam' + import { SubscriptionServerInterface } from '../../Server/Subscription/SubscriptionServerInterface' import { AppleIAPConfirmResponseBody } from './../../Response/Subscription/AppleIAPConfirmResponseBody' import { SubscriptionInviteAcceptResponseBody } from '../../Response/Subscription/SubscriptionInviteAcceptResponseBody' import { SubscriptionInviteCancelResponseBody } from '../../Response/Subscription/SubscriptionInviteCancelResponseBody' import { SubscriptionInviteListResponseBody } from '../../Response/Subscription/SubscriptionInviteListResponseBody' import { SubscriptionInviteResponseBody } from '../../Response/Subscription/SubscriptionInviteResponseBody' -import { HttpResponse } from '@standardnotes/responses' +import { HttpResponse, ApiEndpointParam } from '@standardnotes/responses' import { SubscriptionApiServiceInterface } from './SubscriptionApiServiceInterface' import { SubscriptionApiOperations } from './SubscriptionApiOperations' diff --git a/packages/api/src/Domain/Client/User/UserApiOperations.ts b/packages/api/src/Domain/Client/User/UserApiOperations.ts index 8e22a3f01..6428f1af8 100644 --- a/packages/api/src/Domain/Client/User/UserApiOperations.ts +++ b/packages/api/src/Domain/Client/User/UserApiOperations.ts @@ -2,4 +2,5 @@ export enum UserApiOperations { Registering, SubmittingRequest, DeletingAccount, + UpdatingUser, } diff --git a/packages/api/src/Domain/Client/User/UserApiService.ts b/packages/api/src/Domain/Client/User/UserApiService.ts index c37daaf57..433600ac3 100644 --- a/packages/api/src/Domain/Client/User/UserApiService.ts +++ b/packages/api/src/Domain/Client/User/UserApiService.ts @@ -5,8 +5,7 @@ import { ErrorMessage } from '../../Error/ErrorMessage' import { ApiCallError } from '../../Error/ApiCallError' import { UserServerInterface } from '../../Server/User/UserServerInterface' import { ApiVersion } from '../../Api/ApiVersion' -import { ApiEndpointParam } from '../../Request/ApiEndpointParam' -import { HttpResponse } from '@standardnotes/responses' +import { HttpResponse, ApiEndpointParam } from '@standardnotes/responses' import { UserDeletionResponseBody } from '../../Response/User/UserDeletionResponseBody' import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' @@ -15,6 +14,7 @@ import { UserRequestServerInterface } from '../../Server/UserRequest/UserRequest import { UserApiOperations } from './UserApiOperations' import { UserApiServiceInterface } from './UserApiServiceInterface' +import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse' export class UserApiService implements UserApiServiceInterface { private operationsInProgress: Map @@ -35,7 +35,7 @@ export class UserApiService implements UserApiServiceInterface { return response } catch (error) { - throw new ApiCallError(ErrorMessage.GenericRegistrationFail) + throw new ApiCallError(ErrorMessage.GenericFail) } } @@ -84,6 +84,23 @@ export class UserApiService implements UserApiServiceInterface { } } + async updateUser(updateDTO: { userUuid: string }): Promise> { + this.lockOperation(UserApiOperations.UpdatingUser) + + try { + const response = await this.userServer.update({ + [ApiEndpointParam.ApiVersion]: ApiVersion.v0, + user_uuid: updateDTO.userUuid, + }) + + this.unlockOperation(UserApiOperations.UpdatingUser) + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericFail) + } + } + private lockOperation(operation: UserApiOperations): void { if (this.operationsInProgress.get(operation)) { throw new ApiCallError(ErrorMessage.GenericInProgress) diff --git a/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts index e951b7f62..f10f798a4 100644 --- a/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts @@ -5,6 +5,7 @@ import { HttpResponse } from '@standardnotes/responses' import { UserDeletionResponseBody } from '../../Response/User/UserDeletionResponseBody' import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' import { UserRequestResponseBody } from '../../Response/UserRequest/UserRequestResponseBody' +import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse' export interface UserApiServiceInterface { register(registerDTO: { @@ -13,9 +14,12 @@ export interface UserApiServiceInterface { keyParams: RootKeyParamsInterface ephemeral: boolean }): Promise> + updateUser(updateDTO: { userUuid: string }): Promise> + submitUserRequest(dto: { userUuid: string requestType: UserRequestType }): Promise> + deleteAccount(userUuid: string): Promise> } diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts index 2cda7e8ca..3e76b2e5a 100644 --- a/packages/api/src/Domain/Http/HttpService.ts +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -53,7 +53,15 @@ export class HttpService implements HttpServiceInterface { this.host = host } + getHost(): string { + return this.host + } + async get(path: string, params?: HttpRequestParams, authentication?: string): Promise> { + if (!this.host) { + throw new Error('Attempting to make network request before host is set') + } + return this.runHttp({ url: joinPaths(this.host, path), params, @@ -62,7 +70,20 @@ export class HttpService implements HttpServiceInterface { }) } + async getExternal(url: string, params?: HttpRequestParams): Promise> { + return this.runHttp({ + url, + params, + verb: HttpVerb.Get, + external: true, + }) + } + async post(path: string, params?: HttpRequestParams, authentication?: string): Promise> { + if (!this.host) { + throw new Error('Attempting to make network request before host is set') + } + return this.runHttp({ url: joinPaths(this.host, path), params, diff --git a/packages/api/src/Domain/Http/HttpServiceInterface.ts b/packages/api/src/Domain/Http/HttpServiceInterface.ts index cff0fd875..2db8ff58c 100644 --- a/packages/api/src/Domain/Http/HttpServiceInterface.ts +++ b/packages/api/src/Domain/Http/HttpServiceInterface.ts @@ -3,8 +3,10 @@ import { HttpRequest, HttpRequestParams, HttpResponse, HttpResponseMeta } from ' export interface HttpServiceInterface { setHost(host: string): void + getHost(): string setSession(session: Session): void get(path: string, params?: HttpRequestParams, authentication?: string): Promise> + getExternal(url: string, params?: HttpRequestParams): Promise> post(path: string, params?: HttpRequestParams, authentication?: string): Promise> put(path: string, params?: HttpRequestParams, authentication?: string): Promise> patch(path: string, params: HttpRequestParams, authentication?: string): Promise> diff --git a/packages/api/src/Domain/Request/ApiEndpointParam.ts b/packages/api/src/Domain/Request/ApiEndpointParam.ts deleted file mode 100644 index 007ded326..000000000 --- a/packages/api/src/Domain/Request/ApiEndpointParam.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum ApiEndpointParam { - LastSyncToken = 'sync_token', - PaginationToken = 'cursor_token', - SyncDlLimit = 'limit', - SyncPayloads = 'items', - ApiVersion = 'api', -} diff --git a/packages/api/src/Domain/Request/AsymmetricMessage/CreateAsymmetricMessageParams.ts b/packages/api/src/Domain/Request/AsymmetricMessage/CreateAsymmetricMessageParams.ts new file mode 100644 index 000000000..bcaec0517 --- /dev/null +++ b/packages/api/src/Domain/Request/AsymmetricMessage/CreateAsymmetricMessageParams.ts @@ -0,0 +1,5 @@ +export type CreateAsymmetricMessageParams = { + recipientUuid: string + encryptedMessage: string + replaceabilityIdentifier?: string +} diff --git a/packages/api/src/Domain/Request/AsymmetricMessage/DeleteAsymmetricMessageRequestParams.ts b/packages/api/src/Domain/Request/AsymmetricMessage/DeleteAsymmetricMessageRequestParams.ts new file mode 100644 index 000000000..5aab92dbc --- /dev/null +++ b/packages/api/src/Domain/Request/AsymmetricMessage/DeleteAsymmetricMessageRequestParams.ts @@ -0,0 +1,3 @@ +export type DeleteAsymmetricMessageRequestParams = { + messageUuid: string +} diff --git a/packages/api/src/Domain/Request/AsymmetricMessage/GetOutboundAsymmetricMessagesRequestParams.ts b/packages/api/src/Domain/Request/AsymmetricMessage/GetOutboundAsymmetricMessagesRequestParams.ts new file mode 100644 index 000000000..3ccd1dbce --- /dev/null +++ b/packages/api/src/Domain/Request/AsymmetricMessage/GetOutboundAsymmetricMessagesRequestParams.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type GetOutboundAsymmetricMessagesRequestParams = {} diff --git a/packages/api/src/Domain/Request/AsymmetricMessage/GetUserAsymmetricMessagesRequestParams.ts b/packages/api/src/Domain/Request/AsymmetricMessage/GetUserAsymmetricMessagesRequestParams.ts new file mode 100644 index 000000000..da4d3f813 --- /dev/null +++ b/packages/api/src/Domain/Request/AsymmetricMessage/GetUserAsymmetricMessagesRequestParams.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type GetUserAsymmetricMessagesRequestParams = {} diff --git a/packages/api/src/Domain/Request/AsymmetricMessage/UpdateAsymmetricMessageParams.ts b/packages/api/src/Domain/Request/AsymmetricMessage/UpdateAsymmetricMessageParams.ts new file mode 100644 index 000000000..f1c5757fa --- /dev/null +++ b/packages/api/src/Domain/Request/AsymmetricMessage/UpdateAsymmetricMessageParams.ts @@ -0,0 +1,4 @@ +export type UpdateAsymmetricMessageParams = { + messageUuid: string + encryptedMessage: string +} diff --git a/packages/api/src/Domain/Request/SharedVault/CreateSharedVaultValetTokenParams.ts b/packages/api/src/Domain/Request/SharedVault/CreateSharedVaultValetTokenParams.ts new file mode 100644 index 000000000..60a8833a6 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVault/CreateSharedVaultValetTokenParams.ts @@ -0,0 +1,12 @@ +import { ValetTokenOperation } from '@standardnotes/responses' +import { SharedVaultMoveType } from './SharedVaultMoveType' + +export type CreateSharedVaultValetTokenParams = { + sharedVaultUuid: string + fileUuid?: string + remoteIdentifier: string + operation: ValetTokenOperation + unencryptedFileSize?: number + moveOperationType?: SharedVaultMoveType + sharedVaultToSharedVaultMoveTargetUuid?: string +} diff --git a/packages/api/src/Domain/Request/SharedVault/SharedVaultMoveType.ts b/packages/api/src/Domain/Request/SharedVault/SharedVaultMoveType.ts new file mode 100644 index 000000000..07b24b13d --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVault/SharedVaultMoveType.ts @@ -0,0 +1 @@ +export type SharedVaultMoveType = 'shared-vault-to-user' | 'user-to-shared-vault' | 'shared-vault-to-shared-vault' diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/AcceptInviteRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/AcceptInviteRequestParams.ts new file mode 100644 index 000000000..a88c62a90 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/AcceptInviteRequestParams.ts @@ -0,0 +1,4 @@ +export type AcceptInviteRequestParams = { + sharedVaultUuid: string + inviteUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/CreateSharedVaultInviteParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/CreateSharedVaultInviteParams.ts new file mode 100644 index 000000000..ba8332aae --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/CreateSharedVaultInviteParams.ts @@ -0,0 +1,8 @@ +import { SharedVaultPermission } from '@standardnotes/responses' + +export type CreateSharedVaultInviteParams = { + sharedVaultUuid: string + recipientUuid: string + encryptedMessage: string + permissions: SharedVaultPermission +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/DeclineInviteRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/DeclineInviteRequestParams.ts new file mode 100644 index 000000000..2feb8cca2 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/DeclineInviteRequestParams.ts @@ -0,0 +1,4 @@ +export type DeclineInviteRequestParams = { + sharedVaultUuid: string + inviteUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/DeleteAllSharedVaultInvitesRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/DeleteAllSharedVaultInvitesRequestParams.ts new file mode 100644 index 000000000..0aa027aa0 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/DeleteAllSharedVaultInvitesRequestParams.ts @@ -0,0 +1,3 @@ +export type DeleteAllSharedVaultInvitesRequestParams = { + sharedVaultUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/DeleteInviteRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/DeleteInviteRequestParams.ts new file mode 100644 index 000000000..ede504353 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/DeleteInviteRequestParams.ts @@ -0,0 +1,4 @@ +export type DeleteInviteRequestParams = { + sharedVaultUuid: string + inviteUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/GetOutboundUserInvitesRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/GetOutboundUserInvitesRequestParams.ts new file mode 100644 index 000000000..202488dff --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/GetOutboundUserInvitesRequestParams.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type GetOutboundUserInvitesRequestParams = {} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/GetSharedVaultInvitesRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/GetSharedVaultInvitesRequestParams.ts new file mode 100644 index 000000000..fae9a93e6 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/GetSharedVaultInvitesRequestParams.ts @@ -0,0 +1,3 @@ +export type GetSharedVaultInvitesRequestParams = { + sharedVaultUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/GetUserInvitesRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/GetUserInvitesRequestParams.ts new file mode 100644 index 000000000..1b5d00067 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/GetUserInvitesRequestParams.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type GetUserInvitesRequestParams = {} diff --git a/packages/api/src/Domain/Request/SharedVaultInvites/UpdateSharedVaultInviteParams.ts b/packages/api/src/Domain/Request/SharedVaultInvites/UpdateSharedVaultInviteParams.ts new file mode 100644 index 000000000..3e701d0c6 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultInvites/UpdateSharedVaultInviteParams.ts @@ -0,0 +1,8 @@ +import { SharedVaultPermission } from '@standardnotes/responses' + +export type UpdateSharedVaultInviteParams = { + sharedVaultUuid: string + inviteUuid: string + encryptedMessage: string + permissions?: SharedVaultPermission +} diff --git a/packages/api/src/Domain/Request/SharedVaultUser/DeleteSharedVaultUserRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultUser/DeleteSharedVaultUserRequestParams.ts new file mode 100644 index 000000000..231423431 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultUser/DeleteSharedVaultUserRequestParams.ts @@ -0,0 +1,4 @@ +export type DeleteSharedVaultUserRequestParams = { + sharedVaultUuid: string + userUuid: string +} diff --git a/packages/api/src/Domain/Request/SharedVaultUser/GetSharedVaultUsersRequestParams.ts b/packages/api/src/Domain/Request/SharedVaultUser/GetSharedVaultUsersRequestParams.ts new file mode 100644 index 000000000..3f14066d4 --- /dev/null +++ b/packages/api/src/Domain/Request/SharedVaultUser/GetSharedVaultUsersRequestParams.ts @@ -0,0 +1,3 @@ +export type GetSharedVaultUsersRequestParams = { + sharedVaultUuid: string +} diff --git a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteCancelRequestParams.ts b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteCancelRequestParams.ts index b68f1b420..28eb67d40 100644 --- a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteCancelRequestParams.ts +++ b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteCancelRequestParams.ts @@ -1,4 +1,4 @@ -import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiEndpointParam } from '@standardnotes/responses' import { ApiVersion } from '../../Api/ApiVersion' export type SubscriptionInviteCancelRequestParams = { diff --git a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteDeclineRequestParams.ts b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteDeclineRequestParams.ts index 67968c404..7a6d359e4 100644 --- a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteDeclineRequestParams.ts +++ b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteDeclineRequestParams.ts @@ -1,4 +1,4 @@ -import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiEndpointParam } from '@standardnotes/responses' import { ApiVersion } from '../../Api/ApiVersion' export type SubscriptionInviteDeclineRequestParams = { diff --git a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteListRequestParams.ts b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteListRequestParams.ts index bcf7fcc8d..58cfb77ae 100644 --- a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteListRequestParams.ts +++ b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteListRequestParams.ts @@ -1,4 +1,4 @@ -import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiEndpointParam } from '@standardnotes/responses' import { ApiVersion } from '../../Api/ApiVersion' export type SubscriptionInviteListRequestParams = { diff --git a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteRequestParams.ts b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteRequestParams.ts index 0263ec0df..c9d1dcbcd 100644 --- a/packages/api/src/Domain/Request/Subscription/SubscriptionInviteRequestParams.ts +++ b/packages/api/src/Domain/Request/Subscription/SubscriptionInviteRequestParams.ts @@ -1,4 +1,4 @@ -import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiEndpointParam } from '@standardnotes/responses' import { ApiVersion } from '../../Api/ApiVersion' export type SubscriptionInviteRequestParams = { diff --git a/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts b/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts index 126305d9f..3a6d20bca 100644 --- a/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts +++ b/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts @@ -1,13 +1,11 @@ import { AnyKeyParamsContent } from '@standardnotes/common' -import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiEndpointParam } from '@standardnotes/responses' import { ApiVersion } from '../../Api/ApiVersion' export type UserRegistrationRequestParams = AnyKeyParamsContent & { [ApiEndpointParam.ApiVersion]: ApiVersion.v0 + [additionalParam: string]: unknown password: string email: string ephemeral: boolean - [additionalParam: string]: unknown - pkcPublicKey?: string - pkcEncryptedPrivateKey?: string } diff --git a/packages/api/src/Domain/Request/User/UserUpdateRequestParams.ts b/packages/api/src/Domain/Request/User/UserUpdateRequestParams.ts new file mode 100644 index 000000000..8a9bf4423 --- /dev/null +++ b/packages/api/src/Domain/Request/User/UserUpdateRequestParams.ts @@ -0,0 +1,7 @@ +import { ApiEndpointParam } from '@standardnotes/responses' +import { ApiVersion } from '../../Api/ApiVersion' + +export type UserUpdateRequestParams = { + [ApiEndpointParam.ApiVersion]: ApiVersion.v0 + user_uuid: string +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index e6cd0f1a2..c18e5e6e5 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -1,4 +1,3 @@ -export * from './ApiEndpointParam' export * from './Authenticator/DeleteAuthenticatorRequestParams' export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams' export * from './Authenticator/ListAuthenticatorsRequestParams' @@ -17,3 +16,4 @@ export * from './Subscription/SubscriptionInviteRequestParams' export * from './User/UserRegistrationRequestParams' export * from './UserRequest/UserRequestRequestParams' export * from './WebSocket/WebSocketConnectionTokenRequestParams' +export * from './SharedVault/SharedVaultMoveType' diff --git a/packages/api/src/Domain/Response/AsymmetricMessage/CreateAsymmetricMessageResponse.ts b/packages/api/src/Domain/Response/AsymmetricMessage/CreateAsymmetricMessageResponse.ts new file mode 100644 index 000000000..ae33abaac --- /dev/null +++ b/packages/api/src/Domain/Response/AsymmetricMessage/CreateAsymmetricMessageResponse.ts @@ -0,0 +1,5 @@ +import { AsymmetricMessageServerHash } from '@standardnotes/responses' + +export type CreateAsymmetricMessageResponse = { + message: AsymmetricMessageServerHash +} diff --git a/packages/api/src/Domain/Response/AsymmetricMessage/DeleteAsymmetricMessageResponse.ts b/packages/api/src/Domain/Response/AsymmetricMessage/DeleteAsymmetricMessageResponse.ts new file mode 100644 index 000000000..49b1ac3d5 --- /dev/null +++ b/packages/api/src/Domain/Response/AsymmetricMessage/DeleteAsymmetricMessageResponse.ts @@ -0,0 +1,3 @@ +export type DeleteAsymmetricMessageResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/AsymmetricMessage/GetOutboundAsymmetricMessagesResponse.ts b/packages/api/src/Domain/Response/AsymmetricMessage/GetOutboundAsymmetricMessagesResponse.ts new file mode 100644 index 000000000..963b3df9d --- /dev/null +++ b/packages/api/src/Domain/Response/AsymmetricMessage/GetOutboundAsymmetricMessagesResponse.ts @@ -0,0 +1,5 @@ +import { AsymmetricMessageServerHash } from '@standardnotes/responses' + +export type GetOutboundAsymmetricMessagesResponse = { + messages: AsymmetricMessageServerHash[] +} diff --git a/packages/api/src/Domain/Response/AsymmetricMessage/GetUserAsymmetricMessagesResponse.ts b/packages/api/src/Domain/Response/AsymmetricMessage/GetUserAsymmetricMessagesResponse.ts new file mode 100644 index 000000000..1ced414e6 --- /dev/null +++ b/packages/api/src/Domain/Response/AsymmetricMessage/GetUserAsymmetricMessagesResponse.ts @@ -0,0 +1,5 @@ +import { AsymmetricMessageServerHash } from '@standardnotes/responses' + +export type GetUserAsymmetricMessagesResponse = { + messages: AsymmetricMessageServerHash[] +} diff --git a/packages/api/src/Domain/Response/AsymmetricMessage/UpdateAsymmetricMessageResponse.ts b/packages/api/src/Domain/Response/AsymmetricMessage/UpdateAsymmetricMessageResponse.ts new file mode 100644 index 000000000..252e38c2f --- /dev/null +++ b/packages/api/src/Domain/Response/AsymmetricMessage/UpdateAsymmetricMessageResponse.ts @@ -0,0 +1,5 @@ +import { AsymmetricMessageServerHash } from '@standardnotes/responses' + +export type UpdateAsymmetricMessageResponse = { + message: AsymmetricMessageServerHash +} diff --git a/packages/api/src/Domain/Response/Revision/GetRevisionResponseBody.ts b/packages/api/src/Domain/Response/Revision/GetRevisionResponseBody.ts index 694aab441..2620faac8 100644 --- a/packages/api/src/Domain/Response/Revision/GetRevisionResponseBody.ts +++ b/packages/api/src/Domain/Response/Revision/GetRevisionResponseBody.ts @@ -7,6 +7,9 @@ export interface GetRevisionResponseBody { items_key_id: string | null enc_item_key: string | null auth_hash: string | null + user_uuid: string + key_system_identifier: string | null + shared_vault_uuid: string | null created_at: string updated_at: string } diff --git a/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultResponse.ts b/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultResponse.ts new file mode 100644 index 000000000..bf2176c90 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultResponse.ts @@ -0,0 +1,6 @@ +import { SharedVaultUserServerHash, SharedVaultServerHash } from '@standardnotes/responses' + +export type CreateSharedVaultResponse = { + sharedVault: SharedVaultServerHash + sharedVaultUser: SharedVaultUserServerHash +} diff --git a/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultValetTokenResponse.ts b/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultValetTokenResponse.ts new file mode 100644 index 000000000..1784fd5b4 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVault/CreateSharedVaultValetTokenResponse.ts @@ -0,0 +1,3 @@ +export type CreateSharedVaultValetTokenResponse = { + valetToken: string +} diff --git a/packages/api/src/Domain/Response/SharedVault/GetSharedVaultsResponse.ts b/packages/api/src/Domain/Response/SharedVault/GetSharedVaultsResponse.ts new file mode 100644 index 000000000..8aa95ce6e --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVault/GetSharedVaultsResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultServerHash } from '@standardnotes/responses' + +export type GetSharedVaultsResponse = { + sharedVaults: SharedVaultServerHash[] +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/AcceptInviteResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/AcceptInviteResponse.ts new file mode 100644 index 000000000..3c3f00dc0 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/AcceptInviteResponse.ts @@ -0,0 +1,3 @@ +export type AcceptInviteResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/CreateSharedVaultInviteResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/CreateSharedVaultInviteResponse.ts new file mode 100644 index 000000000..51d145d27 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/CreateSharedVaultInviteResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type CreateSharedVaultInviteResponse = { + invite: SharedVaultInviteServerHash +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/DeclineInviteResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/DeclineInviteResponse.ts new file mode 100644 index 000000000..f46fdb675 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/DeclineInviteResponse.ts @@ -0,0 +1,3 @@ +export type DeclineInviteResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/DeleteAllSharedVaultInvitesResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/DeleteAllSharedVaultInvitesResponse.ts new file mode 100644 index 000000000..0d1db7a4e --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/DeleteAllSharedVaultInvitesResponse.ts @@ -0,0 +1,3 @@ +export type DeleteAllSharedVaultInvitesResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/DeleteInviteResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/DeleteInviteResponse.ts new file mode 100644 index 000000000..4ae224eff --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/DeleteInviteResponse.ts @@ -0,0 +1,3 @@ +export type DeleteInviteResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/GetOutboundUserInvitesResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/GetOutboundUserInvitesResponse.ts new file mode 100644 index 000000000..0d4922d8e --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/GetOutboundUserInvitesResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type GetOutboundUserInvitesResponse = { + invites: SharedVaultInviteServerHash[] +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/GetSharedVaultInvitesResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/GetSharedVaultInvitesResponse.ts new file mode 100644 index 000000000..1ca12153d --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/GetSharedVaultInvitesResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type GetSharedVaultInvitesResponse = { + invites: SharedVaultInviteServerHash[] +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/GetUserInvitesResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/GetUserInvitesResponse.ts new file mode 100644 index 000000000..e5da886e4 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/GetUserInvitesResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type GetUserInvitesResponse = { + invites: SharedVaultInviteServerHash[] +} diff --git a/packages/api/src/Domain/Response/SharedVaultInvites/UpdateSharedVaultInviteResponse.ts b/packages/api/src/Domain/Response/SharedVaultInvites/UpdateSharedVaultInviteResponse.ts new file mode 100644 index 000000000..20498fac9 --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultInvites/UpdateSharedVaultInviteResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type UpdateSharedVaultInviteResponse = { + invite: SharedVaultInviteServerHash +} diff --git a/packages/api/src/Domain/Response/SharedVaultUsers/DeleteSharedVaultUserResponse.ts b/packages/api/src/Domain/Response/SharedVaultUsers/DeleteSharedVaultUserResponse.ts new file mode 100644 index 000000000..ea9d71bdf --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultUsers/DeleteSharedVaultUserResponse.ts @@ -0,0 +1,3 @@ +export type DeleteSharedVaultUserResponse = { + success: boolean +} diff --git a/packages/api/src/Domain/Response/SharedVaultUsers/GetSharedVaultUsersResponse.ts b/packages/api/src/Domain/Response/SharedVaultUsers/GetSharedVaultUsersResponse.ts new file mode 100644 index 000000000..7d159f0eb --- /dev/null +++ b/packages/api/src/Domain/Response/SharedVaultUsers/GetSharedVaultUsersResponse.ts @@ -0,0 +1,5 @@ +import { SharedVaultUserServerHash } from '@standardnotes/responses' + +export type GetSharedVaultUsersResponse = { + users: SharedVaultUserServerHash[] +} diff --git a/packages/api/src/Domain/Response/User/UserUpdateResponse.ts b/packages/api/src/Domain/Response/User/UserUpdateResponse.ts new file mode 100644 index 000000000..aba9e6010 --- /dev/null +++ b/packages/api/src/Domain/Response/User/UserUpdateResponse.ts @@ -0,0 +1,6 @@ +export type UserUpdateResponse = { + user: { + uuid: string + email: string + } +} diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts index 4a183af46..05ef2d7cb 100644 --- a/packages/api/src/Domain/Response/index.ts +++ b/packages/api/src/Domain/Response/index.ts @@ -16,7 +16,9 @@ export * from './Subscription/SubscriptionInviteCancelResponseBody' export * from './Subscription/SubscriptionInviteDeclineResponseBody' export * from './Subscription/SubscriptionInviteListResponseBody' export * from './Subscription/SubscriptionInviteResponseBody' + export * from './User/UserDeletionResponseBody' export * from './User/UserRegistrationResponseBody' + export * from './UserRequest/UserRequestResponseBody' export * from './WebSocket/WebSocketConnectionTokenResponseBody' diff --git a/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServer.ts b/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServer.ts new file mode 100644 index 000000000..298e7ed46 --- /dev/null +++ b/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServer.ts @@ -0,0 +1,41 @@ +import { HttpResponse } from '@standardnotes/responses' +import { HttpServiceInterface } from '../../Http' +import { CreateAsymmetricMessageParams } from '../../Request/AsymmetricMessage/CreateAsymmetricMessageParams' +import { CreateAsymmetricMessageResponse } from '../../Response/AsymmetricMessage/CreateAsymmetricMessageResponse' +import { AsymmetricMessagesPaths } from './Paths' +import { GetUserAsymmetricMessagesResponse } from '../../Response/AsymmetricMessage/GetUserAsymmetricMessagesResponse' +import { AsymmetricMessageServerInterface } from './AsymmetricMessageServerInterface' +import { DeleteAsymmetricMessageRequestParams } from '../../Request/AsymmetricMessage/DeleteAsymmetricMessageRequestParams' +import { DeleteAsymmetricMessageResponse } from '../../Response/AsymmetricMessage/DeleteAsymmetricMessageResponse' + +export class AsymmetricMessageServer implements AsymmetricMessageServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + createMessage(params: CreateAsymmetricMessageParams): Promise> { + return this.httpService.post(AsymmetricMessagesPaths.createMessage, { + recipient_uuid: params.recipientUuid, + encrypted_message: params.encryptedMessage, + replaceability_identifier: params.replaceabilityIdentifier, + }) + } + + getInboundUserMessages(): Promise> { + return this.httpService.get(AsymmetricMessagesPaths.getInboundUserMessages()) + } + + getOutboundUserMessages(): Promise> { + return this.httpService.get(AsymmetricMessagesPaths.getOutboundUserMessages()) + } + + getMessages(): Promise> { + return this.httpService.get(AsymmetricMessagesPaths.getMessages) + } + + deleteMessage(params: DeleteAsymmetricMessageRequestParams): Promise> { + return this.httpService.delete(AsymmetricMessagesPaths.deleteMessage(params.messageUuid)) + } + + deleteAllInboundMessages(): Promise> { + return this.httpService.delete(AsymmetricMessagesPaths.deleteAllInboundMessages) + } +} diff --git a/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServerInterface.ts b/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServerInterface.ts new file mode 100644 index 000000000..046eede55 --- /dev/null +++ b/packages/api/src/Domain/Server/AsymmetricMessage/AsymmetricMessageServerInterface.ts @@ -0,0 +1,17 @@ +import { HttpResponse } from '@standardnotes/responses' +import { CreateAsymmetricMessageParams } from '../../Request/AsymmetricMessage/CreateAsymmetricMessageParams' +import { CreateAsymmetricMessageResponse } from '../../Response/AsymmetricMessage/CreateAsymmetricMessageResponse' +import { GetUserAsymmetricMessagesResponse } from '../../Response/AsymmetricMessage/GetUserAsymmetricMessagesResponse' +import { DeleteAsymmetricMessageRequestParams } from '../../Request/AsymmetricMessage/DeleteAsymmetricMessageRequestParams' +import { DeleteAsymmetricMessageResponse } from '../../Response/AsymmetricMessage/DeleteAsymmetricMessageResponse' + +export interface AsymmetricMessageServerInterface { + createMessage(params: CreateAsymmetricMessageParams): Promise> + + getInboundUserMessages(): Promise> + getOutboundUserMessages(): Promise> + getMessages(): Promise> + + deleteMessage(params: DeleteAsymmetricMessageRequestParams): Promise> + deleteAllInboundMessages(): Promise> +} diff --git a/packages/api/src/Domain/Server/AsymmetricMessage/Paths.ts b/packages/api/src/Domain/Server/AsymmetricMessage/Paths.ts new file mode 100644 index 000000000..1f675fed8 --- /dev/null +++ b/packages/api/src/Domain/Server/AsymmetricMessage/Paths.ts @@ -0,0 +1,9 @@ +export const AsymmetricMessagesPaths = { + createMessage: '/v1/asymmetric-messages', + getMessages: '/v1/asymmetric-messages', + updateMessage: (messageUuid: string) => `/v1/asymmetric-messages/${messageUuid}`, + getInboundUserMessages: () => '/v1/asymmetric-messages', + getOutboundUserMessages: () => '/v1/asymmetric-messages/outbound', + deleteMessage: (messageUuid: string) => `/v1/asymmetric-messages/${messageUuid}`, + deleteAllInboundMessages: '/v1/asymmetric-messages/inbound', +} diff --git a/packages/api/src/Domain/Server/SharedVault/Paths.ts b/packages/api/src/Domain/Server/SharedVault/Paths.ts new file mode 100644 index 000000000..8c919f93f --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVault/Paths.ts @@ -0,0 +1,7 @@ +export const SharedVaultsPaths = { + getSharedVaults: '/v1/shared-vaults', + createSharedVault: '/v1/shared-vaults', + deleteSharedVault: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}`, + updateSharedVault: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}`, + createSharedVaultFileValetToken: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}/valet-tokens`, +} diff --git a/packages/api/src/Domain/Server/SharedVault/SharedVaultServer.ts b/packages/api/src/Domain/Server/SharedVault/SharedVaultServer.ts new file mode 100644 index 000000000..f8e719cb2 --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVault/SharedVaultServer.ts @@ -0,0 +1,37 @@ +import { HttpResponse } from '@standardnotes/responses' +import { HttpServiceInterface } from '../../Http' +import { SharedVaultServerInterface } from './SharedVaultServerInterface' +import { SharedVaultsPaths } from './Paths' +import { CreateSharedVaultResponse } from '../../Response/SharedVault/CreateSharedVaultResponse' +import { GetSharedVaultsResponse } from '../../Response/SharedVault/GetSharedVaultsResponse' +import { CreateSharedVaultValetTokenResponse } from '../../Response/SharedVault/CreateSharedVaultValetTokenResponse' +import { CreateSharedVaultValetTokenParams } from '../../Request/SharedVault/CreateSharedVaultValetTokenParams' + +export class SharedVaultServer implements SharedVaultServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + getSharedVaults(): Promise> { + return this.httpService.get(SharedVaultsPaths.getSharedVaults) + } + + createSharedVault(): Promise> { + return this.httpService.post(SharedVaultsPaths.createSharedVault) + } + + deleteSharedVault(params: { sharedVaultUuid: string }): Promise> { + return this.httpService.delete(SharedVaultsPaths.deleteSharedVault(params.sharedVaultUuid)) + } + + createSharedVaultFileValetToken( + params: CreateSharedVaultValetTokenParams, + ): Promise> { + return this.httpService.post(SharedVaultsPaths.createSharedVaultFileValetToken(params.sharedVaultUuid), { + file_uuid: params.fileUuid, + remote_identifier: params.remoteIdentifier, + operation: params.operation, + unencrypted_file_size: params.unencryptedFileSize, + move_operation_type: params.moveOperationType, + shared_vault_to_shared_vault_move_target_uuid: params.sharedVaultToSharedVaultMoveTargetUuid, + }) + } +} diff --git a/packages/api/src/Domain/Server/SharedVault/SharedVaultServerInterface.ts b/packages/api/src/Domain/Server/SharedVault/SharedVaultServerInterface.ts new file mode 100644 index 000000000..9059f7105 --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVault/SharedVaultServerInterface.ts @@ -0,0 +1,17 @@ +import { HttpResponse } from '@standardnotes/responses' +import { CreateSharedVaultResponse } from '../../Response/SharedVault/CreateSharedVaultResponse' + +import { GetSharedVaultsResponse } from '../../Response/SharedVault/GetSharedVaultsResponse' +import { CreateSharedVaultValetTokenResponse } from '../../Response/SharedVault/CreateSharedVaultValetTokenResponse' +import { CreateSharedVaultValetTokenParams } from '../../Request/SharedVault/CreateSharedVaultValetTokenParams' + +export interface SharedVaultServerInterface { + getSharedVaults(): Promise> + + createSharedVault(): Promise> + deleteSharedVault(params: { sharedVaultUuid: string }): Promise> + + createSharedVaultFileValetToken( + params: CreateSharedVaultValetTokenParams, + ): Promise> +} diff --git a/packages/api/src/Domain/Server/SharedVaultInvites/Paths.ts b/packages/api/src/Domain/Server/SharedVaultInvites/Paths.ts new file mode 100644 index 000000000..3220745fd --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultInvites/Paths.ts @@ -0,0 +1,16 @@ +export const SharedVaultInvitesPaths = { + createInvite: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}/invites`, + updateInvite: (sharedVaultUuid: string, inviteUuid: string) => + `/v1/shared-vaults/${sharedVaultUuid}/invites/${inviteUuid}`, + acceptInvite: (sharedVaultUuid: string, inviteUuid: string) => + `/v1/shared-vaults/${sharedVaultUuid}/invites/${inviteUuid}/accept`, + declineInvite: (sharedVaultUuid: string, inviteUuid: string) => + `/v1/shared-vaults/${sharedVaultUuid}/invites/${inviteUuid}/decline`, + getInboundUserInvites: () => '/v1/shared-vaults/invites', + getOutboundUserInvites: () => '/v1/shared-vaults/invites/outbound', + getSharedVaultInvites: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}/invites`, + deleteInvite: (sharedVaultUuid: string, inviteUuid: string) => + `/v1/shared-vaults/${sharedVaultUuid}/invites/${inviteUuid}`, + deleteAllSharedVaultInvites: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}/invites`, + deleteAllInboundInvites: '/v1/shared-vaults/invites/inbound', +} diff --git a/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServer.ts b/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServer.ts new file mode 100644 index 000000000..85e13a01e --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServer.ts @@ -0,0 +1,75 @@ +import { HttpResponse } from '@standardnotes/responses' +import { HttpServiceInterface } from '../../Http' + +import { AcceptInviteRequestParams } from '../../Request/SharedVaultInvites/AcceptInviteRequestParams' +import { AcceptInviteResponse } from '../../Response/SharedVaultInvites/AcceptInviteResponse' +import { CreateSharedVaultInviteParams } from '../../Request/SharedVaultInvites/CreateSharedVaultInviteParams' +import { CreateSharedVaultInviteResponse } from '../../Response/SharedVaultInvites/CreateSharedVaultInviteResponse' +import { DeclineInviteRequestParams } from '../../Request/SharedVaultInvites/DeclineInviteRequestParams' +import { DeclineInviteResponse } from '../../Response/SharedVaultInvites/DeclineInviteResponse' +import { DeleteInviteRequestParams } from '../../Request/SharedVaultInvites/DeleteInviteRequestParams' +import { DeleteInviteResponse } from '../../Response/SharedVaultInvites/DeleteInviteResponse' +import { GetSharedVaultInvitesRequestParams } from '../../Request/SharedVaultInvites/GetSharedVaultInvitesRequestParams' +import { GetSharedVaultInvitesResponse } from '../../Response/SharedVaultInvites/GetSharedVaultInvitesResponse' +import { GetUserInvitesResponse } from '../../Response/SharedVaultInvites/GetUserInvitesResponse' +import { SharedVaultInvitesPaths } from './Paths' +import { SharedVaultInvitesServerInterface } from './SharedVaultInvitesServerInterface' +import { UpdateSharedVaultInviteParams } from '../../Request/SharedVaultInvites/UpdateSharedVaultInviteParams' +import { UpdateSharedVaultInviteResponse } from '../../Response/SharedVaultInvites/UpdateSharedVaultInviteResponse' +import { DeleteAllSharedVaultInvitesRequestParams } from '../../Request/SharedVaultInvites/DeleteAllSharedVaultInvitesRequestParams' +import { DeleteAllSharedVaultInvitesResponse } from '../../Response/SharedVaultInvites/DeleteAllSharedVaultInvitesResponse' + +export class SharedVaultInvitesServer implements SharedVaultInvitesServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + createInvite(params: CreateSharedVaultInviteParams): Promise> { + return this.httpService.post(SharedVaultInvitesPaths.createInvite(params.sharedVaultUuid), { + recipient_uuid: params.recipientUuid, + encrypted_message: params.encryptedMessage, + permissions: params.permissions, + }) + } + + updateInvite(params: UpdateSharedVaultInviteParams): Promise> { + return this.httpService.patch(SharedVaultInvitesPaths.updateInvite(params.sharedVaultUuid, params.inviteUuid), { + encrypted_message: params.encryptedMessage, + permissions: params.permissions, + }) + } + + acceptInvite(params: AcceptInviteRequestParams): Promise> { + return this.httpService.post(SharedVaultInvitesPaths.acceptInvite(params.sharedVaultUuid, params.inviteUuid)) + } + + declineInvite(params: DeclineInviteRequestParams): Promise> { + return this.httpService.post(SharedVaultInvitesPaths.declineInvite(params.sharedVaultUuid, params.inviteUuid)) + } + + getInboundUserInvites(): Promise> { + return this.httpService.get(SharedVaultInvitesPaths.getInboundUserInvites()) + } + + getOutboundUserInvites(): Promise> { + return this.httpService.get(SharedVaultInvitesPaths.getOutboundUserInvites()) + } + + getSharedVaultInvites( + params: GetSharedVaultInvitesRequestParams, + ): Promise> { + return this.httpService.get(SharedVaultInvitesPaths.getSharedVaultInvites(params.sharedVaultUuid)) + } + + deleteInvite(params: DeleteInviteRequestParams): Promise> { + return this.httpService.delete(SharedVaultInvitesPaths.deleteInvite(params.sharedVaultUuid, params.inviteUuid)) + } + + deleteAllSharedVaultInvites( + params: DeleteAllSharedVaultInvitesRequestParams, + ): Promise> { + return this.httpService.delete(SharedVaultInvitesPaths.deleteAllSharedVaultInvites(params.sharedVaultUuid)) + } + + deleteAllInboundInvites(): Promise> { + return this.httpService.delete(SharedVaultInvitesPaths.deleteAllInboundInvites) + } +} diff --git a/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServerInterface.ts b/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServerInterface.ts new file mode 100644 index 000000000..f5fa2a498 --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultInvites/SharedVaultInvitesServerInterface.ts @@ -0,0 +1,35 @@ +import { HttpResponse } from '@standardnotes/responses' +import { AcceptInviteRequestParams } from '../../Request/SharedVaultInvites/AcceptInviteRequestParams' +import { AcceptInviteResponse } from '../../Response/SharedVaultInvites/AcceptInviteResponse' +import { CreateSharedVaultInviteParams } from '../../Request/SharedVaultInvites/CreateSharedVaultInviteParams' +import { CreateSharedVaultInviteResponse } from '../../Response/SharedVaultInvites/CreateSharedVaultInviteResponse' +import { DeclineInviteRequestParams } from '../../Request/SharedVaultInvites/DeclineInviteRequestParams' +import { DeclineInviteResponse } from '../../Response/SharedVaultInvites/DeclineInviteResponse' +import { DeleteInviteRequestParams } from '../../Request/SharedVaultInvites/DeleteInviteRequestParams' +import { DeleteInviteResponse } from '../../Response/SharedVaultInvites/DeleteInviteResponse' +import { GetSharedVaultInvitesRequestParams } from '../../Request/SharedVaultInvites/GetSharedVaultInvitesRequestParams' +import { GetSharedVaultInvitesResponse } from '../../Response/SharedVaultInvites/GetSharedVaultInvitesResponse' +import { GetUserInvitesResponse } from '../../Response/SharedVaultInvites/GetUserInvitesResponse' +import { UpdateSharedVaultInviteParams } from '../../Request/SharedVaultInvites/UpdateSharedVaultInviteParams' +import { UpdateSharedVaultInviteResponse } from '../../Response/SharedVaultInvites/UpdateSharedVaultInviteResponse' +import { DeleteAllSharedVaultInvitesRequestParams } from '../../Request/SharedVaultInvites/DeleteAllSharedVaultInvitesRequestParams' +import { DeleteAllSharedVaultInvitesResponse } from '../../Response/SharedVaultInvites/DeleteAllSharedVaultInvitesResponse' + +export interface SharedVaultInvitesServerInterface { + createInvite(params: CreateSharedVaultInviteParams): Promise> + updateInvite(params: UpdateSharedVaultInviteParams): Promise> + acceptInvite(params: AcceptInviteRequestParams): Promise> + declineInvite(params: DeclineInviteRequestParams): Promise> + + getInboundUserInvites(): Promise> + getOutboundUserInvites(): Promise> + getSharedVaultInvites( + params: GetSharedVaultInvitesRequestParams, + ): Promise> + + deleteAllSharedVaultInvites( + params: DeleteAllSharedVaultInvitesRequestParams, + ): Promise> + deleteInvite(params: DeleteInviteRequestParams): Promise> + deleteAllInboundInvites(): Promise> +} diff --git a/packages/api/src/Domain/Server/SharedVaultUsers/Paths.ts b/packages/api/src/Domain/Server/SharedVaultUsers/Paths.ts new file mode 100644 index 000000000..ecab7f636 --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultUsers/Paths.ts @@ -0,0 +1,5 @@ +export const SharedVaultUsersPaths = { + getSharedVaultUsers: (sharedVaultUuid: string) => `/v1/shared-vaults/${sharedVaultUuid}/users`, + deleteSharedVaultUser: (sharedVaultUuid: string, userUuid: string) => + `/v1/shared-vaults/${sharedVaultUuid}/users/${userUuid}`, +} diff --git a/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServer.ts b/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServer.ts new file mode 100644 index 000000000..299b2e3ba --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServer.ts @@ -0,0 +1,22 @@ +import { HttpResponse } from '@standardnotes/responses' +import { HttpServiceInterface } from '../../Http' +import { GetSharedVaultUsersRequestParams } from '../../Request/SharedVaultUser/GetSharedVaultUsersRequestParams' +import { DeleteSharedVaultUserRequestParams } from '../../Request/SharedVaultUser/DeleteSharedVaultUserRequestParams' +import { DeleteSharedVaultUserResponse } from '../../Response/SharedVaultUsers/DeleteSharedVaultUserResponse' +import { SharedVaultUsersServerInterface } from './SharedVaultUsersServerInterface' +import { SharedVaultUsersPaths } from './Paths' +import { GetSharedVaultUsersResponse } from '../../Response/SharedVaultUsers/GetSharedVaultUsersResponse' + +export class SharedVaultUsersServer implements SharedVaultUsersServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + getSharedVaultUsers(params: GetSharedVaultUsersRequestParams): Promise> { + return this.httpService.get(SharedVaultUsersPaths.getSharedVaultUsers(params.sharedVaultUuid)) + } + + deleteSharedVaultUser( + params: DeleteSharedVaultUserRequestParams, + ): Promise> { + return this.httpService.delete(SharedVaultUsersPaths.deleteSharedVaultUser(params.sharedVaultUuid, params.userUuid)) + } +} diff --git a/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServerInterface.ts b/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServerInterface.ts new file mode 100644 index 000000000..9cb87c423 --- /dev/null +++ b/packages/api/src/Domain/Server/SharedVaultUsers/SharedVaultUsersServerInterface.ts @@ -0,0 +1,13 @@ +import { HttpResponse } from '@standardnotes/responses' +import { GetSharedVaultUsersRequestParams } from '../../Request/SharedVaultUser/GetSharedVaultUsersRequestParams' +import { DeleteSharedVaultUserRequestParams } from '../../Request/SharedVaultUser/DeleteSharedVaultUserRequestParams' +import { DeleteSharedVaultUserResponse } from '../../Response/SharedVaultUsers/DeleteSharedVaultUserResponse' +import { GetSharedVaultUsersResponse } from '../../Response/SharedVaultUsers/GetSharedVaultUsersResponse' + +export interface SharedVaultUsersServerInterface { + getSharedVaultUsers(params: GetSharedVaultUsersRequestParams): Promise> + + deleteSharedVaultUser( + params: DeleteSharedVaultUserRequestParams, + ): Promise> +} diff --git a/packages/api/src/Domain/Server/User/Paths.ts b/packages/api/src/Domain/Server/User/Paths.ts index 9c9252775..55cc94ad1 100644 --- a/packages/api/src/Domain/Server/User/Paths.ts +++ b/packages/api/src/Domain/Server/User/Paths.ts @@ -1,5 +1,6 @@ const UserPaths = { register: '/v1/users', + updateAccount: (userUuid: string) => `/v1/users/${userUuid}`, deleteAccount: (userUuid: string) => `/v1/users/${userUuid}`, } diff --git a/packages/api/src/Domain/Server/User/UserServer.ts b/packages/api/src/Domain/Server/User/UserServer.ts index 7b5e22838..cafe32514 100644 --- a/packages/api/src/Domain/Server/User/UserServer.ts +++ b/packages/api/src/Domain/Server/User/UserServer.ts @@ -1,3 +1,4 @@ +import { UserUpdateResponse } from './../../Response/User/UserUpdateResponse' import { HttpServiceInterface } from '../../Http/HttpServiceInterface' import { UserDeletionRequestParams } from '../../Request/User/UserDeletionRequestParams' import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams' @@ -6,6 +7,7 @@ import { UserDeletionResponseBody } from '../../Response/User/UserDeletionRespon import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' import { Paths } from './Paths' import { UserServerInterface } from './UserServerInterface' +import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' export class UserServer implements UserServerInterface { constructor(private httpService: HttpServiceInterface) {} @@ -17,4 +19,8 @@ export class UserServer implements UserServerInterface { async register(params: UserRegistrationRequestParams): Promise> { return this.httpService.post(Paths.v1.register, params) } + + async update(params: UserUpdateRequestParams): Promise> { + return this.httpService.patch(Paths.v1.updateAccount(params.user_uuid), params) + } } diff --git a/packages/api/src/Domain/Server/User/UserServerInterface.ts b/packages/api/src/Domain/Server/User/UserServerInterface.ts index b2016019b..1ce40c19d 100644 --- a/packages/api/src/Domain/Server/User/UserServerInterface.ts +++ b/packages/api/src/Domain/Server/User/UserServerInterface.ts @@ -3,8 +3,11 @@ import { UserDeletionRequestParams } from '../../Request/User/UserDeletionReques import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams' import { UserDeletionResponseBody } from '../../Response/User/UserDeletionResponseBody' import { UserRegistrationResponseBody } from '../../Response/User/UserRegistrationResponseBody' +import { UserUpdateResponse } from '../../Response/User/UserUpdateResponse' +import { UserUpdateRequestParams } from '../../Request/User/UserUpdateRequestParams' export interface UserServerInterface { register(params: UserRegistrationRequestParams): Promise> deleteAccount(params: UserDeletionRequestParams): Promise> + update(params: UserUpdateRequestParams): Promise> } diff --git a/packages/api/src/Domain/Server/index.ts b/packages/api/src/Domain/Server/index.ts index f78be2fec..b9ed2d375 100644 --- a/packages/api/src/Domain/Server/index.ts +++ b/packages/api/src/Domain/Server/index.ts @@ -1,14 +1,32 @@ export * from './Auth/AuthServer' export * from './Auth/AuthServerInterface' + export * from './Authenticator/AuthenticatorServer' export * from './Authenticator/AuthenticatorServerInterface' + export * from './Revision/RevisionServer' export * from './Revision/RevisionServerInterface' + +export * from './AsymmetricMessage/AsymmetricMessageServer' +export * from './AsymmetricMessage/AsymmetricMessageServerInterface' + +export * from './SharedVault/SharedVaultServer' +export * from './SharedVault/SharedVaultServerInterface' + +export * from './SharedVaultUsers/SharedVaultUsersServer' +export * from './SharedVaultUsers/SharedVaultUsersServerInterface' + export * from './Subscription/SubscriptionServer' export * from './Subscription/SubscriptionServerInterface' + +export * from './SharedVaultInvites/SharedVaultInvitesServer' +export * from './SharedVaultInvites/SharedVaultInvitesServerInterface' + export * from './User/UserServer' export * from './User/UserServerInterface' + export * from './UserRequest/UserRequestServer' export * from './UserRequest/UserRequestServerInterface' + export * from './WebSocket/WebSocketServer' export * from './WebSocket/WebSocketServerInterface' diff --git a/packages/encryption/package.json b/packages/encryption/package.json index b898c53e3..b19c07e2c 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -5,22 +5,15 @@ "node": ">=16.0.0 <17.0.0" }, "description": "Payload encryption used in SNJS library", - "main": "dist/index.js", + "main": "./src/index.ts", + "private": true, "author": "Standard Notes", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], "license": "AGPL-3.0-or-later", "scripts": { - "clean": "rm -fr dist", - "prestart": "yarn clean", - "start": "tsc -p tsconfig.json --watch", - "prebuild": "yarn clean", - "build": "tsc -p tsconfig.json", "lint": "eslint src --ext .ts", "format": "prettier --write src", - "test": "jest" + "test": "jest", + "build": "echo 'Empty build script required for yarn topological install'" }, "devDependencies": { "@standardnotes/config": "2.4.3", @@ -35,7 +28,7 @@ "typescript": "*" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/models": "workspace:*", "@standardnotes/responses": "workspace:*", "@standardnotes/sncrypto-common": "workspace:*", diff --git a/packages/encryption/src/Domain/Algorithm.ts b/packages/encryption/src/Domain/Algorithm.ts index 9605320a9..45c320380 100644 --- a/packages/encryption/src/Domain/Algorithm.ts +++ b/packages/encryption/src/Domain/Algorithm.ts @@ -1,3 +1,5 @@ +import { SodiumConstant } from '@standardnotes/sncrypto-common' + export const V001Algorithm = Object.freeze({ SaltSeedLength: 128, /** @@ -41,11 +43,21 @@ export enum V004Algorithm { ArgonIterations = 5, ArgonMemLimit = 67108864, ArgonOutputKeyBytes = 64, + EncryptionKeyLength = 256, EncryptionNonceLength = 192, -} -export enum V005Algorithm { AsymmetricEncryptionNonceLength = 192, - SymmetricEncryptionNonceLength = 192, + + MasterKeyEncryptionKeyPairSubKeyNumber = 1, + MasterKeyEncryptionKeyPairSubKeyContext = 'sn-pkc-e', + MasterKeyEncryptionKeyPairSubKeyBytes = SodiumConstant.crypto_box_SEEDBYTES, + + MasterKeySigningKeyPairSubKeyNumber = 2, + MasterKeySigningKeyPairSubKeyContext = 'sn-pkc-s', + MasterKeySigningKeyPairSubKeyBytes = SodiumConstant.crypto_sign_SEEDBYTES, + + PayloadKeyHashingKeySubKeyNumber = 1, + PayloadKeyHashingKeySubKeyContext = 'sn-sym-h', + PayloadKeyHashingKeySubKeyBytes = SodiumConstant.crypto_generichash_KEYBYTES, } diff --git a/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts index 8201dbda9..383b76eb6 100644 --- a/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts +++ b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts @@ -7,11 +7,10 @@ import { HistoryEntryInterface, ItemsKeyContent, ItemsKeyInterface, - RootKeyInterface, } from '@standardnotes/models' -export function isItemsKey(x: ItemsKeyInterface | RootKeyInterface): x is ItemsKeyInterface { - return x.content_type === ContentType.ItemsKey +export function isItemsKey(x: unknown): x is ItemsKeyInterface { + return (x as ItemsKeyInterface).content_type === ContentType.ItemsKey } /** diff --git a/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKey.ts b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKey.ts new file mode 100644 index 000000000..9b52d4794 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKey.ts @@ -0,0 +1,41 @@ +import { ContentType, ProtocolVersion } from '@standardnotes/common' +import { + ConflictStrategy, + DecryptedItem, + DecryptedItemInterface, + DecryptedPayloadInterface, + HistoryEntryInterface, + KeySystemItemsKeyContent, + KeySystemItemsKeyInterface, +} from '@standardnotes/models' + +export function isKeySystemItemsKey(x: unknown): x is KeySystemItemsKeyInterface { + return (x as KeySystemItemsKeyInterface).content_type === ContentType.KeySystemItemsKey +} + +/** + * A key used to encrypt other items. Items keys are synced and persisted. + */ +export class KeySystemItemsKey extends DecryptedItem implements KeySystemItemsKeyInterface { + creationTimestamp: number + keyVersion: ProtocolVersion + itemsKey: string + rootKeyToken: string + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.creationTimestamp = payload.content.creationTimestamp + this.keyVersion = payload.content.version + this.itemsKey = this.payload.content.itemsKey + this.rootKeyToken = this.payload.content.rootKeyToken + } + + /** Do not duplicate vault items keys. Always keep original */ + override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } +} diff --git a/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKeyMutator.ts b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKeyMutator.ts new file mode 100644 index 000000000..5e8ee2793 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/KeySystemItemsKeyMutator.ts @@ -0,0 +1,3 @@ +import { DecryptedItemMutator, KeySystemItemsKeyContent } from '@standardnotes/models' + +export class KeySystemItemsKeyMutator extends DecryptedItemMutator {} diff --git a/packages/encryption/src/Domain/Keys/KeySystemItemsKey/Registration.ts b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/Registration.ts new file mode 100644 index 000000000..721cecb64 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/KeySystemItemsKey/Registration.ts @@ -0,0 +1,10 @@ +import { ContentType } from '@standardnotes/common' +import { DecryptedItemMutator, KeySystemItemsKeyContent, RegisterItemClass } from '@standardnotes/models' +import { KeySystemItemsKey } from './KeySystemItemsKey' +import { KeySystemItemsKeyMutator } from './KeySystemItemsKeyMutator' + +RegisterItemClass( + ContentType.KeySystemItemsKey, + KeySystemItemsKey, + KeySystemItemsKeyMutator as unknown as DecryptedItemMutator, +) diff --git a/packages/encryption/src/Domain/Keys/RootKey/Functions.ts b/packages/encryption/src/Domain/Keys/RootKey/Functions.ts index 793993b78..2cc0d30d5 100644 --- a/packages/encryption/src/Domain/Keys/RootKey/Functions.ts +++ b/packages/encryption/src/Domain/Keys/RootKey/Functions.ts @@ -5,11 +5,12 @@ import { PayloadTimestampDefaults, RootKeyContent, RootKeyContentSpecialized, + RootKeyInterface, } from '@standardnotes/models' import { UuidGenerator } from '@standardnotes/utils' import { SNRootKey } from './RootKey' -export function CreateNewRootKey(content: RootKeyContentSpecialized): SNRootKey { +export function CreateNewRootKey(content: RootKeyContentSpecialized): K { const uuid = UuidGenerator.GenerateUuid() const payload = new DecryptedPayload({ @@ -19,7 +20,7 @@ export function CreateNewRootKey(content: RootKeyContentSpecialized): SNRootKey ...PayloadTimestampDefaults(), }) - return new SNRootKey(payload) + return new SNRootKey(payload) as K } export function FillRootKeyContent(content: Partial): RootKeyContent { @@ -37,15 +38,3 @@ export function FillRootKeyContent(content: Partial): return FillItemContentSpecialized(content) } - -export function ContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean { - return ( - contentType === ContentType.RootKey || - contentType === ContentType.ItemsKey || - contentType === ContentType.EncryptedStorage - ) -} - -export function ItemContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean { - return contentType === ContentType.ItemsKey -} diff --git a/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts b/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts index 3b11dc825..03e77431a 100644 --- a/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts +++ b/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts @@ -7,7 +7,7 @@ import { RootKeyContentInStorage, RootKeyInterface, } from '@standardnotes/models' -import { timingSafeEqual } from '@standardnotes/sncrypto-common' +import { PkcKeyPair, timingSafeEqual } from '@standardnotes/sncrypto-common' import { SNRootKeyParams } from './RootKeyParams' /** @@ -47,6 +47,14 @@ export class SNRootKey extends DecryptedItem implements RootKeyI return this.content.serverPassword } + get encryptionKeyPair(): PkcKeyPair | undefined { + return this.content.encryptionKeyPair + } + + get signingKeyPair(): PkcKeyPair | undefined { + return this.content.signingKeyPair + } + /** 003 and below only. */ public get dataAuthenticationKey(): string | undefined { return this.content.dataAuthenticationKey @@ -84,6 +92,8 @@ export class SNRootKey extends DecryptedItem implements RootKeyI const values: NamespacedRootKeyInKeychain = { version: this.keyVersion, masterKey: this.masterKey, + encryptionKeyPair: this.encryptionKeyPair, + signingKeyPair: this.signingKeyPair, } if (this.dataAuthenticationKey) { diff --git a/packages/encryption/src/Domain/Operator/001/Operator001.ts b/packages/encryption/src/Domain/Operator/001/Operator001.ts index 878ea5884..93fe76c8d 100644 --- a/packages/encryption/src/Domain/Operator/001/Operator001.ts +++ b/packages/encryption/src/Domain/Operator/001/Operator001.ts @@ -8,8 +8,13 @@ import { ItemsKeyContent, ItemsKeyInterface, PayloadTimestampDefaults, + KeySystemItemsKeyInterface, + KeySystemIdentifier, + KeySystemRootKeyInterface, + RootKeyInterface, + KeySystemRootKeyParamsInterface, } from '@standardnotes/models' -import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' import { firstHalfOfString, secondHalfOfString, splitString, UuidGenerator } from '@standardnotes/utils' import { V001Algorithm } from '../../Algorithm' import { isItemsKey } from '../../Keys/ItemsKey/ItemsKey' @@ -17,11 +22,16 @@ import { CreateNewRootKey } from '../../Keys/RootKey/Functions' import { Create001KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' import { SNRootKey } from '../../Keys/RootKey/RootKey' import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' -import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { EncryptedOutputParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../Types/DecryptedParameters' import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' import { LegacyAttachedData } from '../../Types/LegacyAttachedData' import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' -import { AsynchronousOperator } from '../Operator' +import { OperatorInterface } from '../OperatorInterface/OperatorInterface' +import { PublicKeySet } from '../Types/PublicKeySet' +import { AsymmetricDecryptResult } from '../Types/AsymmetricDecryptResult' +import { AsymmetricSignatureVerificationDetachedResult } from '../Types/AsymmetricSignatureVerificationDetachedResult' +import { AsyncOperatorInterface } from '../OperatorInterface/AsyncOperatorInterface' const NO_IV = '00000000000000000000000000000000' @@ -29,7 +39,7 @@ const NO_IV = '00000000000000000000000000000000' * @deprecated * A legacy operator no longer used to generate new accounts */ -export class SNProtocolOperator001 implements AsynchronousOperator { +export class SNProtocolOperator001 implements OperatorInterface, AsyncOperatorInterface { protected readonly crypto: PureCryptoInterface constructor(crypto: PureCryptoInterface) { @@ -68,11 +78,11 @@ export class SNProtocolOperator001 implements AsynchronousOperator { return CreateDecryptedItemFromPayload(payload) } - public async createRootKey( + public async createRootKey( identifier: string, password: string, origination: KeyParamsOrigination, - ): Promise { + ): Promise { const pwCost = V001Algorithm.PbkdfMinCost as number const pwNonce = this.crypto.generateRandomKey(V001Algorithm.SaltSeedLength) const pwSalt = await this.crypto.unsafeSha1(identifier + 'SN' + pwNonce) @@ -90,13 +100,13 @@ export class SNProtocolOperator001 implements AsynchronousOperator { return this.deriveKey(password, keyParams) } - public getPayloadAuthenticatedData( - _encrypted: EncryptedParameters, + public getPayloadAuthenticatedDataForExternalUse( + _encrypted: EncryptedOutputParameters, ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { return undefined } - public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { return this.deriveKey(password, keyParams) } @@ -111,7 +121,7 @@ export class SNProtocolOperator001 implements AsynchronousOperator { public async generateEncryptedParametersAsync( payload: DecryptedPayloadInterface, key: ItemsKeyInterface | SNRootKey, - ): Promise { + ): Promise { /** * Generate new item key that is double the key size. * Will be split to create encryption key and authentication key. @@ -132,16 +142,19 @@ export class SNProtocolOperator001 implements AsynchronousOperator { return { uuid: payload.uuid, + content_type: payload.content_type, items_key_id: isItemsKey(key) ? key.uuid : undefined, content: ciphertext, enc_item_key: encItemKey, auth_hash: authHash, version: this.version, + key_system_identifier: payload.key_system_identifier, + shared_vault_uuid: payload.shared_vault_uuid, } } public async generateDecryptedParametersAsync( - encrypted: EncryptedParameters, + encrypted: EncryptedOutputParameters, key: ItemsKeyInterface | SNRootKey, ): Promise | ErrorDecryptingParameters> { if (!encrypted.enc_item_key) { @@ -178,6 +191,7 @@ export class SNProtocolOperator001 implements AsynchronousOperator { return { uuid: encrypted.uuid, content: JSON.parse(content), + signatureData: { required: false, contentHash: '' }, } } } @@ -191,7 +205,7 @@ export class SNProtocolOperator001 implements AsynchronousOperator { } } - protected async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + protected async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { const derivedKey = await this.crypto.pbkdf2( password, keyParams.content001.pw_salt, @@ -205,11 +219,63 @@ export class SNProtocolOperator001 implements AsynchronousOperator { const partitions = splitString(derivedKey, 2) - return CreateNewRootKey({ + return CreateNewRootKey({ serverPassword: partitions[0], masterKey: partitions[1], version: ProtocolVersion.V001, keyParams: keyParams.getPortableValue(), }) } + + createRandomizedKeySystemRootKey(_dto: { systemIdentifier: string }): KeySystemRootKeyInterface { + throw new Error('Method not implemented.') + } + + createUserInputtedKeySystemRootKey(_dto: { + systemIdentifier: string + systemName: string + userInputtedPassword: string + }): KeySystemRootKeyInterface { + throw new Error('Method not implemented.') + } + + deriveUserInputtedKeySystemRootKey(_dto: { + keyParams: KeySystemRootKeyParamsInterface + userInputtedPassword: string + }): KeySystemRootKeyInterface { + throw new Error('Method not implemented.') + } + + createKeySystemItemsKey( + _uuid: string, + _keySystemIdentifier: KeySystemIdentifier, + _sharedVaultUuid: string | undefined, + ): KeySystemItemsKeyInterface { + throw new Error('Method not implemented.') + } + + versionForAsymmetricallyEncryptedString(_encryptedString: string): ProtocolVersion { + throw new Error('Method not implemented.') + } + + asymmetricEncrypt(_dto: { + stringToEncrypt: string + senderKeyPair: PkcKeyPair + senderSigningKeyPair: PkcKeyPair + recipientPublicKey: string + }): string { + throw new Error('Method not implemented.') + } + + asymmetricDecrypt(_dto: { stringToDecrypt: string; recipientSecretKey: string }): AsymmetricDecryptResult | null { + throw new Error('Method not implemented.') + } + + asymmetricSignatureVerifyDetached(_encryptedString: string): AsymmetricSignatureVerificationDetachedResult { + throw new Error('Method not implemented.') + } + + getSenderPublicKeySetFromAsymmetricallyEncryptedString(_string: string): PublicKeySet { + throw new Error('Method not implemented.') + } } diff --git a/packages/encryption/src/Domain/Operator/002/Operator002.ts b/packages/encryption/src/Domain/Operator/002/Operator002.ts index 08e74f5ad..5e7822f53 100644 --- a/packages/encryption/src/Domain/Operator/002/Operator002.ts +++ b/packages/encryption/src/Domain/Operator/002/Operator002.ts @@ -9,7 +9,8 @@ import { CreateNewRootKey } from '../../Keys/RootKey/Functions' import { Create002KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' import { SNRootKey } from '../../Keys/RootKey/RootKey' import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' -import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { EncryptedOutputParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../Types/DecryptedParameters' import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' import { LegacyAttachedData } from '../../Types/LegacyAttachedData' import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' @@ -50,11 +51,11 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { return Models.CreateDecryptedItemFromPayload(payload) } - public override async createRootKey( + public override async createRootKey( identifier: string, password: string, origination: Common.KeyParamsOrigination, - ): Promise { + ): Promise { const pwCost = Utils.lastElement(V002Algorithm.PbkdfCostsUsed) as number const pwNonce = this.crypto.generateRandomKey(V002Algorithm.SaltSeedLength) const pwSalt = await this.crypto.unsafeSha1(identifier + ':' + pwNonce) @@ -77,7 +78,10 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { * may have had costs of 5000, and others of 101000. Therefore, when computing * the root key, we must use the value returned by the server. */ - public override async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + public override async computeRootKey( + password: string, + keyParams: SNRootKeyParams, + ): Promise { return this.deriveKey(password, keyParams) } @@ -141,8 +145,8 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { return this.decryptString002(contentCiphertext, encryptionKey, iv) } - public override getPayloadAuthenticatedData( - encrypted: EncryptedParameters, + public override getPayloadAuthenticatedDataForExternalUse( + encrypted: EncryptedOutputParameters, ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { const itemKeyComponents = this.encryptionComponentsFromString002(encrypted.enc_item_key) const authenticatedData = itemKeyComponents.keyParams @@ -161,7 +165,7 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { public override async generateEncryptedParametersAsync( payload: Models.DecryptedPayloadInterface, key: Models.ItemsKeyInterface | SNRootKey, - ): Promise { + ): Promise { /** * Generate new item key that is double the key size. * Will be split to create encryption key and authentication key. @@ -189,15 +193,18 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { return { uuid: payload.uuid, + content_type: payload.content_type, items_key_id: isItemsKey(key) ? key.uuid : undefined, content: ciphertext, enc_item_key: encItemKey, version: this.version, + key_system_identifier: payload.key_system_identifier, + shared_vault_uuid: payload.shared_vault_uuid, } } public override async generateDecryptedParametersAsync( - encrypted: EncryptedParameters, + encrypted: EncryptedOutputParameters, key: Models.ItemsKeyInterface | SNRootKey, ): Promise | ErrorDecryptingParameters> { if (!encrypted.enc_item_key) { @@ -252,11 +259,15 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { return { uuid: encrypted.uuid, content: JSON.parse(content), + signatureData: { required: false, contentHash: '' }, } } } - protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + protected override async deriveKey( + password: string, + keyParams: SNRootKeyParams, + ): Promise { const derivedKey = await this.crypto.pbkdf2( password, keyParams.content002.pw_salt, @@ -270,7 +281,7 @@ export class SNProtocolOperator002 extends SNProtocolOperator001 { const partitions = Utils.splitString(derivedKey, 3) - return CreateNewRootKey({ + return CreateNewRootKey({ serverPassword: partitions[0], masterKey: partitions[1], dataAuthenticationKey: partitions[2], diff --git a/packages/encryption/src/Domain/Operator/003/Operator003.ts b/packages/encryption/src/Domain/Operator/003/Operator003.ts index d5e47a8f0..9273844fe 100644 --- a/packages/encryption/src/Domain/Operator/003/Operator003.ts +++ b/packages/encryption/src/Domain/Operator/003/Operator003.ts @@ -6,12 +6,12 @@ import { ItemsKeyContent, ItemsKeyInterface, PayloadTimestampDefaults, + RootKeyInterface, } from '@standardnotes/models' import { splitString, UuidGenerator } from '@standardnotes/utils' import { V003Algorithm } from '../../Algorithm' import { CreateNewRootKey } from '../../Keys/RootKey/Functions' import { Create003KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' -import { SNRootKey } from '../../Keys/RootKey/RootKey' import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' import { SNProtocolOperator002 } from '../002/Operator002' @@ -53,11 +53,17 @@ export class SNProtocolOperator003 extends SNProtocolOperator002 { return CreateDecryptedItemFromPayload(payload) } - public override async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + public override async computeRootKey( + password: string, + keyParams: SNRootKeyParams, + ): Promise { return this.deriveKey(password, keyParams) } - protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + protected override async deriveKey( + password: string, + keyParams: SNRootKeyParams, + ): Promise { const salt = await this.generateSalt( keyParams.content003.identifier, ProtocolVersion.V003, @@ -78,7 +84,7 @@ export class SNProtocolOperator003 extends SNProtocolOperator002 { const partitions = splitString(derivedKey, 3) - return CreateNewRootKey({ + return CreateNewRootKey({ serverPassword: partitions[0], masterKey: partitions[1], dataAuthenticationKey: partitions[2], @@ -87,11 +93,11 @@ export class SNProtocolOperator003 extends SNProtocolOperator002 { }) } - public override async createRootKey( + public override async createRootKey( identifier: string, password: string, origination: KeyParamsOrigination, - ): Promise { + ): Promise { const version = ProtocolVersion.V003 const pwNonce = this.crypto.generateRandomKey(V003Algorithm.SaltSeedLength) const keyParams = Create003KeyParams({ diff --git a/packages/encryption/src/Domain/Operator/004/MockedCrypto.ts b/packages/encryption/src/Domain/Operator/004/MockedCrypto.ts new file mode 100644 index 000000000..c6a012a3b --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/MockedCrypto.ts @@ -0,0 +1,87 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +export function getMockedCrypto(): PureCryptoInterface { + const crypto = {} as jest.Mocked + + const mockGenerateKeyPair = (seed: string) => { + const publicKey = `public-key-${seed}` + const privateKey = `private-key-${seed}` + + return { + publicKey: `${publicKey}:${privateKey}`, + privateKey: `${privateKey}:${publicKey}`, + } + } + + const replaceColonsToAvoidJSONConflicts = (text: string) => { + return text.replace(/:/g, '|') + } + + const undoReplaceColonsToAvoidJSONConflicts = (text: string) => { + return text.replace(/\|/g, ':') + } + + crypto.base64Encode = jest.fn().mockImplementation((text: string) => { + return `base64-${replaceColonsToAvoidJSONConflicts(text)}` + }) + + crypto.base64Decode = jest.fn().mockImplementation((text: string) => { + const decodedText = text.split('base64-')[1] + return undoReplaceColonsToAvoidJSONConflicts(decodedText) + }) + + crypto.xchacha20Encrypt = jest.fn().mockImplementation((text: string) => { + return `${replaceColonsToAvoidJSONConflicts(text)}` + }) + + crypto.xchacha20Decrypt = jest.fn().mockImplementation((text: string) => { + return undoReplaceColonsToAvoidJSONConflicts(text.split('')[1]) + }) + + crypto.generateRandomKey = jest.fn().mockImplementation(() => { + return 'random-string' + }) + + crypto.sodiumCryptoBoxEasyEncrypt = jest.fn().mockImplementation((text: string) => { + return `${replaceColonsToAvoidJSONConflicts(text)}` + }) + + crypto.sodiumCryptoBoxEasyDecrypt = jest.fn().mockImplementation((text: string) => { + return undoReplaceColonsToAvoidJSONConflicts(text.split('')[1]) + }) + + crypto.sodiumCryptoBoxSeedKeypair = jest.fn().mockImplementation((seed: string) => { + return mockGenerateKeyPair(seed) + }) + + crypto.sodiumCryptoKdfDeriveFromKey = jest + .fn() + .mockImplementation((key: string, subkeyNumber: number, subkeyLength: number, context: string) => { + return `subkey-${key}-${subkeyNumber}-${subkeyLength}-${context}` + }) + + crypto.sodiumCryptoSign = jest.fn().mockImplementation((message: string, privateKey: string) => { + const signature = `signature:m=${message}:pk=${privateKey}` + return signature + }) + + crypto.sodiumCryptoSignSeedKeypair = jest.fn().mockImplementation((seed: string) => { + return mockGenerateKeyPair(seed) + }) + + crypto.sodiumCryptoSignVerify = jest + .fn() + .mockImplementation((message: string, signature: string, publicKey: string) => { + const keyComponents = publicKey.split(':') + const privateKeyComponent = keyComponents[1] + const privateKey = `${privateKeyComponent}:${keyComponents[0]}` + const computedSignature = crypto.sodiumCryptoSign(message, privateKey) + return computedSignature === signature + }) + + crypto.sodiumCryptoGenericHash = jest.fn().mockImplementation((message: string, key: string) => { + return `hash-${message}-${key}` + }) + + return crypto +} diff --git a/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts b/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts index b7a717c5e..1118292bf 100644 --- a/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts +++ b/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts @@ -1,69 +1,34 @@ import { ContentType, ProtocolVersion } from '@standardnotes/common' import { DecryptedPayload, ItemContent, ItemsKeyContent, PayloadTimestampDefaults } from '@standardnotes/models' -import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { SNItemsKey } from '../../Keys/ItemsKey/ItemsKey' -import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' import { SNProtocolOperator004 } from './Operator004' - -const b64 = (text: string): string => { - return Buffer.from(text).toString('base64') -} +import { getMockedCrypto } from './MockedCrypto' +import { deconstructEncryptedPayloadString } from './V004AlgorithmHelpers' describe('operator 004', () => { - let crypto: PureCryptoInterface + const crypto = getMockedCrypto() + let operator: SNProtocolOperator004 beforeEach(() => { - crypto = {} as jest.Mocked - crypto.base64Encode = jest.fn().mockImplementation((text: string) => { - return b64(text) - }) - crypto.base64Decode = jest.fn().mockImplementation((text: string) => { - return Buffer.from(text, 'base64').toString('ascii') - }) - crypto.xchacha20Encrypt = jest.fn().mockImplementation((text: string) => { - return `${text}` - }) - crypto.xchacha20Decrypt = jest.fn().mockImplementation((text: string) => { - return text.split('')[1] - }) - crypto.generateRandomKey = jest.fn().mockImplementation(() => { - return 'random-string' - }) - operator = new SNProtocolOperator004(crypto) }) - it('should generateEncryptedProtocolString', () => { - const aad: ItemAuthenticatedData = { - u: '123', - v: ProtocolVersion.V004, - } - - const nonce = 'noncy' - const plaintext = 'foo' - - operator.generateEncryptionNonce = jest.fn().mockReturnValue(nonce) - - const result = operator.generateEncryptedProtocolString(plaintext, 'secret', aad) - - expect(result).toEqual(`004:${nonce}:${plaintext}:${b64(JSON.stringify(aad))}`) - }) - it('should deconstructEncryptedPayloadString', () => { const string = '004:noncy:foo:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9' - const result = operator.deconstructEncryptedPayloadString(string) + const result = deconstructEncryptedPayloadString(string) expect(result).toEqual({ version: '004', nonce: 'noncy', ciphertext: 'foo', authenticatedData: 'eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', + additionalData: 'e30=', }) }) - it('should generateEncryptedParametersSync', () => { + it('should generateEncryptedParameters', () => { const payload = { uuid: '123', content_type: ContentType.Note, @@ -83,13 +48,16 @@ describe('operator 004', () => { }), ) - const result = operator.generateEncryptedParametersSync(payload, key) + const result = operator.generateEncryptedParameters(payload, key) expect(result).toEqual({ uuid: '123', items_key_id: 'key-456', - content: '004:random-string:{"foo":"bar"}:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', - enc_item_key: '004:random-string:random-string:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', + key_system_identifier: undefined, + shared_vault_uuid: undefined, + content: '004:random-string:{"foo"|"bar"}:base64-{"u"|"123","v"|"004"}:base64-{}', + content_type: ContentType.Note, + enc_item_key: '004:random-string:random-string:base64-{"u"|"123","v"|"004"}:base64-{}', version: '004', }) }) diff --git a/packages/encryption/src/Domain/Operator/004/Operator004.ts b/packages/encryption/src/Domain/Operator/004/Operator004.ts index aa2a0975d..5fceb1121 100644 --- a/packages/encryption/src/Domain/Operator/004/Operator004.ts +++ b/packages/encryption/src/Domain/Operator/004/Operator004.ts @@ -1,44 +1,56 @@ -import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' -import * as Models from '@standardnotes/models' import { CreateDecryptedItemFromPayload, - FillItemContent, ItemContent, - ItemsKeyContent, ItemsKeyInterface, PayloadTimestampDefaults, + DecryptedPayload, + DecryptedPayloadInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + FillItemContentSpecialized, + ItemsKeyContentSpecialized, + KeySystemIdentifier, + RootKeyInterface, + KeySystemRootKeyParamsInterface, } from '@standardnotes/models' -import { PureCryptoInterface } from '@standardnotes/sncrypto-common' -import * as Utils from '@standardnotes/utils' +import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { HexString, PkcKeyPair, PureCryptoInterface, Utf8String } from '@standardnotes/sncrypto-common' import { V004Algorithm } from '../../Algorithm' -import { isItemsKey } from '../../Keys/ItemsKey/ItemsKey' -import { ContentTypeUsesRootKeyEncryption, CreateNewRootKey } from '../../Keys/RootKey/Functions' -import { Create004KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' -import { SNRootKey } from '../../Keys/RootKey/RootKey' import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' -import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { + EncryptedInputParameters, + EncryptedOutputParameters, + ErrorDecryptingParameters, +} from '../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../Types/DecryptedParameters' import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' import { LegacyAttachedData } from '../../Types/LegacyAttachedData' import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' -import { SynchronousOperator } from '../Operator' +import { OperatorInterface } from '../OperatorInterface/OperatorInterface' +import { AsymmetricallyEncryptedString } from '../Types/Types' +import { AsymmetricItemAdditionalData } from '../../Types/EncryptionAdditionalData' +import { V004AsymmetricStringComponents } from './V004AlgorithmTypes' +import { AsymmetricEncryptUseCase } from './UseCase/Asymmetric/AsymmetricEncrypt' +import { ParseConsistentBase64JsonPayloadUseCase } from './UseCase/Utils/ParseConsistentBase64JsonPayload' +import { AsymmetricDecryptUseCase } from './UseCase/Asymmetric/AsymmetricDecrypt' +import { GenerateDecryptedParametersUseCase } from './UseCase/Symmetric/GenerateDecryptedParameters' +import { GenerateEncryptedParametersUseCase } from './UseCase/Symmetric/GenerateEncryptedParameters' +import { DeriveRootKeyUseCase } from './UseCase/RootKey/DeriveRootKey' +import { GetPayloadAuthenticatedDataDetachedUseCase } from './UseCase/Symmetric/GetPayloadAuthenticatedDataDetached' +import { CreateRootKeyUseCase } from './UseCase/RootKey/CreateRootKey' +import { UuidGenerator } from '@standardnotes/utils' +import { CreateKeySystemItemsKeyUseCase } from './UseCase/KeySystem/CreateKeySystemItemsKey' +import { AsymmetricDecryptResult } from '../Types/AsymmetricDecryptResult' +import { PublicKeySet } from '../Types/PublicKeySet' +import { CreateRandomKeySystemRootKey } from './UseCase/KeySystem/CreateRandomKeySystemRootKey' +import { CreateUserInputKeySystemRootKey } from './UseCase/KeySystem/CreateUserInputKeySystemRootKey' +import { AsymmetricSignatureVerificationDetachedResult } from '../Types/AsymmetricSignatureVerificationDetachedResult' +import { AsymmetricSignatureVerificationDetachedUseCase } from './UseCase/Asymmetric/AsymmetricSignatureVerificationDetached' +import { DeriveKeySystemRootKeyUseCase } from './UseCase/KeySystem/DeriveKeySystemRootKey' +import { SyncOperatorInterface } from '../OperatorInterface/SyncOperatorInterface' -type V004StringComponents = [version: string, nonce: string, ciphertext: string, authenticatedData: string] - -type V004Components = { - version: V004StringComponents[0] - nonce: V004StringComponents[1] - ciphertext: V004StringComponents[2] - authenticatedData: V004StringComponents[3] -} - -const PARTITION_CHARACTER = ':' - -export class SNProtocolOperator004 implements SynchronousOperator { - protected readonly crypto: PureCryptoInterface - - constructor(crypto: PureCryptoInterface) { - this.crypto = crypto - } +export class SNProtocolOperator004 implements OperatorInterface, SyncOperatorInterface { + constructor(protected readonly crypto: PureCryptoInterface) {} public getEncryptionDisplayName(): string { return 'XChaCha20-Poly1305' @@ -50,7 +62,7 @@ export class SNProtocolOperator004 implements SynchronousOperator { private generateNewItemsKeyContent() { const itemsKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) - const response = FillItemContent({ + const response = FillItemContentSpecialized({ itemsKey: itemsKey, version: ProtocolVersion.V004, }) @@ -62,260 +74,130 @@ export class SNProtocolOperator004 implements SynchronousOperator { * The consumer must save/sync this item. */ public createItemsKey(): ItemsKeyInterface { - const payload = new Models.DecryptedPayload({ - uuid: Utils.UuidGenerator.GenerateUuid(), + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), content_type: ContentType.ItemsKey, content: this.generateNewItemsKeyContent(), + key_system_identifier: undefined, + shared_vault_uuid: undefined, ...PayloadTimestampDefaults(), }) return CreateDecryptedItemFromPayload(payload) } - /** - * We require both a client-side component and a server-side component in generating a - * salt. This way, a comprimised server cannot benefit from sending the same seed value - * for every user. We mix a client-controlled value that is globally unique - * (their identifier), with a server controlled value to produce a salt for our KDF. - * @param identifier - * @param seed - */ - private async generateSalt004(identifier: string, seed: string) { - const hash = await this.crypto.sha256([identifier, seed].join(PARTITION_CHARACTER)) - return Utils.truncateHexString(hash, V004Algorithm.ArgonSaltLength) + createRandomizedKeySystemRootKey(dto: { systemIdentifier: KeySystemIdentifier }): KeySystemRootKeyInterface { + const usecase = new CreateRandomKeySystemRootKey(this.crypto) + return usecase.execute(dto) } - /** - * Computes a root key given a passworf - * qwd and previous keyParams - * @param password - Plain string representing raw user password - * @param keyParams - KeyParams object - */ - public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { - return this.deriveKey(password, keyParams) + createUserInputtedKeySystemRootKey(dto: { + systemIdentifier: KeySystemIdentifier + userInputtedPassword: string + }): KeySystemRootKeyInterface { + const usecase = new CreateUserInputKeySystemRootKey(this.crypto) + return usecase.execute(dto) } - /** - * Creates a new root key given an identifier and a user password - * @param identifier - Plain string representing a unique identifier - * @param password - Plain string representing raw user password - */ - public async createRootKey( + deriveUserInputtedKeySystemRootKey(dto: { + keyParams: KeySystemRootKeyParamsInterface + userInputtedPassword: string + }): KeySystemRootKeyInterface { + const usecase = new DeriveKeySystemRootKeyUseCase(this.crypto) + return usecase.execute({ + keyParams: dto.keyParams, + password: dto.userInputtedPassword, + }) + } + + public createKeySystemItemsKey( + uuid: string, + keySystemIdentifier: KeySystemIdentifier, + sharedVaultUuid: string | undefined, + rootKeyToken: string, + ): KeySystemItemsKeyInterface { + const usecase = new CreateKeySystemItemsKeyUseCase(this.crypto) + return usecase.execute({ uuid, keySystemIdentifier, sharedVaultUuid, rootKeyToken }) + } + + public async computeRootKey( + password: Utf8String, + keyParams: SNRootKeyParams, + ): Promise { + const usecase = new DeriveRootKeyUseCase(this.crypto) + return usecase.execute(password, keyParams) + } + + public async createRootKey( identifier: string, - password: string, + password: Utf8String, origination: KeyParamsOrigination, - ): Promise { - const version = ProtocolVersion.V004 - const seed = this.crypto.generateRandomKey(V004Algorithm.ArgonSaltSeedLength) - const keyParams = Create004KeyParams({ - identifier: identifier, - pw_nonce: seed, - version: version, - origination: origination, - created: `${Date.now()}`, - }) - return this.deriveKey(password, keyParams) + ): Promise { + const usecase = new CreateRootKeyUseCase(this.crypto) + return usecase.execute(identifier, password, origination) } - /** - * @param plaintext - The plaintext to encrypt. - * @param rawKey - The key to use to encrypt the plaintext. - * @param nonce - The nonce for encryption. - * @param authenticatedData - JavaScript object (will be stringified) representing - 'Additional authenticated data': data you want to be included in authentication. - */ - encryptString004(plaintext: string, rawKey: string, nonce: string, authenticatedData: ItemAuthenticatedData) { - if (!nonce) { - throw 'encryptString null nonce' - } - if (!rawKey) { - throw 'encryptString null rawKey' - } - return this.crypto.xchacha20Encrypt(plaintext, nonce, rawKey, this.authenticatedDataToString(authenticatedData)) - } - - /** - * @param ciphertext The encrypted text to decrypt. - * @param rawKey The key to use to decrypt the ciphertext. - * @param nonce The nonce for decryption. - * @param rawAuthenticatedData String representing - 'Additional authenticated data' - data you want to be included in authentication. - */ - private decryptString004(ciphertext: string, rawKey: string, nonce: string, rawAuthenticatedData: string) { - return this.crypto.xchacha20Decrypt(ciphertext, nonce, rawKey, rawAuthenticatedData) - } - - generateEncryptionNonce(): string { - return this.crypto.generateRandomKey(V004Algorithm.EncryptionNonceLength) - } - - /** - * @param plaintext The plaintext text to decrypt. - * @param rawKey The key to use to encrypt the plaintext. - */ - generateEncryptedProtocolString(plaintext: string, rawKey: string, authenticatedData: ItemAuthenticatedData) { - const nonce = this.generateEncryptionNonce() - - const ciphertext = this.encryptString004(plaintext, rawKey, nonce, authenticatedData) - - const components: V004StringComponents = [ - ProtocolVersion.V004 as string, - nonce, - ciphertext, - this.authenticatedDataToString(authenticatedData), - ] - - return components.join(PARTITION_CHARACTER) - } - - deconstructEncryptedPayloadString(payloadString: string): V004Components { - const components = payloadString.split(PARTITION_CHARACTER) as V004StringComponents - - return { - version: components[0], - nonce: components[1], - ciphertext: components[2], - authenticatedData: components[3], - } - } - - public getPayloadAuthenticatedData( - encrypted: EncryptedParameters, + public getPayloadAuthenticatedDataForExternalUse( + encrypted: EncryptedOutputParameters, ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { - const itemKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key) - const authenticatedDataString = itemKeyComponents.authenticatedData - const result = this.stringToAuthenticatedData(authenticatedDataString) - - return result + const usecase = new GetPayloadAuthenticatedDataDetachedUseCase(this.crypto) + return usecase.execute(encrypted) } - /** - * For items that are encrypted with a root key, we append the root key's key params, so - * that in the event the client/user loses a reference to their root key, they may still - * decrypt data by regenerating the key based on the attached key params. - */ - private generateAuthenticatedDataForPayload( - payload: Models.DecryptedPayloadInterface, - key: ItemsKeyInterface | SNRootKey, - ): ItemAuthenticatedData | RootKeyEncryptedAuthenticatedData { - const baseData: ItemAuthenticatedData = { - u: payload.uuid, - v: ProtocolVersion.V004, - } - if (ContentTypeUsesRootKeyEncryption(payload.content_type)) { - return { - ...baseData, - kp: (key as SNRootKey).keyParams.content, - } - } else { - if (!isItemsKey(key)) { - throw Error('Attempting to use non-items key for regular item.') - } - return baseData - } + public generateEncryptedParameters( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): EncryptedOutputParameters { + const usecase = new GenerateEncryptedParametersUseCase(this.crypto) + return usecase.execute(payload, key, signingKeyPair) } - private authenticatedDataToString(attachedData: ItemAuthenticatedData) { - return this.crypto.base64Encode(JSON.stringify(Utils.sortedCopy(Utils.omitUndefinedCopy(attachedData)))) - } - - private stringToAuthenticatedData( - rawAuthenticatedData: string, - override?: Partial, - ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData { - const base = JSON.parse(this.crypto.base64Decode(rawAuthenticatedData)) - return Utils.sortedCopy({ - ...base, - ...override, - }) - } - - public generateEncryptedParametersSync( - payload: Models.DecryptedPayloadInterface, - key: ItemsKeyInterface | SNRootKey, - ): EncryptedParameters { - const itemKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) - - const contentPlaintext = JSON.stringify(payload.content) - const authenticatedData = this.generateAuthenticatedDataForPayload(payload, key) - const encryptedContentString = this.generateEncryptedProtocolString(contentPlaintext, itemKey, authenticatedData) - - const encryptedItemKey = this.generateEncryptedProtocolString(itemKey, key.itemsKey, authenticatedData) - - return { - uuid: payload.uuid, - items_key_id: isItemsKey(key) ? key.uuid : undefined, - content: encryptedContentString, - enc_item_key: encryptedItemKey, - version: this.version, - } - } - - public generateDecryptedParametersSync( - encrypted: EncryptedParameters, - key: ItemsKeyInterface | SNRootKey, + public generateDecryptedParameters( + encrypted: EncryptedInputParameters, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, ): DecryptedParameters | ErrorDecryptingParameters { - const contentKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key) - const authenticatedData = this.stringToAuthenticatedData(contentKeyComponents.authenticatedData, { - u: encrypted.uuid, - v: encrypted.version, - }) + const usecase = new GenerateDecryptedParametersUseCase(this.crypto) + return usecase.execute(encrypted, key) + } - const useAuthenticatedString = this.authenticatedDataToString(authenticatedData) - const contentKey = this.decryptString004( - contentKeyComponents.ciphertext, - key.itemsKey, - contentKeyComponents.nonce, - useAuthenticatedString, - ) + public asymmetricEncrypt(dto: { + stringToEncrypt: Utf8String + senderKeyPair: PkcKeyPair + senderSigningKeyPair: PkcKeyPair + recipientPublicKey: HexString + }): AsymmetricallyEncryptedString { + const usecase = new AsymmetricEncryptUseCase(this.crypto) + return usecase.execute(dto) + } - if (!contentKey) { - console.error('Error decrypting itemKey parameters', encrypted) - return { - uuid: encrypted.uuid, - errorDecrypting: true, - } - } + asymmetricDecrypt(dto: { + stringToDecrypt: AsymmetricallyEncryptedString + recipientSecretKey: HexString + }): AsymmetricDecryptResult | null { + const usecase = new AsymmetricDecryptUseCase(this.crypto) + return usecase.execute(dto) + } - const contentComponents = this.deconstructEncryptedPayloadString(encrypted.content) - const content = this.decryptString004( - contentComponents.ciphertext, - contentKey, - contentComponents.nonce, - useAuthenticatedString, - ) + asymmetricSignatureVerifyDetached( + encryptedString: AsymmetricallyEncryptedString, + ): AsymmetricSignatureVerificationDetachedResult { + const usecase = new AsymmetricSignatureVerificationDetachedUseCase(this.crypto) + return usecase.execute({ encryptedString }) + } - if (!content) { - return { - uuid: encrypted.uuid, - errorDecrypting: true, - } - } else { - return { - uuid: encrypted.uuid, - content: JSON.parse(content), - } + getSenderPublicKeySetFromAsymmetricallyEncryptedString(string: AsymmetricallyEncryptedString): PublicKeySet { + const [_, __, ___, additionalDataString] = string.split(':') + const parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(this.crypto) + const additionalData = parseBase64Usecase.execute(additionalDataString) + return { + encryption: additionalData.senderPublicKey, + signing: additionalData.signingData.publicKey, } } - private async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { - const salt = await this.generateSalt004(keyParams.content004.identifier, keyParams.content004.pw_nonce) - const derivedKey = this.crypto.argon2( - password, - salt, - V004Algorithm.ArgonIterations, - V004Algorithm.ArgonMemLimit, - V004Algorithm.ArgonOutputKeyBytes, - ) - - const partitions = Utils.splitString(derivedKey, 2) - const masterKey = partitions[0] - const serverPassword = partitions[1] - - return CreateNewRootKey({ - masterKey, - serverPassword, - version: ProtocolVersion.V004, - keyParams: keyParams.getPortableValue(), - }) + versionForAsymmetricallyEncryptedString(string: string): ProtocolVersion { + const [versionPrefix] = string.split(':') + const version = versionPrefix.split('_')[0] + return version as ProtocolVersion } } diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.spec.ts new file mode 100644 index 000000000..58ae00d0f --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.spec.ts @@ -0,0 +1,81 @@ +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { getMockedCrypto } from '../../MockedCrypto' +import { AsymmetricDecryptUseCase } from './AsymmetricDecrypt' +import { AsymmetricEncryptUseCase } from './AsymmetricEncrypt' +import { V004AsymmetricStringComponents } from '../../V004AlgorithmTypes' +import { AsymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' + +describe('asymmetric decrypt use case', () => { + let crypto: PureCryptoInterface + let usecase: AsymmetricDecryptUseCase + let recipientKeyPair: PkcKeyPair + let senderKeyPair: PkcKeyPair + let senderSigningKeyPair: PkcKeyPair + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new AsymmetricDecryptUseCase(crypto) + recipientKeyPair = crypto.sodiumCryptoBoxSeedKeypair('recipient-seedling') + senderKeyPair = crypto.sodiumCryptoBoxSeedKeypair('sender-seedling') + senderSigningKeyPair = crypto.sodiumCryptoSignSeedKeypair('sender-signing-seedling') + }) + + const getEncryptedString = () => { + const encryptUsecase = new AsymmetricEncryptUseCase(crypto) + + const result = encryptUsecase.execute({ + stringToEncrypt: 'foobar', + senderKeyPair: senderKeyPair, + senderSigningKeyPair: senderSigningKeyPair, + recipientPublicKey: recipientKeyPair.publicKey, + }) + + return result + } + + it('should generate decrypted string', () => { + const encryptedString = getEncryptedString() + + const decrypted = usecase.execute({ + stringToDecrypt: encryptedString, + recipientSecretKey: recipientKeyPair.privateKey, + }) + + expect(decrypted).toEqual({ + plaintext: 'foobar', + signatureVerified: true, + signaturePublicKey: senderSigningKeyPair.publicKey, + senderPublicKey: senderKeyPair.publicKey, + }) + }) + + it('should fail signature verification if signature is changed', () => { + const encryptedString = getEncryptedString() + + const [version, nonce, ciphertext] = encryptedString.split(':') + + const corruptAdditionalData: AsymmetricItemAdditionalData = { + signingData: { + publicKey: senderSigningKeyPair.publicKey, + signature: 'corrupt', + }, + senderPublicKey: senderKeyPair.publicKey, + } + + const corruptedAdditionalDataString = crypto.base64Encode(JSON.stringify(corruptAdditionalData)) + + const corruptEncryptedString = [version, nonce, ciphertext, corruptedAdditionalDataString].join(':') + + const decrypted = usecase.execute({ + stringToDecrypt: corruptEncryptedString, + recipientSecretKey: recipientKeyPair.privateKey, + }) + + expect(decrypted).toEqual({ + plaintext: 'foobar', + signatureVerified: false, + signaturePublicKey: senderSigningKeyPair.publicKey, + senderPublicKey: senderKeyPair.publicKey, + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.ts new file mode 100644 index 000000000..2cd3c899b --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricDecrypt.ts @@ -0,0 +1,48 @@ +import { HexString, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { AsymmetricallyEncryptedString } from '../../../Types/Types' +import { V004AsymmetricStringComponents } from '../../V004AlgorithmTypes' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' +import { AsymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { AsymmetricDecryptResult } from '../../../Types/AsymmetricDecryptResult' + +export class AsymmetricDecryptUseCase { + private parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { + stringToDecrypt: AsymmetricallyEncryptedString + recipientSecretKey: HexString + }): AsymmetricDecryptResult | null { + const [_, nonce, ciphertext, additionalDataString] = dto.stringToDecrypt.split(':') + + const additionalData = this.parseBase64Usecase.execute(additionalDataString) + + try { + const plaintext = this.crypto.sodiumCryptoBoxEasyDecrypt( + ciphertext, + nonce, + additionalData.senderPublicKey, + dto.recipientSecretKey, + ) + if (!plaintext) { + return null + } + + const signatureVerified = this.crypto.sodiumCryptoSignVerify( + ciphertext, + additionalData.signingData.signature, + additionalData.signingData.publicKey, + ) + + return { + plaintext, + signatureVerified, + signaturePublicKey: additionalData.signingData.publicKey, + senderPublicKey: additionalData.senderPublicKey, + } + } catch (error) { + return null + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.spec.ts new file mode 100644 index 000000000..aee4a58ca --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.spec.ts @@ -0,0 +1,45 @@ +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { getMockedCrypto } from '../../MockedCrypto' +import { AsymmetricEncryptUseCase } from './AsymmetricEncrypt' +import { V004AsymmetricStringComponents } from '../../V004AlgorithmTypes' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' +import { AsymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' + +describe('asymmetric encrypt use case', () => { + let crypto: PureCryptoInterface + let usecase: AsymmetricEncryptUseCase + let encryptionKeyPair: PkcKeyPair + let signingKeyPair: PkcKeyPair + let parseBase64Usecase: ParseConsistentBase64JsonPayloadUseCase + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new AsymmetricEncryptUseCase(crypto) + encryptionKeyPair = crypto.sodiumCryptoBoxSeedKeypair('seedling') + signingKeyPair = crypto.sodiumCryptoSignSeedKeypair('seedling') + parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(crypto) + }) + + it('should generate encrypted string', () => { + const recipientKeyPair = crypto.sodiumCryptoBoxSeedKeypair('recipient-seedling') + + const result = usecase.execute({ + stringToEncrypt: 'foobar', + senderKeyPair: encryptionKeyPair, + senderSigningKeyPair: signingKeyPair, + recipientPublicKey: recipientKeyPair.publicKey, + }) + + const [version, nonce, ciphertext, additionalDataString] = result.split(':') + + expect(version).toEqual('004_Asym') + expect(nonce).toEqual(expect.any(String)) + expect(ciphertext).toEqual(expect.any(String)) + expect(additionalDataString).toEqual(expect.any(String)) + + const additionalData = parseBase64Usecase.execute(additionalDataString) + expect(additionalData.signingData.publicKey).toEqual(signingKeyPair.publicKey) + expect(additionalData.signingData.signature).toEqual(expect.any(String)) + expect(additionalData.senderPublicKey).toEqual(encryptionKeyPair.publicKey) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.ts new file mode 100644 index 000000000..5a51523b4 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricEncrypt.ts @@ -0,0 +1,45 @@ +import { HexString, PkcKeyPair, PureCryptoInterface, Utf8String } from '@standardnotes/sncrypto-common' +import { AsymmetricallyEncryptedString } from '../../../Types/Types' +import { V004Algorithm } from '../../../../Algorithm' +import { V004AsymmetricCiphertextPrefix, V004AsymmetricStringComponents } from '../../V004AlgorithmTypes' +import { CreateConsistentBase64JsonPayloadUseCase } from '../Utils/CreateConsistentBase64JsonPayload' +import { AsymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' + +export class AsymmetricEncryptUseCase { + private base64DataUsecase = new CreateConsistentBase64JsonPayloadUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { + stringToEncrypt: Utf8String + senderKeyPair: PkcKeyPair + senderSigningKeyPair: PkcKeyPair + recipientPublicKey: HexString + }): AsymmetricallyEncryptedString { + const nonce = this.crypto.generateRandomKey(V004Algorithm.AsymmetricEncryptionNonceLength) + + const ciphertext = this.crypto.sodiumCryptoBoxEasyEncrypt( + dto.stringToEncrypt, + nonce, + dto.senderKeyPair.privateKey, + dto.recipientPublicKey, + ) + + const additionalData: AsymmetricItemAdditionalData = { + signingData: { + publicKey: dto.senderSigningKeyPair.publicKey, + signature: this.crypto.sodiumCryptoSign(ciphertext, dto.senderSigningKeyPair.privateKey), + }, + senderPublicKey: dto.senderKeyPair.publicKey, + } + + const components: V004AsymmetricStringComponents = [ + V004AsymmetricCiphertextPrefix, + nonce, + ciphertext, + this.base64DataUsecase.execute(additionalData), + ] + + return components.join(':') + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricSignatureVerificationDetached.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricSignatureVerificationDetached.ts new file mode 100644 index 000000000..36e272ad5 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Asymmetric/AsymmetricSignatureVerificationDetached.ts @@ -0,0 +1,36 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { AsymmetricallyEncryptedString } from '../../../Types/Types' +import { V004AsymmetricStringComponents } from '../../V004AlgorithmTypes' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' +import { AsymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { AsymmetricSignatureVerificationDetachedResult } from '../../../Types/AsymmetricSignatureVerificationDetachedResult' + +export class AsymmetricSignatureVerificationDetachedUseCase { + private parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { encryptedString: AsymmetricallyEncryptedString }): AsymmetricSignatureVerificationDetachedResult { + const [_, __, ciphertext, additionalDataString] = dto.encryptedString.split(':') + + const additionalData = this.parseBase64Usecase.execute(additionalDataString) + + try { + const signatureVerified = this.crypto.sodiumCryptoSignVerify( + ciphertext, + additionalData.signingData.signature, + additionalData.signingData.publicKey, + ) + + return { + signatureVerified, + signaturePublicKey: additionalData.signingData.publicKey, + senderPublicKey: additionalData.senderPublicKey, + } + } catch (error) { + return { + signatureVerified: false, + } + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Hash/DeriveHashingKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/DeriveHashingKey.ts new file mode 100644 index 000000000..eb0360b67 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/DeriveHashingKey.ts @@ -0,0 +1,28 @@ +import { + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { V004Algorithm } from '../../../../Algorithm' +import { HashingKey } from './HashingKey' + +export class DeriveHashingKeyUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): HashingKey { + const hashingKey = this.crypto.sodiumCryptoKdfDeriveFromKey( + key.itemsKey, + V004Algorithm.PayloadKeyHashingKeySubKeyNumber, + V004Algorithm.PayloadKeyHashingKeySubKeyBytes, + V004Algorithm.PayloadKeyHashingKeySubKeyContext, + ) + + return { + key: hashingKey, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashString.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashString.ts new file mode 100644 index 000000000..7d5f79e3e --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashString.ts @@ -0,0 +1,10 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { HashingKey } from './HashingKey' + +export class HashStringUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(string: string, hashingKey: HashingKey): string { + return this.crypto.sodiumCryptoGenericHash(string, hashingKey.key) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashingKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashingKey.ts new file mode 100644 index 000000000..21f9530d0 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Hash/HashingKey.ts @@ -0,0 +1,3 @@ +export interface HashingKey { + key: string +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateKeySystemItemsKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateKeySystemItemsKey.ts new file mode 100644 index 000000000..3671e4203 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateKeySystemItemsKey.ts @@ -0,0 +1,45 @@ +import { + CreateDecryptedItemFromPayload, + DecryptedPayload, + DecryptedTransferPayload, + FillItemContentSpecialized, + KeySystemIdentifier, + KeySystemItemsKeyContentSpecialized, + KeySystemItemsKeyInterface, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { V004Algorithm } from '../../../../Algorithm' +import { ContentType, ProtocolVersion } from '@standardnotes/common' + +export class CreateKeySystemItemsKeyUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { + uuid: string + keySystemIdentifier: KeySystemIdentifier + sharedVaultUuid: string | undefined + rootKeyToken: string + }): KeySystemItemsKeyInterface { + const key = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) + const content = FillItemContentSpecialized({ + itemsKey: key, + creationTimestamp: new Date().getTime(), + version: ProtocolVersion.V004, + rootKeyToken: dto.rootKeyToken, + }) + + const transferPayload: DecryptedTransferPayload = { + uuid: dto.uuid, + content_type: ContentType.KeySystemItemsKey, + key_system_identifier: dto.keySystemIdentifier, + shared_vault_uuid: dto.sharedVaultUuid, + content: content, + dirty: true, + ...PayloadTimestampDefaults(), + } + + const payload = new DecryptedPayload(transferPayload) + return CreateDecryptedItemFromPayload(payload) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateRandomKeySystemRootKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateRandomKeySystemRootKey.ts new file mode 100644 index 000000000..542d3a72b --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateRandomKeySystemRootKey.ts @@ -0,0 +1,35 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { V004Algorithm } from '../../../../Algorithm' +import { + KeySystemRootKeyInterface, + KeySystemRootKeyParamsInterface, + KeySystemRootKeyPasswordType, +} from '@standardnotes/models' +import { ProtocolVersion } from '@standardnotes/common' +import { DeriveKeySystemRootKeyUseCase } from './DeriveKeySystemRootKey' + +export class CreateRandomKeySystemRootKey { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { systemIdentifier: string }): KeySystemRootKeyInterface { + const version = ProtocolVersion.V004 + + const seed = this.crypto.generateRandomKey(V004Algorithm.ArgonSaltSeedLength) + + const randomPassword = this.crypto.generateRandomKey(32) + + const keyParams: KeySystemRootKeyParamsInterface = { + systemIdentifier: dto.systemIdentifier, + passwordType: KeySystemRootKeyPasswordType.Randomized, + creationTimestamp: new Date().getTime(), + seed, + version, + } + + const usecase = new DeriveKeySystemRootKeyUseCase(this.crypto) + return usecase.execute({ + password: randomPassword, + keyParams, + }) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateUserInputKeySystemRootKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateUserInputKeySystemRootKey.ts new file mode 100644 index 000000000..2ba88a6a3 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/CreateUserInputKeySystemRootKey.ts @@ -0,0 +1,34 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { V004Algorithm } from '../../../../Algorithm' +import { + KeySystemIdentifier, + KeySystemRootKeyInterface, + KeySystemRootKeyParamsInterface, + KeySystemRootKeyPasswordType, +} from '@standardnotes/models' +import { ProtocolVersion } from '@standardnotes/common' +import { DeriveKeySystemRootKeyUseCase } from './DeriveKeySystemRootKey' + +export class CreateUserInputKeySystemRootKey { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { systemIdentifier: KeySystemIdentifier; userInputtedPassword: string }): KeySystemRootKeyInterface { + const version = ProtocolVersion.V004 + + const seed = this.crypto.generateRandomKey(V004Algorithm.ArgonSaltSeedLength) + + const keyParams: KeySystemRootKeyParamsInterface = { + systemIdentifier: dto.systemIdentifier, + passwordType: KeySystemRootKeyPasswordType.UserInputted, + creationTimestamp: new Date().getTime(), + seed, + version, + } + + const usecase = new DeriveKeySystemRootKeyUseCase(this.crypto) + return usecase.execute({ + password: dto.userInputtedPassword, + keyParams, + }) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/DeriveKeySystemRootKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/DeriveKeySystemRootKey.ts new file mode 100644 index 000000000..db6790320 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/KeySystem/DeriveKeySystemRootKey.ts @@ -0,0 +1,60 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { UuidGenerator, splitString, truncateHexString } from '@standardnotes/utils' +import { V004PartitionCharacter } from '../../V004AlgorithmTypes' +import { V004Algorithm } from '../../../../Algorithm' +import { + DecryptedPayload, + FillItemContentSpecialized, + KeySystemRootKey, + KeySystemRootKeyContent, + KeySystemRootKeyContentSpecialized, + KeySystemRootKeyInterface, + PayloadTimestampDefaults, + KeySystemRootKeyParamsInterface, +} from '@standardnotes/models' +import { ContentType, ProtocolVersion } from '@standardnotes/common' + +export class DeriveKeySystemRootKeyUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(dto: { password: string; keyParams: KeySystemRootKeyParamsInterface }): KeySystemRootKeyInterface { + const seed = dto.keyParams.seed + const salt = this.generateSalt(dto.keyParams.systemIdentifier, seed) + const derivedKey = this.crypto.argon2( + dto.password, + salt, + V004Algorithm.ArgonIterations, + V004Algorithm.ArgonMemLimit, + V004Algorithm.ArgonOutputKeyBytes, + ) + + const partitions = splitString(derivedKey, 2) + const masterKey = partitions[0] + const token = partitions[1] + + const uuid = UuidGenerator.GenerateUuid() + + const content: KeySystemRootKeyContentSpecialized = { + systemIdentifier: dto.keyParams.systemIdentifier, + key: masterKey, + keyVersion: ProtocolVersion.V004, + keyParams: dto.keyParams, + token, + } + + const payload = new DecryptedPayload({ + uuid: uuid, + content_type: ContentType.KeySystemRootKey, + content: FillItemContentSpecialized(content), + ...PayloadTimestampDefaults(), + }) + + return new KeySystemRootKey(payload) + } + + private generateSalt(identifier: string, seed: string) { + const hash = this.crypto.sodiumCryptoGenericHash([identifier, seed].join(V004PartitionCharacter)) + + return truncateHexString(hash, V004Algorithm.ArgonSaltLength) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/CreateRootKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/CreateRootKey.ts new file mode 100644 index 000000000..e51d8a3a3 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/CreateRootKey.ts @@ -0,0 +1,29 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { V004Algorithm } from '../../../../Algorithm' +import { RootKeyInterface } from '@standardnotes/models' +import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { DeriveRootKeyUseCase } from './DeriveRootKey' +import { Create004KeyParams } from '../../../../Keys/RootKey/KeyParamsFunctions' + +export class CreateRootKeyUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + async execute( + identifier: string, + password: string, + origination: KeyParamsOrigination, + ): Promise { + const version = ProtocolVersion.V004 + const seed = this.crypto.generateRandomKey(V004Algorithm.ArgonSaltSeedLength) + const keyParams = Create004KeyParams({ + identifier: identifier, + pw_nonce: seed, + version: version, + origination: origination, + created: `${Date.now()}`, + }) + + const usecase = new DeriveRootKeyUseCase(this.crypto) + return usecase.execute(password, keyParams) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/DeriveRootKey.ts b/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/DeriveRootKey.ts new file mode 100644 index 000000000..57186d0af --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/RootKey/DeriveRootKey.ts @@ -0,0 +1,66 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { splitString, truncateHexString } from '@standardnotes/utils' +import { V004PartitionCharacter } from '../../V004AlgorithmTypes' +import { V004Algorithm } from '../../../../Algorithm' +import { RootKeyInterface } from '@standardnotes/models' +import { SNRootKeyParams } from '../../../../Keys/RootKey/RootKeyParams' +import { CreateNewRootKey } from '../../../../Keys/RootKey/Functions' +import { ProtocolVersion } from '@standardnotes/common' + +export class DeriveRootKeyUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + async execute(password: string, keyParams: SNRootKeyParams): Promise { + const seed = keyParams.content004.pw_nonce + const salt = await this.generateSalt(keyParams.content004.identifier, seed) + const derivedKey = this.crypto.argon2( + password, + salt, + V004Algorithm.ArgonIterations, + V004Algorithm.ArgonMemLimit, + V004Algorithm.ArgonOutputKeyBytes, + ) + + const partitions = splitString(derivedKey, 2) + const masterKey = partitions[0] + const serverPassword = partitions[1] + + const encryptionKeyPairSeed = this.crypto.sodiumCryptoKdfDeriveFromKey( + masterKey, + V004Algorithm.MasterKeyEncryptionKeyPairSubKeyNumber, + V004Algorithm.MasterKeyEncryptionKeyPairSubKeyBytes, + V004Algorithm.MasterKeyEncryptionKeyPairSubKeyContext, + ) + const encryptionKeyPair = this.crypto.sodiumCryptoBoxSeedKeypair(encryptionKeyPairSeed) + + const signingKeyPairSeed = this.crypto.sodiumCryptoKdfDeriveFromKey( + masterKey, + V004Algorithm.MasterKeySigningKeyPairSubKeyNumber, + V004Algorithm.MasterKeySigningKeyPairSubKeyBytes, + V004Algorithm.MasterKeySigningKeyPairSubKeyContext, + ) + const signingKeyPair = this.crypto.sodiumCryptoSignSeedKeypair(signingKeyPairSeed) + + return CreateNewRootKey({ + masterKey, + serverPassword, + version: ProtocolVersion.V004, + keyParams: keyParams.getPortableValue(), + encryptionKeyPair, + signingKeyPair, + }) + } + + /** + * We require both a client-side component and a server-side component in generating a + * salt. This way, a comprimised server cannot benefit from sending the same seed value + * for every user. We mix a client-controlled value that is globally unique + * (their identifier), with a server controlled value to produce a salt for our KDF. + * @param identifier + * @param seed + */ + private async generateSalt(identifier: string, seed: string) { + const hash = await this.crypto.sha256([identifier, seed].join(V004PartitionCharacter)) + return truncateHexString(hash, V004Algorithm.ArgonSaltLength) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.spec.ts new file mode 100644 index 000000000..0b409b4cb --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.spec.ts @@ -0,0 +1,111 @@ +import { CreateAnyKeyParams } from '../../../../Keys/RootKey/KeyParamsFunctions' +import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common' +import { GenerateAuthenticatedDataUseCase } from './GenerateAuthenticatedData' +import { + DecryptedPayloadInterface, + ItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { KeySystemItemsKey } from '../../../../Keys/KeySystemItemsKey/KeySystemItemsKey' + +describe('generate authenticated data use case', () => { + let usecase: GenerateAuthenticatedDataUseCase + + beforeEach(() => { + usecase = new GenerateAuthenticatedDataUseCase() + }) + + it('should include key params if payload being encrypted is an items key', () => { + const payload = { + uuid: '123', + content_type: ContentType.ItemsKey, + } as jest.Mocked + + const keyParams = CreateAnyKeyParams({ + identifier: 'key-params-123', + } as jest.Mocked) + + const rootKey = { + keyParams, + } as jest.Mocked + + const authenticatedData = usecase.execute(payload, rootKey) + + expect(authenticatedData).toEqual({ + u: payload.uuid, + v: ProtocolVersion.V004, + kp: keyParams.content, + }) + }) + + it('should include root key params if payload is a key system items key', () => { + const payload = { + uuid: '123', + content_type: ContentType.KeySystemItemsKey, + shared_vault_uuid: 'shared-vault-uuid-123', + key_system_identifier: 'key-system-identifier-123', + } as jest.Mocked + + const keySystemRootKey = { + keyVersion: ProtocolVersion.V004, + keyParams: { + seed: 'seed-123', + }, + content_type: ContentType.KeySystemRootKey, + token: '123', + } as jest.Mocked + + const authenticatedData = usecase.execute(payload, keySystemRootKey) + + expect(authenticatedData).toEqual({ + u: payload.uuid, + v: ProtocolVersion.V004, + kp: keySystemRootKey.keyParams, + ksi: payload.key_system_identifier, + svu: payload.shared_vault_uuid, + }) + }) + + it('should include key system identifier and shared vault uuid', () => { + const payload = { + uuid: '123', + content_type: ContentType.Note, + shared_vault_uuid: 'shared-vault-uuid-123', + key_system_identifier: 'key-system-identifier-123', + } as jest.Mocked + + const itemsKey = { + creationTimestamp: 123, + keyVersion: ProtocolVersion.V004, + content_type: ContentType.KeySystemItemsKey, + } as jest.Mocked + + const authenticatedData = usecase.execute(payload, itemsKey) + + expect(authenticatedData).toEqual({ + u: payload.uuid, + v: ProtocolVersion.V004, + ksi: payload.key_system_identifier, + svu: payload.shared_vault_uuid, + }) + }) + + it('should include only uuid and version if non-keysystem item with items key', () => { + const payload = { + uuid: '123', + content_type: ContentType.Note, + } as jest.Mocked + + const itemsKey = { + content_type: ContentType.ItemsKey, + } as jest.Mocked + + const authenticatedData = usecase.execute(payload, itemsKey) + + expect(authenticatedData).toEqual({ + u: payload.uuid, + v: ProtocolVersion.V004, + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.ts new file mode 100644 index 000000000..6f31e7290 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateAuthenticatedData.ts @@ -0,0 +1,58 @@ +import { + DecryptedPayloadInterface, + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, + isKeySystemRootKey, + ContentTypeUsesRootKeyEncryption, + ContentTypeUsesKeySystemRootKeyEncryption, +} from '@standardnotes/models' +import { ItemAuthenticatedData } from '../../../../Types/ItemAuthenticatedData' +import { RootKeyEncryptedAuthenticatedData } from '../../../../Types/RootKeyEncryptedAuthenticatedData' +import { KeySystemItemsKeyAuthenticatedData } from '../../../../Types/KeySystemItemsKeyAuthenticatedData' +import { ProtocolVersion } from '@standardnotes/common' +import { isItemsKey } from '../../../../Keys/ItemsKey/ItemsKey' +import { isKeySystemItemsKey } from '../../../../Keys/KeySystemItemsKey/KeySystemItemsKey' + +export class GenerateAuthenticatedDataUseCase { + execute( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): ItemAuthenticatedData | RootKeyEncryptedAuthenticatedData | KeySystemItemsKeyAuthenticatedData { + const baseData: ItemAuthenticatedData = { + u: payload.uuid, + v: ProtocolVersion.V004, + } + + if (payload.key_system_identifier) { + baseData.ksi = payload.key_system_identifier + } + + if (payload.shared_vault_uuid) { + baseData.svu = payload.shared_vault_uuid + } + + if (ContentTypeUsesRootKeyEncryption(payload.content_type)) { + return { + ...baseData, + kp: (key as RootKeyInterface).keyParams.content, + } + } else if (ContentTypeUsesKeySystemRootKeyEncryption(payload.content_type)) { + if (!isKeySystemRootKey(key)) { + throw Error( + `Attempting to use non-key system root key ${key.content_type} for item content type ${payload.content_type}`, + ) + } + return { + ...baseData, + kp: key.keyParams, + } + } else { + if (!isItemsKey(key) && !isKeySystemItemsKey(key)) { + throw Error('Attempting to use non-items key for regular item.') + } + return baseData + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.spec.ts new file mode 100644 index 000000000..db5583165 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.spec.ts @@ -0,0 +1,80 @@ +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { getMockedCrypto } from '../../MockedCrypto' +import { GenerateDecryptedParametersUseCase } from './GenerateDecryptedParameters' +import { ContentType } from '@standardnotes/common' +import { DecryptedPayloadInterface, ItemsKeyInterface } from '@standardnotes/models' +import { GenerateEncryptedParametersUseCase } from './GenerateEncryptedParameters' +import { EncryptedInputParameters, EncryptedOutputParameters } from '../../../../Types/EncryptedParameters' + +describe('generate decrypted parameters usecase', () => { + let crypto: PureCryptoInterface + let usecase: GenerateDecryptedParametersUseCase + let signingKeyPair: PkcKeyPair + let itemsKey: ItemsKeyInterface + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new GenerateDecryptedParametersUseCase(crypto) + itemsKey = { + uuid: 'items-key-id', + itemsKey: 'items-key', + content_type: ContentType.ItemsKey, + } as jest.Mocked + }) + + const generateEncryptedParameters = (plaintext: string) => { + const decrypted = { + uuid: '123', + content: { + text: plaintext, + }, + content_type: ContentType.Note, + } as unknown as jest.Mocked + + const encryptedParametersUsecase = new GenerateEncryptedParametersUseCase(crypto) + return encryptedParametersUsecase.execute(decrypted, itemsKey, signingKeyPair) as T + } + + describe('without signatures', () => { + it('should generate decrypted parameters', () => { + const encrypted = generateEncryptedParameters('foo') + + const result = usecase.execute(encrypted, itemsKey) + + expect(result).toEqual({ + uuid: expect.any(String), + content: expect.any(Object), + signatureData: { + required: false, + contentHash: expect.any(String), + }, + }) + }) + }) + + describe('with signatures', () => { + beforeEach(() => { + signingKeyPair = crypto.sodiumCryptoSignSeedKeypair('seedling') + }) + + it('should generate decrypted parameters', () => { + const encrypted = generateEncryptedParameters('foo') + + const result = usecase.execute(encrypted, itemsKey) + + expect(result).toEqual({ + uuid: expect.any(String), + content: expect.any(Object), + signatureData: { + required: false, + contentHash: expect.any(String), + result: { + passes: true, + publicKey: signingKeyPair.publicKey, + signature: expect.any(String), + }, + }, + }) + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.ts new file mode 100644 index 000000000..8168136c5 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateDecryptedParameters.ts @@ -0,0 +1,140 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { deconstructEncryptedPayloadString } from '../../V004AlgorithmHelpers' +import { + ItemContent, + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { StringToAuthenticatedDataUseCase } from '../Utils/StringToAuthenticatedData' +import { CreateConsistentBase64JsonPayloadUseCase } from '../Utils/CreateConsistentBase64JsonPayload' +import { GenerateSymmetricPayloadSignatureResultUseCase } from './GenerateSymmetricPayloadSignatureResult' +import { + EncryptedInputParameters, + EncryptedOutputParameters, + ErrorDecryptingParameters, +} from './../../../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../../../Types/DecryptedParameters' +import { DeriveHashingKeyUseCase } from '../Hash/DeriveHashingKey' + +export class GenerateDecryptedParametersUseCase { + private base64DataUsecase = new CreateConsistentBase64JsonPayloadUseCase(this.crypto) + private stringToAuthenticatedDataUseCase = new StringToAuthenticatedDataUseCase(this.crypto) + private signingVerificationUseCase = new GenerateSymmetricPayloadSignatureResultUseCase(this.crypto) + private deriveHashingKeyUseCase = new DeriveHashingKeyUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + encrypted: EncryptedInputParameters, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): DecryptedParameters | ErrorDecryptingParameters { + const contentKeyResult = this.decryptContentKey(encrypted, key) + if (!contentKeyResult) { + console.error('Error decrypting contentKey from parameters', encrypted) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const contentResult = this.decryptContent(encrypted, contentKeyResult.contentKey) + if (!contentResult) { + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const hashingKey = this.deriveHashingKeyUseCase.execute(key) + + const signatureVerificationResult = this.signingVerificationUseCase.execute( + encrypted, + hashingKey, + { + additionalData: contentKeyResult.components.additionalData, + plaintext: contentKeyResult.contentKey, + }, + { + additionalData: contentResult.components.additionalData, + plaintext: contentResult.content, + }, + ) + + return { + uuid: encrypted.uuid, + content: JSON.parse(contentResult.content), + signatureData: signatureVerificationResult, + } + } + + private decryptContent(encrypted: EncryptedOutputParameters, contentKey: string) { + const contentComponents = deconstructEncryptedPayloadString(encrypted.content) + + const contentAuthenticatedData = this.stringToAuthenticatedDataUseCase.execute( + contentComponents.authenticatedData, + { + u: encrypted.uuid, + v: encrypted.version, + ksi: encrypted.key_system_identifier, + svu: encrypted.shared_vault_uuid, + }, + ) + + const authenticatedDataString = this.base64DataUsecase.execute(contentAuthenticatedData) + + const content = this.crypto.xchacha20Decrypt( + contentComponents.ciphertext, + contentComponents.nonce, + contentKey, + authenticatedDataString, + ) + + if (!content) { + return null + } + + return { + content, + components: contentComponents, + authenticatedDataString, + } + } + + private decryptContentKey( + encrypted: EncryptedOutputParameters, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ) { + const contentKeyComponents = deconstructEncryptedPayloadString(encrypted.enc_item_key) + + const contentKeyAuthenticatedData = this.stringToAuthenticatedDataUseCase.execute( + contentKeyComponents.authenticatedData, + { + u: encrypted.uuid, + v: encrypted.version, + ksi: encrypted.key_system_identifier, + svu: encrypted.shared_vault_uuid, + }, + ) + + const authenticatedDataString = this.base64DataUsecase.execute(contentKeyAuthenticatedData) + + const contentKey = this.crypto.xchacha20Decrypt( + contentKeyComponents.ciphertext, + contentKeyComponents.nonce, + key.itemsKey, + authenticatedDataString, + ) + + if (!contentKey) { + return null + } + + return { + contentKey, + components: contentKeyComponents, + authenticatedDataString, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.spec.ts new file mode 100644 index 000000000..ed80094b8 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.spec.ts @@ -0,0 +1,137 @@ +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { getMockedCrypto } from '../../MockedCrypto' +import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common' +import { GenerateEncryptedParametersUseCase } from './GenerateEncryptedParameters' +import { + DecryptedPayloadInterface, + ItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { deconstructEncryptedPayloadString } from '../../V004AlgorithmHelpers' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' +import { SymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' + +describe('generate encrypted parameters usecase', () => { + let crypto: PureCryptoInterface + let usecase: GenerateEncryptedParametersUseCase + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new GenerateEncryptedParametersUseCase(crypto) + }) + + describe('without signing keypair', () => { + it('should generate encrypted parameters', () => { + const decrypted = { + uuid: '123', + content: { + title: 'title', + text: 'text', + }, + content_type: ContentType.Note, + } as unknown as jest.Mocked + + const itemsKey = { + uuid: 'items-key-id', + itemsKey: 'items-key', + content_type: ContentType.ItemsKey, + } as jest.Mocked + + const result = usecase.execute(decrypted, itemsKey) + + expect(result).toEqual({ + uuid: '123', + content_type: ContentType.Note, + items_key_id: 'items-key-id', + content: expect.any(String), + enc_item_key: expect.any(String), + version: ProtocolVersion.V004, + rawSigningDataClientOnly: undefined, + }) + }) + + it('should not include items_key_id if item to encrypt is items key payload', () => { + const decrypted = { + uuid: '123', + content: { + foo: 'bar', + }, + content_type: ContentType.ItemsKey, + } as unknown as jest.Mocked + + const rootKey = { + uuid: 'items-key-id', + itemsKey: 'items-key', + keyParams: { + content: {} as jest.Mocked, + }, + content_type: ContentType.RootKey, + } as jest.Mocked + + const result = usecase.execute(decrypted, rootKey) + + expect(result.items_key_id).toBeUndefined() + }) + + it('should not include items_key_id if item to encrypt is key system items key payload', () => { + const decrypted = { + uuid: '123', + content: { + foo: 'bar', + }, + content_type: ContentType.KeySystemItemsKey, + } as unknown as jest.Mocked + + const rootKey = { + uuid: 'items-key-id', + itemsKey: 'items-key', + content_type: ContentType.KeySystemRootKey, + } as jest.Mocked + + const result = usecase.execute(decrypted, rootKey) + + expect(result.items_key_id).toBeUndefined() + }) + }) + + describe('with signing keypair', () => { + let signingKeyPair: PkcKeyPair + let parseBase64Usecase: ParseConsistentBase64JsonPayloadUseCase + + beforeEach(() => { + signingKeyPair = crypto.sodiumCryptoSignSeedKeypair('seedling') + parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(crypto) + }) + + it('encrypted string should include additional data', () => { + const decrypted = { + uuid: '123', + content: { + title: 'title', + text: 'text', + }, + content_type: ContentType.Note, + } as unknown as jest.Mocked + + const itemsKey = { + uuid: 'items-key-id', + itemsKey: 'items-key', + content_type: ContentType.ItemsKey, + } as jest.Mocked + + const result = usecase.execute(decrypted, itemsKey, signingKeyPair) + + const contentComponents = deconstructEncryptedPayloadString(result.content) + + const additionalData = parseBase64Usecase.execute(contentComponents.additionalData) + + expect(additionalData).toEqual({ + signingData: { + signature: expect.any(String), + publicKey: signingKeyPair.publicKey, + }, + }) + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.ts new file mode 100644 index 000000000..cbd2cce47 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedParameters.ts @@ -0,0 +1,120 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { + DecryptedPayloadInterface, + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { CreateConsistentBase64JsonPayloadUseCase } from '../Utils/CreateConsistentBase64JsonPayload' +import { doesPayloadRequireSigning } from '../../V004AlgorithmHelpers' +import { EncryptedOutputParameters } from '../../../../Types/EncryptedParameters' +import { GenerateAuthenticatedDataUseCase } from './GenerateAuthenticatedData' +import { GenerateEncryptedProtocolStringUseCase } from './GenerateEncryptedProtocolString' +import { GenerateSymmetricAdditionalDataUseCase } from './GenerateSymmetricAdditionalData' +import { isItemsKey } from '../../../../Keys/ItemsKey/ItemsKey' +import { isKeySystemItemsKey } from '../../../../Keys/KeySystemItemsKey/KeySystemItemsKey' +import { ItemAuthenticatedData } from '../../../../Types/ItemAuthenticatedData' +import { V004Algorithm } from '../../../../Algorithm' +import { AdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { HashingKey } from '../Hash/HashingKey' +import { DeriveHashingKeyUseCase } from '../Hash/DeriveHashingKey' + +export class GenerateEncryptedParametersUseCase { + private generateProtocolStringUseCase = new GenerateEncryptedProtocolStringUseCase(this.crypto) + private generateAuthenticatedDataUseCase = new GenerateAuthenticatedDataUseCase() + private generateAdditionalDataUseCase = new GenerateSymmetricAdditionalDataUseCase(this.crypto) + private encodeBase64DataUsecase = new CreateConsistentBase64JsonPayloadUseCase(this.crypto) + private deriveHashingKeyUseCase = new DeriveHashingKeyUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): EncryptedOutputParameters { + if (doesPayloadRequireSigning(payload) && !signingKeyPair) { + throw Error('Payload requires signing but no signing key pair was provided.') + } + + const commonAuthenticatedData = this.generateAuthenticatedDataUseCase.execute(payload, key) + + const hashingKey = this.deriveHashingKeyUseCase.execute(key) + + const { contentKey, encryptedContentKey } = this.generateEncryptedContentKey( + key, + hashingKey, + commonAuthenticatedData, + signingKeyPair, + ) + + const { encryptedContent } = this.generateEncryptedContent( + payload, + hashingKey, + contentKey, + commonAuthenticatedData, + signingKeyPair, + ) + + return { + uuid: payload.uuid, + content_type: payload.content_type, + items_key_id: isItemsKey(key) || isKeySystemItemsKey(key) ? key.uuid : undefined, + content: encryptedContent, + enc_item_key: encryptedContentKey, + version: ProtocolVersion.V004, + key_system_identifier: payload.key_system_identifier, + shared_vault_uuid: payload.shared_vault_uuid, + } + } + + private generateEncryptedContent( + payload: DecryptedPayloadInterface, + hashingKey: HashingKey, + contentKey: string, + commonAuthenticatedData: ItemAuthenticatedData, + signingKeyPair?: PkcKeyPair, + ): { + encryptedContent: string + } { + const content = JSON.stringify(payload.content) + + const { additionalData } = this.generateAdditionalDataUseCase.execute(content, hashingKey, signingKeyPair) + + const encryptedContent = this.generateProtocolStringUseCase.execute( + content, + contentKey, + this.encodeBase64DataUsecase.execute(commonAuthenticatedData), + this.encodeBase64DataUsecase.execute(additionalData), + ) + + return { + encryptedContent, + } + } + + private generateEncryptedContentKey( + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + hashingKey: HashingKey, + commonAuthenticatedData: ItemAuthenticatedData, + signingKeyPair?: PkcKeyPair, + ) { + const contentKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) + + const { additionalData } = this.generateAdditionalDataUseCase.execute(contentKey, hashingKey, signingKeyPair) + + const encryptedContentKey = this.generateProtocolStringUseCase.execute( + contentKey, + key.itemsKey, + this.encodeBase64DataUsecase.execute(commonAuthenticatedData), + this.encodeBase64DataUsecase.execute(additionalData), + ) + + return { + contentKey, + encryptedContentKey, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.spec.ts new file mode 100644 index 000000000..331cdaa04 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.spec.ts @@ -0,0 +1,44 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +import { ItemAuthenticatedData } from './../../../../Types/ItemAuthenticatedData' +import { GenerateEncryptedProtocolStringUseCase } from './GenerateEncryptedProtocolString' +import { AdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { getMockedCrypto } from '../../MockedCrypto' + +describe('generate encrypted protocol string', () => { + let crypto: PureCryptoInterface + let usecase: GenerateEncryptedProtocolStringUseCase + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new GenerateEncryptedProtocolStringUseCase(crypto) + }) + + it('should generate encrypted protocol string', () => { + const aad: ItemAuthenticatedData = { + u: '123', + v: ProtocolVersion.V004, + } + + const signingData: AdditionalData = {} + + const nonce = 'noncy' + crypto.generateRandomKey = jest.fn().mockReturnValue(nonce) + + const plaintext = 'foo' + + const result = usecase.execute( + plaintext, + 'secret', + crypto.base64Encode(JSON.stringify(aad)), + crypto.base64Encode(JSON.stringify(signingData)), + ) + + expect(result).toEqual( + `004:${nonce}:${plaintext}:${crypto.base64Encode(JSON.stringify(aad))}:${crypto.base64Encode( + JSON.stringify(signingData), + )}`, + ) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.ts new file mode 100644 index 000000000..caca147fc --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateEncryptedProtocolString.ts @@ -0,0 +1,41 @@ +import { Base64String, HexString, PureCryptoInterface, Utf8String } from '@standardnotes/sncrypto-common' +import { V004PartitionCharacter, V004StringComponents } from '../../V004AlgorithmTypes' +import { ProtocolVersion } from '@standardnotes/common' +import { V004Algorithm } from '../../../../Algorithm' + +export class GenerateEncryptedProtocolStringUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(plaintext: string, rawKey: string, authenticatedData: string, additionalData: string): string { + const nonce = this.crypto.generateRandomKey(V004Algorithm.EncryptionNonceLength) + + const ciphertext = this.encryptString(plaintext, rawKey, nonce, authenticatedData) + + const components: V004StringComponents = [ + ProtocolVersion.V004 as string, + nonce, + ciphertext, + authenticatedData, + additionalData, + ] + + return components.join(V004PartitionCharacter) + } + + encryptString( + plaintext: Utf8String, + rawKey: HexString, + nonce: HexString, + authenticatedData: Utf8String, + ): Base64String { + if (!nonce) { + throw 'encryptString null nonce' + } + + if (!rawKey) { + throw 'encryptString null rawKey' + } + + return this.crypto.xchacha20Encrypt(plaintext, nonce, rawKey, authenticatedData) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.spec.ts new file mode 100644 index 000000000..5b85bfb83 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.spec.ts @@ -0,0 +1,43 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +import { getMockedCrypto } from '../../MockedCrypto' +import { GenerateSymmetricAdditionalDataUseCase } from './GenerateSymmetricAdditionalData' +import { HashingKey } from '../Hash/HashingKey' + +describe('generate symmetric additional data usecase', () => { + let crypto: PureCryptoInterface + let usecase: GenerateSymmetricAdditionalDataUseCase + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new GenerateSymmetricAdditionalDataUseCase(crypto) + }) + + it('should generate signing data with signing keypair', () => { + const payloadPlaintext = 'foo' + const hashingKey: HashingKey = { key: 'secret-123' } + const signingKeyPair = crypto.sodiumCryptoSignSeedKeypair('seedling') + + const { additionalData, plaintextHash } = usecase.execute(payloadPlaintext, hashingKey, signingKeyPair) + + expect(additionalData).toEqual({ + signingData: { + publicKey: signingKeyPair.publicKey, + signature: crypto.sodiumCryptoSign(plaintextHash, signingKeyPair.privateKey), + }, + }) + + expect(plaintextHash).toEqual(crypto.sodiumCryptoGenericHash(payloadPlaintext, hashingKey.key)) + }) + + it('should generate empty signing data without signing keypair', () => { + const payloadPlaintext = 'foo' + const hashingKey: HashingKey = { key: 'secret-123' } + + const { additionalData, plaintextHash } = usecase.execute(payloadPlaintext, hashingKey, undefined) + + expect(additionalData).toEqual({}) + + expect(plaintextHash).toEqual(crypto.sodiumCryptoGenericHash(payloadPlaintext, hashingKey.key)) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.ts new file mode 100644 index 000000000..8d0f44b6b --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricAdditionalData.ts @@ -0,0 +1,37 @@ +import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { AdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { HashStringUseCase } from '../Hash/HashString' +import { HashingKey } from '../Hash/HashingKey' + +export class GenerateSymmetricAdditionalDataUseCase { + private hashUseCase = new HashStringUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + payloadPlaintext: string, + hashingKey: HashingKey, + signingKeyPair?: PkcKeyPair, + ): { additionalData: AdditionalData; plaintextHash: string } { + const plaintextHash = this.hashUseCase.execute(payloadPlaintext, hashingKey) + + if (!signingKeyPair) { + return { + additionalData: {}, + plaintextHash, + } + } + + const signature = this.crypto.sodiumCryptoSign(plaintextHash, signingKeyPair.privateKey) + + return { + additionalData: { + signingData: { + publicKey: signingKeyPair.publicKey, + signature, + }, + }, + plaintextHash, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.spec.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.spec.ts new file mode 100644 index 000000000..1ab51e745 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.spec.ts @@ -0,0 +1,303 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { getMockedCrypto } from '../../MockedCrypto' +import { EncryptedInputParameters, EncryptedOutputParameters } from '../../../../Types/EncryptedParameters' +import { GenerateSymmetricPayloadSignatureResultUseCase } from './GenerateSymmetricPayloadSignatureResult' +import { GenerateSymmetricAdditionalDataUseCase } from './GenerateSymmetricAdditionalData' +import { CreateConsistentBase64JsonPayloadUseCase } from '../Utils/CreateConsistentBase64JsonPayload' +import { doesPayloadRequireSigning } from '../../V004AlgorithmHelpers' +import { PersistentSignatureData } from '@standardnotes/models' +import { HashStringUseCase } from '../Hash/HashString' +import { HashingKey } from '../Hash/HashingKey' + +describe('generate symmetric signing data usecase', () => { + let crypto: PureCryptoInterface + let usecase: GenerateSymmetricPayloadSignatureResultUseCase + let hashUsecase: HashStringUseCase + let additionalDataUseCase: GenerateSymmetricAdditionalDataUseCase + let encodeUseCase: CreateConsistentBase64JsonPayloadUseCase + + beforeEach(() => { + crypto = getMockedCrypto() + usecase = new GenerateSymmetricPayloadSignatureResultUseCase(crypto) + hashUsecase = new HashStringUseCase(crypto) + additionalDataUseCase = new GenerateSymmetricAdditionalDataUseCase(crypto) + encodeUseCase = new CreateConsistentBase64JsonPayloadUseCase(crypto) + }) + + it('payload with shared vault uuid should require signature', () => { + const payload: Partial = { + shared_vault_uuid: '456', + } + + expect(doesPayloadRequireSigning(payload)).toBe(true) + }) + + it('payload with key system identifier only should not require signature', () => { + const payload: Partial = { + key_system_identifier: '123', + } + + expect(doesPayloadRequireSigning(payload)).toBe(false) + }) + + it('payload without key system identifier or shared vault uuid should not require signature', () => { + const payload: Partial = { + key_system_identifier: undefined, + shared_vault_uuid: undefined, + } + + expect(doesPayloadRequireSigning(payload)).toBe(false) + }) + + it('signature should be verified with correct parameters', () => { + const payload = { + key_system_identifier: '123', + shared_vault_uuid: '456', + } as jest.Mocked + + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + + const keypair = crypto.sodiumCryptoSignSeedKeypair('seedling') + + const contentAdditionalDataResultResult = additionalDataUseCase.execute(content, hashingKey, keypair) + + const contentKeyAdditionalDataResultResult = additionalDataUseCase.execute(contentKey, hashingKey, keypair) + + const result = usecase.execute( + payload, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResultResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResultResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: true, + contentHash: expect.any(String), + result: { + passes: true, + publicKey: keypair.publicKey, + signature: expect.any(String), + }, + }) + }) + + it('should return required false with no result if no signing data is provided and signing is not required', () => { + const payloadWithOptionalSigning = { + key_system_identifier: undefined, + shared_vault_uuid: undefined, + } as jest.Mocked + + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + + const contentAdditionalDataResult = additionalDataUseCase.execute(content, hashingKey, undefined) + const contentKeyAdditionalDataResult = additionalDataUseCase.execute(contentKey, hashingKey, undefined) + + const result = usecase.execute( + payloadWithOptionalSigning, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: false, + contentHash: expect.any(String), + }) + }) + + it('should return required true with fail result if no signing data is provided and signing is required', () => { + const payloadWithRequiredSigning = { + key_system_identifier: '123', + shared_vault_uuid: '456', + } as jest.Mocked + + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + + const contentAdditionalDataResult = additionalDataUseCase.execute(content, hashingKey, undefined) + const contentKeyAdditionalDataResult = additionalDataUseCase.execute(contentKey, hashingKey, undefined) + + const result = usecase.execute( + payloadWithRequiredSigning, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: true, + contentHash: expect.any(String), + result: { + passes: false, + publicKey: '', + signature: '', + }, + }) + }) + + it('should fail if content public key differs from contentKey public key', () => { + const payload = { + key_system_identifier: '123', + shared_vault_uuid: '456', + } as jest.Mocked + + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + + const contentKeyPair = crypto.sodiumCryptoSignSeedKeypair('contentseed') + const contentKeyKeyPair = crypto.sodiumCryptoSignSeedKeypair('contentkeyseed') + + const contentAdditionalDataResult = additionalDataUseCase.execute(content, hashingKey, contentKeyPair) + const contentKeyAdditionalDataResult = additionalDataUseCase.execute(contentKey, hashingKey, contentKeyKeyPair) + + const result = usecase.execute( + payload, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: true, + contentHash: expect.any(String), + result: { + passes: false, + publicKey: '', + signature: '', + }, + }) + }) + + it('if content hash has not changed and previous failing signature is supplied, new result should also be failing', () => { + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + const contentHash = hashUsecase.execute(content, hashingKey) + + const previousResult: PersistentSignatureData = { + required: true, + contentHash: contentHash, + result: { + passes: false, + publicKey: '', + signature: '', + }, + } + + const payload = { + key_system_identifier: '123', + shared_vault_uuid: '456', + signatureData: previousResult, + } as jest.Mocked + + const keypair = crypto.sodiumCryptoSignSeedKeypair('seedling') + + const contentAdditionalDataResultResult = additionalDataUseCase.execute(content, hashingKey, keypair) + + const contentKeyAdditionalDataResultResult = additionalDataUseCase.execute(contentKey, hashingKey, keypair) + + const result = usecase.execute( + payload, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResultResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResultResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: true, + contentHash: contentHash, + result: { + passes: false, + publicKey: keypair.publicKey, + signature: expect.any(String), + }, + }) + }) + + it('previous failing signature should be ignored if content hash has changed', () => { + const hashingKey: HashingKey = { key: 'secret-123' } + const content = 'contentplaintext' + const contentKey = 'contentkeysecret' + + const previousResult: PersistentSignatureData = { + required: true, + contentHash: 'different hash', + result: { + passes: false, + publicKey: '', + signature: '', + }, + } + + const payload = { + key_system_identifier: '123', + shared_vault_uuid: '456', + signatureData: previousResult, + } as jest.Mocked + + const keypair = crypto.sodiumCryptoSignSeedKeypair('seedling') + + const contentAdditionalDataResultResult = additionalDataUseCase.execute(content, hashingKey, keypair) + + const contentKeyAdditionalDataResultResult = additionalDataUseCase.execute(contentKey, hashingKey, keypair) + + const result = usecase.execute( + payload, + hashingKey, + { + additionalData: encodeUseCase.execute(contentKeyAdditionalDataResultResult.additionalData), + plaintext: contentKey, + }, + { + additionalData: encodeUseCase.execute(contentAdditionalDataResultResult.additionalData), + plaintext: content, + }, + ) + + expect(result).toEqual({ + required: true, + contentHash: expect.any(String), + result: { + passes: true, + publicKey: keypair.publicKey, + signature: expect.any(String), + }, + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.ts new file mode 100644 index 000000000..35f71748b --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GenerateSymmetricPayloadSignatureResult.ts @@ -0,0 +1,127 @@ +import { EncryptedInputParameters } from '../../../../Types/EncryptedParameters' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { doesPayloadRequireSigning } from '../../V004AlgorithmHelpers' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' +import { SymmetricItemAdditionalData } from '../../../../Types/EncryptionAdditionalData' +import { HashStringUseCase } from '../Hash/HashString' +import { PersistentSignatureData } from '@standardnotes/models' +import { HashingKey } from '../Hash/HashingKey' + +/** + * Embedded signatures check the signature on the symmetric string, but this string can change every time we encrypt + * the payload, even though its content hasn't changed. This would mean that if we received a signed payload from User B, + * then saved this payload into local storage by encrypting it, we would lose the signature of the content it came with, and + * it would instead be overwritten by our local user signature, which would always pass. + * + * In addition to embedded signature verification, we'll also hang on to a sticky signature of the content, which + * remains the same until the hash changes. We do not perform any static verification on this data; instead, clients + * can compute authenticity of the content on demand. + */ +export class GenerateSymmetricPayloadSignatureResultUseCase { + private parseBase64Usecase = new ParseConsistentBase64JsonPayloadUseCase(this.crypto) + private hashUseCase = new HashStringUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + payload: EncryptedInputParameters, + hashingKey: HashingKey, + contentKeyParameters: { + additionalData: string + plaintext: string + }, + contentParameters: { + additionalData: string + plaintext: string + }, + ): PersistentSignatureData { + const contentKeyHash = this.hashUseCase.execute(contentKeyParameters.plaintext, hashingKey) + + const contentHash = this.hashUseCase.execute(contentParameters.plaintext, hashingKey) + + const contentKeyAdditionalData = this.parseBase64Usecase.execute( + contentKeyParameters.additionalData, + ) + + const contentAdditionalData = this.parseBase64Usecase.execute( + contentParameters.additionalData, + ) + + const verificationRequired = doesPayloadRequireSigning(payload) + + if (!contentKeyAdditionalData.signingData || !contentAdditionalData.signingData) { + if (verificationRequired) { + return { + required: true, + contentHash: contentHash, + result: { + passes: false, + publicKey: '', + signature: '', + }, + } + } + return { + required: false, + contentHash: contentHash, + } + } + + if (contentKeyAdditionalData.signingData.publicKey !== contentAdditionalData.signingData.publicKey) { + return { + required: verificationRequired, + contentHash: contentHash, + result: { + passes: false, + publicKey: '', + signature: '', + }, + } + } + + const commonPublicKey = contentKeyAdditionalData.signingData.publicKey + + const contentKeySignatureVerified = this.verifySignature( + contentKeyHash, + contentKeyAdditionalData.signingData.signature, + commonPublicKey, + ) + + const contentSignatureVerified = this.verifySignature( + contentHash, + contentAdditionalData.signingData.signature, + commonPublicKey, + ) + + let passesStickyContentVerification = true + const previousSignatureResult = payload.signatureData + if (previousSignatureResult) { + const previousSignatureStillApplicable = previousSignatureResult.contentHash === contentHash + + if (previousSignatureStillApplicable) { + if (previousSignatureResult.required) { + passesStickyContentVerification = previousSignatureResult.result.passes + } else if (previousSignatureResult.result) { + passesStickyContentVerification = previousSignatureResult.result.passes + } + } + } + + const passesAllVerification = + contentKeySignatureVerified && contentSignatureVerified && passesStickyContentVerification + + return { + required: verificationRequired, + contentHash: contentHash, + result: { + passes: passesAllVerification, + publicKey: commonPublicKey, + signature: contentAdditionalData.signingData.signature, + }, + } + } + + private verifySignature(contentHash: string, signature: string, publicKey: string) { + return this.crypto.sodiumCryptoSignVerify(contentHash, signature, publicKey) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GetPayloadAuthenticatedDataDetached.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GetPayloadAuthenticatedDataDetached.ts new file mode 100644 index 000000000..9c0b802f2 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Symmetric/GetPayloadAuthenticatedDataDetached.ts @@ -0,0 +1,27 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { EncryptedOutputParameters } from '../../../../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../../../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../../../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../../../Types/LegacyAttachedData' +import { deconstructEncryptedPayloadString } from '../../V004AlgorithmHelpers' +import { ParseConsistentBase64JsonPayloadUseCase } from '../Utils/ParseConsistentBase64JsonPayload' + +export class GetPayloadAuthenticatedDataDetachedUseCase { + private parseStringUseCase = new ParseConsistentBase64JsonPayloadUseCase(this.crypto) + + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + encrypted: EncryptedOutputParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { + const itemKeyComponents = deconstructEncryptedPayloadString(encrypted.enc_item_key) + + const authenticatedDataString = itemKeyComponents.authenticatedData + + const result = this.parseStringUseCase.execute< + RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData + >(authenticatedDataString) + + return result + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Utils/CreateConsistentBase64JsonPayload.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/CreateConsistentBase64JsonPayload.ts new file mode 100644 index 000000000..306659d6e --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/CreateConsistentBase64JsonPayload.ts @@ -0,0 +1,10 @@ +import { Base64String, PureCryptoInterface } from '@standardnotes/sncrypto-common' +import * as Utils from '@standardnotes/utils' + +export class CreateConsistentBase64JsonPayloadUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute(jsonObject: T): Base64String { + return this.crypto.base64Encode(JSON.stringify(Utils.sortedCopy(Utils.omitUndefinedCopy(jsonObject)))) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Utils/ParseConsistentBase64JsonPayload.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/ParseConsistentBase64JsonPayload.ts new file mode 100644 index 000000000..90a32bba9 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/ParseConsistentBase64JsonPayload.ts @@ -0,0 +1,9 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +export class ParseConsistentBase64JsonPayloadUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute

(stringifiedData: string): P { + return JSON.parse(this.crypto.base64Decode(stringifiedData)) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/UseCase/Utils/StringToAuthenticatedData.ts b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/StringToAuthenticatedData.ts new file mode 100644 index 000000000..eb961e383 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/UseCase/Utils/StringToAuthenticatedData.ts @@ -0,0 +1,19 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { sortedCopy } from '@standardnotes/utils' +import { RootKeyEncryptedAuthenticatedData } from './../../../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from './../../../../Types/ItemAuthenticatedData' + +export class StringToAuthenticatedDataUseCase { + constructor(private readonly crypto: PureCryptoInterface) {} + + execute( + rawAuthenticatedData: string, + override: ItemAuthenticatedData, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData { + const base = JSON.parse(this.crypto.base64Decode(rawAuthenticatedData)) + return sortedCopy({ + ...base, + ...override, + }) + } +} diff --git a/packages/encryption/src/Domain/Operator/004/V004AlgorithmHelpers.ts b/packages/encryption/src/Domain/Operator/004/V004AlgorithmHelpers.ts new file mode 100644 index 000000000..93593f85a --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/V004AlgorithmHelpers.ts @@ -0,0 +1,20 @@ +import { V004Components, V004PartitionCharacter, V004StringComponents } from './V004AlgorithmTypes' + +export function doesPayloadRequireSigning(payload: { shared_vault_uuid?: string }) { + return payload.shared_vault_uuid != undefined +} + +export function deconstructEncryptedPayloadString(payloadString: string): V004Components { + /** Base64 encoding of JSON.stringify({}) */ + const EmptyAdditionalDataString = 'e30=' + + const components = payloadString.split(V004PartitionCharacter) as V004StringComponents + + return { + version: components[0], + nonce: components[1], + ciphertext: components[2], + authenticatedData: components[3], + additionalData: components[4] ?? EmptyAdditionalDataString, + } +} diff --git a/packages/encryption/src/Domain/Operator/004/V004AlgorithmTypes.ts b/packages/encryption/src/Domain/Operator/004/V004AlgorithmTypes.ts new file mode 100644 index 000000000..e16f92cc4 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/V004AlgorithmTypes.ts @@ -0,0 +1,32 @@ +export const V004AsymmetricCiphertextPrefix = '004_Asym' +export const V004PartitionCharacter = ':' + +export type V004StringComponents = [ + version: string, + nonce: string, + ciphertext: string, + authenticatedData: string, + additionalData: string, +] + +export type V004Components = { + version: V004StringComponents[0] + nonce: V004StringComponents[1] + ciphertext: V004StringComponents[2] + authenticatedData: V004StringComponents[3] + additionalData: V004StringComponents[4] +} + +export type V004AsymmetricStringComponents = [ + version: typeof V004AsymmetricCiphertextPrefix, + nonce: string, + ciphertext: string, + additionalData: string, +] + +export type V004AsymmetricComponents = { + version: V004AsymmetricStringComponents[0] + nonce: V004AsymmetricStringComponents[1] + ciphertext: V004AsymmetricStringComponents[2] + additionalData: V004AsymmetricStringComponents[3] +} diff --git a/packages/encryption/src/Domain/Operator/005/Operator005.spec.ts b/packages/encryption/src/Domain/Operator/005/Operator005.spec.ts deleted file mode 100644 index 50ce6cb2b..000000000 --- a/packages/encryption/src/Domain/Operator/005/Operator005.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ProtocolOperator005 } from './Operator005' -import { PureCryptoInterface } from '@standardnotes/sncrypto-common' - -describe('operator 005', () => { - let crypto: PureCryptoInterface - let operator: ProtocolOperator005 - - beforeEach(() => { - crypto = {} as jest.Mocked - crypto.generateRandomKey = jest.fn().mockImplementation(() => { - return 'random-string' - }) - crypto.xchacha20Encrypt = jest.fn().mockImplementation((text: string) => { - return `${text}` - }) - crypto.xchacha20Decrypt = jest.fn().mockImplementation((text: string) => { - return text.split('')[1] - }) - crypto.sodiumCryptoBoxGenerateKeypair = jest.fn().mockImplementation(() => { - return { privateKey: 'private-key', publicKey: 'public-key', keyType: 'x25519' } - }) - crypto.sodiumCryptoBoxEasyEncrypt = jest.fn().mockImplementation((text: string) => { - return `${text}` - }) - crypto.sodiumCryptoBoxEasyDecrypt = jest.fn().mockImplementation((text: string) => { - return text.split('')[1] - }) - - operator = new ProtocolOperator005(crypto) - }) - - it('should generateKeyPair', () => { - const result = operator.generateKeyPair() - - expect(result).toEqual({ privateKey: 'private-key', publicKey: 'public-key', keyType: 'x25519' }) - }) - - it('should asymmetricEncryptKey', () => { - const senderKeypair = operator.generateKeyPair() - const recipientKeypair = operator.generateKeyPair() - - const plaintext = 'foo' - - const result = operator.asymmetricEncryptKey(plaintext, senderKeypair.privateKey, recipientKeypair.publicKey) - - expect(result).toEqual(`${'005_KeyAsym'}:random-string:foo`) - }) - - it('should asymmetricDecryptKey', () => { - const senderKeypair = operator.generateKeyPair() - const recipientKeypair = operator.generateKeyPair() - const plaintext = 'foo' - const ciphertext = operator.asymmetricEncryptKey(plaintext, senderKeypair.privateKey, recipientKeypair.publicKey) - const decrypted = operator.asymmetricDecryptKey(ciphertext, senderKeypair.publicKey, recipientKeypair.privateKey) - - expect(decrypted).toEqual('foo') - }) - - it('should symmetricEncryptPrivateKey', () => { - const keypair = operator.generateKeyPair() - const symmetricKey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - const encryptedKey = operator.symmetricEncryptPrivateKey(keypair.privateKey, symmetricKey) - - expect(encryptedKey).toEqual(`${'005_KeySym'}:random-string:${keypair.privateKey}`) - }) - - it('should symmetricDecryptPrivateKey', () => { - const keypair = operator.generateKeyPair() - const symmetricKey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - const encryptedKey = operator.symmetricEncryptPrivateKey(keypair.privateKey, symmetricKey) - const decryptedKey = operator.symmetricDecryptPrivateKey(encryptedKey, symmetricKey) - - expect(decryptedKey).toEqual(keypair.privateKey) - }) -}) diff --git a/packages/encryption/src/Domain/Operator/005/Operator005.ts b/packages/encryption/src/Domain/Operator/005/Operator005.ts deleted file mode 100644 index d2d743a21..000000000 --- a/packages/encryption/src/Domain/Operator/005/Operator005.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ProtocolVersion } from '@standardnotes/common' -import { Base64String, HexString, PkcKeyPair, Utf8String } from '@standardnotes/sncrypto-common' -import { V005Algorithm } from '../../Algorithm' -import { SNProtocolOperator004 } from '../004/Operator004' - -const VersionString = '005' -const SymmetricCiphertextPrefix = `${VersionString}_KeySym` -const AsymmetricCiphertextPrefix = `${VersionString}_KeyAsym` - -export type AsymmetricallyEncryptedKey = Base64String -export type SymmetricallyEncryptedPrivateKey = Base64String - -/** - * @experimental - * @unreleased - */ -export class ProtocolOperator005 extends SNProtocolOperator004 { - public override getEncryptionDisplayName(): string { - return 'XChaCha20-Poly1305' - } - - override get version(): ProtocolVersion { - return VersionString as ProtocolVersion - } - - generateKeyPair(): PkcKeyPair { - return this.crypto.sodiumCryptoBoxGenerateKeypair() - } - - asymmetricEncryptKey( - keyToEncrypt: HexString, - senderSecretKey: HexString, - recipientPublicKey: HexString, - ): AsymmetricallyEncryptedKey { - const nonce = this.crypto.generateRandomKey(V005Algorithm.AsymmetricEncryptionNonceLength) - - const ciphertext = this.crypto.sodiumCryptoBoxEasyEncrypt(keyToEncrypt, nonce, senderSecretKey, recipientPublicKey) - - return [AsymmetricCiphertextPrefix, nonce, ciphertext].join(':') - } - - asymmetricDecryptKey( - keyToDecrypt: AsymmetricallyEncryptedKey, - senderPublicKey: HexString, - recipientSecretKey: HexString, - ): Utf8String { - const components = keyToDecrypt.split(':') - - const nonce = components[1] - - return this.crypto.sodiumCryptoBoxEasyDecrypt(keyToDecrypt, nonce, senderPublicKey, recipientSecretKey) - } - - symmetricEncryptPrivateKey(privateKey: HexString, symmetricKey: HexString): SymmetricallyEncryptedPrivateKey { - if (symmetricKey.length !== 64) { - throw new Error('Symmetric key length must be 256 bits') - } - - const nonce = this.crypto.generateRandomKey(V005Algorithm.SymmetricEncryptionNonceLength) - - const encryptedKey = this.crypto.xchacha20Encrypt(privateKey, nonce, symmetricKey) - - return [SymmetricCiphertextPrefix, nonce, encryptedKey].join(':') - } - - symmetricDecryptPrivateKey( - encryptedPrivateKey: SymmetricallyEncryptedPrivateKey, - symmetricKey: HexString, - ): HexString | null { - if (symmetricKey.length !== 64) { - throw new Error('Symmetric key length must be 256 bits') - } - - const components = encryptedPrivateKey.split(':') - - const nonce = components[1] - - return this.crypto.xchacha20Decrypt(encryptedPrivateKey, nonce, symmetricKey) - } -} diff --git a/packages/encryption/src/Domain/Operator/Functions.ts b/packages/encryption/src/Domain/Operator/Functions.ts index e5eb25f99..53125e2c7 100644 --- a/packages/encryption/src/Domain/Operator/Functions.ts +++ b/packages/encryption/src/Domain/Operator/Functions.ts @@ -4,12 +4,9 @@ import { SNProtocolOperator001 } from '../Operator/001/Operator001' import { SNProtocolOperator002 } from '../Operator/002/Operator002' import { SNProtocolOperator003 } from '../Operator/003/Operator003' import { SNProtocolOperator004 } from '../Operator/004/Operator004' -import { AsynchronousOperator, SynchronousOperator } from '../Operator/Operator' +import { AnyOperatorInterface } from './OperatorInterface/TypeCheck' -export function createOperatorForVersion( - version: ProtocolVersion, - crypto: PureCryptoInterface, -): AsynchronousOperator | SynchronousOperator { +export function createOperatorForVersion(version: ProtocolVersion, crypto: PureCryptoInterface): AnyOperatorInterface { if (version === ProtocolVersion.V001) { return new SNProtocolOperator001(crypto) } else if (version === ProtocolVersion.V002) { @@ -22,9 +19,3 @@ export function createOperatorForVersion( throw Error(`Unable to find operator for version ${version}`) } } - -export function isAsyncOperator( - operator: AsynchronousOperator | SynchronousOperator, -): operator is AsynchronousOperator { - return (operator as AsynchronousOperator).generateDecryptedParametersAsync !== undefined -} diff --git a/packages/encryption/src/Domain/Operator/Operator.ts b/packages/encryption/src/Domain/Operator/Operator.ts deleted file mode 100644 index dd1b5d533..000000000 --- a/packages/encryption/src/Domain/Operator/Operator.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { KeyParamsOrigination } from '@standardnotes/common' -import * as Models from '@standardnotes/models' -import { ItemsKeyInterface, RootKeyInterface } from '@standardnotes/models' -import { SNRootKey } from '../Keys/RootKey/RootKey' -import { SNRootKeyParams } from '../Keys/RootKey/RootKeyParams' -import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../Types/EncryptedParameters' -import { ItemAuthenticatedData } from '../Types/ItemAuthenticatedData' -import { LegacyAttachedData } from '../Types/LegacyAttachedData' -import { RootKeyEncryptedAuthenticatedData } from '../Types/RootKeyEncryptedAuthenticatedData' - -/**w - * An operator is responsible for performing crypto operations, such as generating keys - * and encrypting/decrypting payloads. Operators interact directly with - * platform dependent SNPureCrypto implementation to directly access cryptographic primitives. - * Each operator is versioned according to the protocol version. Functions that are common - * across all versions appear in this generic parent class. - */ -export interface OperatorCommon { - createItemsKey(): ItemsKeyInterface - /** - * Returns encryption protocol display name - */ - getEncryptionDisplayName(): string - - readonly version: string - - /** - * Returns the payload's authenticated data. The passed payload must be in a - * non-decrypted, ciphertext state. - */ - getPayloadAuthenticatedData( - encrypted: EncryptedParameters, - ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined - - /** - * Computes a root key given a password and previous keyParams - * @param password - Plain string representing raw user password - */ - computeRootKey(password: string, keyParams: SNRootKeyParams): Promise - - /** - * Creates a new root key given an identifier and a user password - * @param identifier - Plain string representing a unique identifier - * for the user - * @param password - Plain string representing raw user password - */ - createRootKey(identifier: string, password: string, origination: KeyParamsOrigination): Promise -} - -export interface SynchronousOperator extends OperatorCommon { - /** - * Converts a bare payload into an encrypted one in the desired format. - * @param payload - The non-encrypted payload object to encrypt - * @param key - The key to use to encrypt the payload. Can be either - * a RootKey (when encrypting payloads that require root key encryption, such as encrypting - * items keys), or an ItemsKey (if encrypted regular items) - */ - generateEncryptedParametersSync( - payload: Models.DecryptedPayloadInterface, - key: ItemsKeyInterface | RootKeyInterface, - ): EncryptedParameters - - generateDecryptedParametersSync( - encrypted: EncryptedParameters, - key: ItemsKeyInterface | RootKeyInterface, - ): DecryptedParameters | ErrorDecryptingParameters -} - -export interface AsynchronousOperator extends OperatorCommon { - /** - * Converts a bare payload into an encrypted one in the desired format. - * @param payload - The non-encrypted payload object to encrypt - * @param key - The key to use to encrypt the payload. Can be either - * a RootKey (when encrypting payloads that require root key encryption, such as encrypting - * items keys), or an ItemsKey (if encrypted regular items) - */ - generateEncryptedParametersAsync( - payload: Models.DecryptedPayloadInterface, - key: ItemsKeyInterface | RootKeyInterface, - ): Promise - - generateDecryptedParametersAsync( - encrypted: EncryptedParameters, - key: ItemsKeyInterface | RootKeyInterface, - ): Promise | ErrorDecryptingParameters> -} diff --git a/packages/encryption/src/Domain/Operator/OperatorInterface/AsyncOperatorInterface.ts b/packages/encryption/src/Domain/Operator/OperatorInterface/AsyncOperatorInterface.ts new file mode 100644 index 000000000..c029ca7e1 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorInterface/AsyncOperatorInterface.ts @@ -0,0 +1,22 @@ +import { + DecryptedPayloadInterface, + ItemContent, + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { EncryptedOutputParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../Types/DecryptedParameters' + +export interface AsyncOperatorInterface { + generateEncryptedParametersAsync( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): Promise + + generateDecryptedParametersAsync( + encrypted: EncryptedOutputParameters, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): Promise | ErrorDecryptingParameters> +} diff --git a/packages/encryption/src/Domain/Operator/OperatorInterface/OperatorInterface.ts b/packages/encryption/src/Domain/Operator/OperatorInterface/OperatorInterface.ts new file mode 100644 index 000000000..1b51975e8 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorInterface/OperatorInterface.ts @@ -0,0 +1,102 @@ +import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { + ItemsKeyInterface, + RootKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + KeySystemIdentifier, + KeySystemRootKeyParamsInterface, +} from '@standardnotes/models' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { EncryptedOutputParameters } from '../../Types/EncryptedParameters' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../Types/LegacyAttachedData' +import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { HexString, PkcKeyPair } from '@standardnotes/sncrypto-common' +import { AsymmetricallyEncryptedString } from '../Types/Types' +import { AsymmetricDecryptResult } from '../Types/AsymmetricDecryptResult' +import { PublicKeySet } from '../Types/PublicKeySet' +import { AsymmetricSignatureVerificationDetachedResult } from '../Types/AsymmetricSignatureVerificationDetachedResult' + +/**w + * An operator is responsible for performing crypto operations, such as generating keys + * and encrypting/decrypting payloads. Operators interact directly with + * platform dependent SNPureCrypto implementation to directly access cryptographic primitives. + * Each operator is versioned according to the protocol version. Functions that are common + * across all versions appear in this generic parent class. + */ +export interface OperatorInterface { + /** + * Returns encryption protocol display name + */ + getEncryptionDisplayName(): string + + readonly version: string + + createItemsKey(): ItemsKeyInterface + + /** + * Returns the payload's authenticated data. The passed payload must be in a + * non-decrypted, ciphertext state. + */ + getPayloadAuthenticatedDataForExternalUse( + encrypted: EncryptedOutputParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined + + /** + * Computes a root key given a password and previous keyParams + * @param password - Plain string representing raw user password + */ + computeRootKey(password: string, keyParams: SNRootKeyParams): Promise + + /** + * Creates a new root key given an identifier and a user password + * @param identifier - Plain string representing a unique identifier + * for the user + * @param password - Plain string representing raw user password + */ + createRootKey( + identifier: string, + password: string, + origination: KeyParamsOrigination, + ): Promise + + createRandomizedKeySystemRootKey(dto: { systemIdentifier: KeySystemIdentifier }): KeySystemRootKeyInterface + + createUserInputtedKeySystemRootKey(dto: { + systemIdentifier: KeySystemIdentifier + userInputtedPassword: string + }): KeySystemRootKeyInterface + + deriveUserInputtedKeySystemRootKey(dto: { + keyParams: KeySystemRootKeyParamsInterface + userInputtedPassword: string + }): KeySystemRootKeyInterface + + createKeySystemItemsKey( + uuid: string, + keySystemIdentifier: KeySystemIdentifier, + sharedVaultUuid: string | undefined, + rootKeyToken: string, + ): KeySystemItemsKeyInterface + + asymmetricEncrypt(dto: { + stringToEncrypt: HexString + senderKeyPair: PkcKeyPair + senderSigningKeyPair: PkcKeyPair + recipientPublicKey: HexString + }): AsymmetricallyEncryptedString + + asymmetricDecrypt(dto: { + stringToDecrypt: AsymmetricallyEncryptedString + recipientSecretKey: HexString + }): AsymmetricDecryptResult | null + + asymmetricSignatureVerifyDetached( + encryptedString: AsymmetricallyEncryptedString, + ): AsymmetricSignatureVerificationDetachedResult + + getSenderPublicKeySetFromAsymmetricallyEncryptedString(string: AsymmetricallyEncryptedString): PublicKeySet + + versionForAsymmetricallyEncryptedString(encryptedString: string): ProtocolVersion +} diff --git a/packages/encryption/src/Domain/Operator/OperatorInterface/SyncOperatorInterface.ts b/packages/encryption/src/Domain/Operator/OperatorInterface/SyncOperatorInterface.ts new file mode 100644 index 000000000..fa2c5d9d5 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorInterface/SyncOperatorInterface.ts @@ -0,0 +1,31 @@ +import { + DecryptedPayloadInterface, + ItemContent, + ItemsKeyInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { PkcKeyPair } from '@standardnotes/sncrypto-common' +import { EncryptedOutputParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { DecryptedParameters } from '../../Types/DecryptedParameters' + +export interface SyncOperatorInterface { + /** + * Converts a bare payload into an encrypted one in the desired format. + * @param payload - The non-encrypted payload object to encrypt + * @param key - The key to use to encrypt the payload. Can be either + * a RootKey (when encrypting payloads that require root key encryption, such as encrypting + * items keys), or an ItemsKey (if encrypted regular items) + */ + generateEncryptedParameters( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): EncryptedOutputParameters + + generateDecryptedParameters( + encrypted: EncryptedOutputParameters, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, + ): DecryptedParameters | ErrorDecryptingParameters +} diff --git a/packages/encryption/src/Domain/Operator/OperatorInterface/TypeCheck.ts b/packages/encryption/src/Domain/Operator/OperatorInterface/TypeCheck.ts new file mode 100644 index 000000000..38b9b4c2a --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorInterface/TypeCheck.ts @@ -0,0 +1,13 @@ +import { AsyncOperatorInterface } from './AsyncOperatorInterface' +import { OperatorInterface } from './OperatorInterface' +import { SyncOperatorInterface } from './SyncOperatorInterface' + +export type AnyOperatorInterface = OperatorInterface & (AsyncOperatorInterface | SyncOperatorInterface) + +export function isAsyncOperator(operator: unknown): operator is AsyncOperatorInterface { + return 'generateEncryptedParametersAsync' in (operator as AsyncOperatorInterface) +} + +export function isSyncOperator(operator: unknown): operator is SyncOperatorInterface { + return !isAsyncOperator(operator) +} diff --git a/packages/encryption/src/Domain/Operator/OperatorManager.ts b/packages/encryption/src/Domain/Operator/OperatorManager.ts index 5441bcb31..82b710ad9 100644 --- a/packages/encryption/src/Domain/Operator/OperatorManager.ts +++ b/packages/encryption/src/Domain/Operator/OperatorManager.ts @@ -1,10 +1,10 @@ import { ProtocolVersion, ProtocolVersionLatest } from '@standardnotes/common' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { createOperatorForVersion } from './Functions' -import { AsynchronousOperator, SynchronousOperator } from './Operator' +import { AnyOperatorInterface } from './OperatorInterface/TypeCheck' export class OperatorManager { - private operators: Record = {} + private operators: Record = {} constructor(private crypto: PureCryptoInterface) { this.crypto = crypto @@ -15,7 +15,7 @@ export class OperatorManager { this.operators = {} } - public operatorForVersion(version: ProtocolVersion): SynchronousOperator | AsynchronousOperator { + public operatorForVersion(version: ProtocolVersion): AnyOperatorInterface { const operatorKey = version let operator = this.operators[operatorKey] if (!operator) { @@ -28,7 +28,7 @@ export class OperatorManager { /** * Returns the operator corresponding to the latest protocol version */ - public defaultOperator(): SynchronousOperator | AsynchronousOperator { + public defaultOperator(): AnyOperatorInterface { return this.operatorForVersion(ProtocolVersionLatest) } } diff --git a/packages/encryption/src/Domain/Operator/OperatorWrapper.ts b/packages/encryption/src/Domain/Operator/OperatorWrapper.ts index 323d8dcb5..c2aed9c59 100644 --- a/packages/encryption/src/Domain/Operator/OperatorWrapper.ts +++ b/packages/encryption/src/Domain/Operator/OperatorWrapper.ts @@ -4,49 +4,53 @@ import { RootKeyInterface, ItemContent, EncryptedPayloadInterface, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, } from '@standardnotes/models' import { - DecryptedParameters, - EncryptedParameters, - encryptedParametersFromPayload, + EncryptedOutputParameters, + encryptedInputParametersFromPayload, ErrorDecryptingParameters, } from '../Types/EncryptedParameters' -import { isAsyncOperator } from './Functions' +import { DecryptedParameters } from '../Types/DecryptedParameters' import { OperatorManager } from './OperatorManager' +import { PkcKeyPair } from '@standardnotes/sncrypto-common' +import { isAsyncOperator } from './OperatorInterface/TypeCheck' export async function encryptPayload( payload: DecryptedPayloadInterface, - key: ItemsKeyInterface | RootKeyInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, operatorManager: OperatorManager, -): Promise { + signingKeyPair: PkcKeyPair | undefined, +): Promise { const operator = operatorManager.operatorForVersion(key.keyVersion) - let encryptionParameters + let result: EncryptedOutputParameters | undefined = undefined if (isAsyncOperator(operator)) { - encryptionParameters = await operator.generateEncryptedParametersAsync(payload, key) + result = await operator.generateEncryptedParametersAsync(payload, key) } else { - encryptionParameters = operator.generateEncryptedParametersSync(payload, key) + result = operator.generateEncryptedParameters(payload, key, signingKeyPair) } - if (!encryptionParameters) { + if (!result) { throw 'Unable to generate encryption parameters' } - return encryptionParameters + return result } export async function decryptPayload( payload: EncryptedPayloadInterface, - key: ItemsKeyInterface | RootKeyInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | RootKeyInterface, operatorManager: OperatorManager, ): Promise | ErrorDecryptingParameters> { const operator = operatorManager.operatorForVersion(payload.version) try { if (isAsyncOperator(operator)) { - return await operator.generateDecryptedParametersAsync(encryptedParametersFromPayload(payload), key) + return await operator.generateDecryptedParametersAsync(encryptedInputParametersFromPayload(payload), key) } else { - return operator.generateDecryptedParametersSync(encryptedParametersFromPayload(payload), key) + return operator.generateDecryptedParameters(encryptedInputParametersFromPayload(payload), key) } } catch (e) { console.error('Error decrypting payload', payload, e) diff --git a/packages/encryption/src/Domain/Operator/Types/AsymmetricDecryptResult.ts b/packages/encryption/src/Domain/Operator/Types/AsymmetricDecryptResult.ts new file mode 100644 index 000000000..28cc8f061 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Types/AsymmetricDecryptResult.ts @@ -0,0 +1,8 @@ +import { HexString } from '@standardnotes/sncrypto-common' + +export type AsymmetricDecryptResult = { + plaintext: HexString + signatureVerified: boolean + signaturePublicKey: string + senderPublicKey: string +} diff --git a/packages/encryption/src/Domain/Operator/Types/AsymmetricSignatureVerificationDetachedResult.ts b/packages/encryption/src/Domain/Operator/Types/AsymmetricSignatureVerificationDetachedResult.ts new file mode 100644 index 000000000..5f3cdecb6 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Types/AsymmetricSignatureVerificationDetachedResult.ts @@ -0,0 +1,9 @@ +export type AsymmetricSignatureVerificationDetachedResult = + | { + signatureVerified: true + signaturePublicKey: string + senderPublicKey: string + } + | { + signatureVerified: false + } diff --git a/packages/encryption/src/Domain/Operator/Types/PublicKeySet.ts b/packages/encryption/src/Domain/Operator/Types/PublicKeySet.ts new file mode 100644 index 000000000..20c35d9c3 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Types/PublicKeySet.ts @@ -0,0 +1,4 @@ +export type PublicKeySet = { + encryption: string + signing: string +} diff --git a/packages/encryption/src/Domain/Operator/Types/Types.ts b/packages/encryption/src/Domain/Operator/Types/Types.ts new file mode 100644 index 000000000..f37bf6062 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Types/Types.ts @@ -0,0 +1,4 @@ +import { Base64String } from '@standardnotes/sncrypto-common' + +export type AsymmetricallyEncryptedString = Base64String +export type SymmetricallyEncryptedString = Base64String diff --git a/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts b/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts index 133133c77..39d62fb4c 100644 --- a/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts +++ b/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts @@ -1,3 +1,4 @@ +import { AsymmetricSignatureVerificationDetachedResult } from '../../Operator/Types/AsymmetricSignatureVerificationDetachedResult' import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' import { BackupFile, @@ -6,17 +7,26 @@ import { ItemContent, ItemsKeyInterface, RootKeyInterface, + KeySystemIdentifier, + KeySystemItemsKeyInterface, + AsymmetricMessagePayload, + KeySystemRootKeyInterface, + KeySystemRootKeyParamsInterface, + TrustedContactInterface, } from '@standardnotes/models' import { ClientDisplayableError } from '@standardnotes/responses' - import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' import { KeyedDecryptionSplit } from '../../Split/KeyedDecryptionSplit' import { KeyedEncryptionSplit } from '../../Split/KeyedEncryptionSplit' import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' -import { LegacyAttachedData } from '../../Types/LegacyAttachedData' -import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { PkcKeyPair } from '@standardnotes/sncrypto-common' +import { PublicKeySet } from '../../Operator/Types/PublicKeySet' +import { KeySystemKeyManagerInterface } from '../KeySystemKeyManagerInterface' +import { AsymmetricallyEncryptedString } from '../../Operator/Types/Types' export interface EncryptionProviderInterface { + keys: KeySystemKeyManagerInterface + encryptSplitSingle(split: KeyedEncryptionSplit): Promise encryptSplit(split: KeyedEncryptionSplit): Promise decryptSplitSingle< @@ -31,29 +41,24 @@ export interface EncryptionProviderInterface { >( split: KeyedDecryptionSplit, ): Promise<(P | EncryptedPayloadInterface)[]> - hasRootKeyEncryptionSource(): boolean - getKeyEmbeddedKeyParams(key: EncryptedPayloadInterface): SNRootKeyParams | undefined - computeRootKey(password: string, keyParams: SNRootKeyParams): Promise + + getEmbeddedPayloadAuthenticatedData( + payload: EncryptedPayloadInterface, + ): D | undefined + getKeyEmbeddedKeyParamsFromItemsKey(key: EncryptedPayloadInterface): SNRootKeyParams | undefined + supportedVersions(): ProtocolVersion[] isVersionNewerThanLibraryVersion(version: ProtocolVersion): boolean platformSupportsKeyDerivation(keyParams: SNRootKeyParams): boolean - computeWrappingKey(passcode: string): Promise - getUserVersion(): ProtocolVersion | undefined + decryptBackupFile( file: BackupFile, password?: string, ): Promise + + getUserVersion(): ProtocolVersion | undefined hasAccount(): boolean - decryptErroredPayloads(): Promise - deleteWorkspaceSpecificKeyStateFromDevice(): Promise hasPasscode(): boolean - createRootKey( - identifier: string, - password: string, - origination: KeyParamsOrigination, - version?: ProtocolVersion, - ): Promise - setNewRootKeyWrapper(wrappingKey: RootKeyInterface): Promise removePasscode(): Promise validateAccountPassword(password: string): Promise< | { @@ -66,11 +71,63 @@ export interface EncryptionProviderInterface { valid: boolean } > + + decryptErroredPayloads(): Promise + deleteWorkspaceSpecificKeyStateFromDevice(): Promise + + computeRootKey(password: string, keyParams: SNRootKeyParams): Promise + computeWrappingKey(passcode: string): Promise + hasRootKeyEncryptionSource(): boolean + createRootKey( + identifier: string, + password: string, + origination: KeyParamsOrigination, + version?: ProtocolVersion, + ): Promise + getRootKeyParams(): SNRootKeyParams | undefined + setNewRootKeyWrapper(wrappingKey: RootKeyInterface): Promise + createNewItemsKeyWithRollback(): Promise<() => Promise> - reencryptItemsKeys(): Promise + reencryptApplicableItemsAfterUserRootKeyChange(): Promise getSureDefaultItemsKey(): ItemsKeyInterface - getRootKeyParams(): Promise - getEmbeddedPayloadAuthenticatedData( - payload: EncryptedPayloadInterface, - ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined + + createRandomizedKeySystemRootKey(dto: { systemIdentifier: KeySystemIdentifier }): KeySystemRootKeyInterface + + createUserInputtedKeySystemRootKey(dto: { + systemIdentifier: KeySystemIdentifier + userInputtedPassword: string + }): KeySystemRootKeyInterface + + deriveUserInputtedKeySystemRootKey(dto: { + keyParams: KeySystemRootKeyParamsInterface + userInputtedPassword: string + }): KeySystemRootKeyInterface + + createKeySystemItemsKey( + uuid: string, + keySystemIdentifier: KeySystemIdentifier, + sharedVaultUuid: string | undefined, + rootKeyToken: string, + ): KeySystemItemsKeyInterface + + reencryptKeySystemItemsKeysForVault(keySystemIdentifier: KeySystemIdentifier): Promise + + getKeyPair(): PkcKeyPair + getSigningKeyPair(): PkcKeyPair + + asymmetricallyEncryptMessage(dto: { + message: AsymmetricMessagePayload + senderKeyPair: PkcKeyPair + senderSigningKeyPair: PkcKeyPair + recipientPublicKey: string + }): string + asymmetricallyDecryptMessage(dto: { + encryptedString: AsymmetricallyEncryptedString + trustedSender: TrustedContactInterface | undefined + privateKey: string + }): M | undefined + asymmetricSignatureVerifyDetached( + encryptedString: AsymmetricallyEncryptedString, + ): AsymmetricSignatureVerificationDetachedResult + getSenderPublicKeySetFromAsymmetricallyEncryptedString(string: string): PublicKeySet } diff --git a/packages/encryption/src/Domain/Service/KeySystemKeyManagerInterface.ts b/packages/encryption/src/Domain/Service/KeySystemKeyManagerInterface.ts new file mode 100644 index 000000000..38afcaade --- /dev/null +++ b/packages/encryption/src/Domain/Service/KeySystemKeyManagerInterface.ts @@ -0,0 +1,31 @@ +import { + EncryptedItemInterface, + KeySystemIdentifier, + KeySystemItemsKeyInterface, + KeySystemRootKeyInterface, + KeySystemRootKeyStorageMode, + VaultListingInterface, +} from '@standardnotes/models' + +export interface KeySystemKeyManagerInterface { + getAllKeySystemItemsKeys(): (KeySystemItemsKeyInterface | EncryptedItemInterface)[] + getKeySystemItemsKeys(systemIdentifier: KeySystemIdentifier): KeySystemItemsKeyInterface[] + getPrimaryKeySystemItemsKey(systemIdentifier: KeySystemIdentifier): KeySystemItemsKeyInterface + + /** Returns synced root keys, in addition to any local or ephemeral keys */ + getAllKeySystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface[] + getSyncedKeySystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface[] + getAllSyncedKeySystemRootKeys(): KeySystemRootKeyInterface[] + getKeySystemRootKeyWithToken( + systemIdentifier: KeySystemIdentifier, + keyIdentifier: string, + ): KeySystemRootKeyInterface | undefined + getPrimaryKeySystemRootKey(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface | undefined + + intakeNonPersistentKeySystemRootKey(key: KeySystemRootKeyInterface, storage: KeySystemRootKeyStorageMode): void + undoIntakeNonPersistentKeySystemRootKey(systemIdentifier: KeySystemIdentifier): void + + clearMemoryOfKeysRelatedToVault(vault: VaultListingInterface): void + deleteNonPersistentSystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): Promise + deleteAllSyncedKeySystemRootKeys(systemIdentifier: KeySystemIdentifier): Promise +} diff --git a/packages/encryption/src/Domain/Split/AbstractKeySplit.ts b/packages/encryption/src/Domain/Split/AbstractKeySplit.ts index 59a46b008..acc3ec37f 100644 --- a/packages/encryption/src/Domain/Split/AbstractKeySplit.ts +++ b/packages/encryption/src/Domain/Split/AbstractKeySplit.ts @@ -1,8 +1,10 @@ import { DecryptedPayloadInterface, EncryptedPayloadInterface, + KeySystemRootKeyInterface, ItemsKeyInterface, RootKeyInterface, + KeySystemItemsKeyInterface, } from '@standardnotes/models' export interface AbstractKeySplit { @@ -10,13 +12,20 @@ export interface AbstractKeySplit item.uuid === uuid) + if (inUsesKeySystemRootKey) { + return inUsesKeySystemRootKey + } + const inUsesItemsKeyWithKeyLookup = split.usesItemsKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) if (inUsesItemsKeyWithKeyLookup) { return inUsesItemsKeyWithKeyLookup @@ -56,6 +69,13 @@ export function FindPayloadInEncryptionSplit(uuid: string, split: KeyedEncryptio return inUsesRootKeyWithKeyLookup } + const inUsesKeySystemRootKeyWithKeyLookup = split.usesKeySystemRootKeyWithKeyLookup?.items.find( + (item) => item.uuid === uuid, + ) + if (inUsesKeySystemRootKeyWithKeyLookup) { + return inUsesKeySystemRootKeyWithKeyLookup + } + throw Error('Cannot find payload in encryption split') } @@ -70,6 +90,11 @@ export function FindPayloadInDecryptionSplit(uuid: string, split: KeyedDecryptio return inUsesRootKey } + const inUsesKeySystemRootKey = split.usesKeySystemRootKey?.items.find((item) => item.uuid === uuid) + if (inUsesKeySystemRootKey) { + return inUsesKeySystemRootKey + } + const inUsesItemsKeyWithKeyLookup = split.usesItemsKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) if (inUsesItemsKeyWithKeyLookup) { return inUsesItemsKeyWithKeyLookup @@ -80,5 +105,12 @@ export function FindPayloadInDecryptionSplit(uuid: string, split: KeyedDecryptio return inUsesRootKeyWithKeyLookup } + const inUsesKeySystemRootKeyWithKeyLookup = split.usesKeySystemRootKeyWithKeyLookup?.items.find( + (item) => item.uuid === uuid, + ) + if (inUsesKeySystemRootKeyWithKeyLookup) { + return inUsesKeySystemRootKeyWithKeyLookup + } + throw Error('Cannot find payload in encryption split') } diff --git a/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts b/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts index 34f5def1f..d032f6a2d 100644 --- a/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts +++ b/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts @@ -2,5 +2,6 @@ import { DecryptedPayloadInterface, EncryptedPayloadInterface } from '@standardn export interface EncryptionTypeSplit { rootKeyEncryption?: T[] + keySystemRootKeyEncryption?: T[] itemsKeyEncryption?: T[] } diff --git a/packages/encryption/src/Domain/Split/Functions.ts b/packages/encryption/src/Domain/Split/Functions.ts index ea39bcebf..e36065590 100644 --- a/packages/encryption/src/Domain/Split/Functions.ts +++ b/packages/encryption/src/Domain/Split/Functions.ts @@ -1,5 +1,9 @@ -import { DecryptedPayloadInterface, EncryptedPayloadInterface } from '@standardnotes/models' -import { ItemContentTypeUsesRootKeyEncryption } from '../Keys/RootKey/Functions' +import { + DecryptedPayloadInterface, + EncryptedPayloadInterface, + ContentTypeUsesKeySystemRootKeyEncryption, + ContentTypeUsesRootKeyEncryption, +} from '@standardnotes/models' import { EncryptionTypeSplit } from './EncryptionTypeSplit' export function SplitPayloadsByEncryptionType( @@ -7,10 +11,13 @@ export function SplitPayloadsByEncryptionType { const usesRootKey: T[] = [] const usesItemsKey: T[] = [] + const usesKeySystemRootKey: T[] = [] for (const item of payloads) { - if (ItemContentTypeUsesRootKeyEncryption(item.content_type)) { + if (ContentTypeUsesRootKeyEncryption(item.content_type)) { usesRootKey.push(item) + } else if (ContentTypeUsesKeySystemRootKeyEncryption(item.content_type)) { + usesKeySystemRootKey.push(item) } else { usesItemsKey.push(item) } @@ -19,5 +26,6 @@ export function SplitPayloadsByEncryptionType 0 ? usesRootKey : undefined, itemsKeyEncryption: usesItemsKey.length > 0 ? usesItemsKey : undefined, + keySystemRootKeyEncryption: usesKeySystemRootKey.length > 0 ? usesKeySystemRootKey : undefined, } } diff --git a/packages/encryption/src/Domain/Types/DecryptedParameters.ts b/packages/encryption/src/Domain/Types/DecryptedParameters.ts new file mode 100644 index 000000000..de54a3558 --- /dev/null +++ b/packages/encryption/src/Domain/Types/DecryptedParameters.ts @@ -0,0 +1,7 @@ +import { ItemContent, PersistentSignatureData } from '@standardnotes/models' + +export type DecryptedParameters = { + uuid: string + content: C + signatureData: PersistentSignatureData +} diff --git a/packages/encryption/src/Domain/Types/EncryptedParameters.ts b/packages/encryption/src/Domain/Types/EncryptedParameters.ts index b5ef78996..1640f0fc2 100644 --- a/packages/encryption/src/Domain/Types/EncryptedParameters.ts +++ b/packages/encryption/src/Domain/Types/EncryptedParameters.ts @@ -1,20 +1,23 @@ -import { ProtocolVersion } from '@standardnotes/common' -import { EncryptedPayloadInterface, ItemContent } from '@standardnotes/models' +import { ContentType, ProtocolVersion } from '@standardnotes/common' +import { EncryptedPayloadInterface, DecryptedPayloadInterface, PersistentSignatureData } from '@standardnotes/models' +import { DecryptedParameters } from './DecryptedParameters' -export type EncryptedParameters = { +export type EncryptedOutputParameters = { uuid: string content: string + content_type: ContentType items_key_id: string | undefined enc_item_key: string version: ProtocolVersion + key_system_identifier: string | undefined + shared_vault_uuid: string | undefined /** @deprecated */ auth_hash?: string } -export type DecryptedParameters = { - uuid: string - content: C +export type EncryptedInputParameters = EncryptedOutputParameters & { + signatureData: PersistentSignatureData | undefined } export type ErrorDecryptingParameters = { @@ -24,18 +27,27 @@ export type ErrorDecryptingParameters = { } export function isErrorDecryptingParameters( - x: EncryptedParameters | DecryptedParameters | ErrorDecryptingParameters, + x: + | EncryptedOutputParameters + | DecryptedParameters + | ErrorDecryptingParameters + | DecryptedPayloadInterface + | EncryptedPayloadInterface, ): x is ErrorDecryptingParameters { return (x as ErrorDecryptingParameters).errorDecrypting } -export function encryptedParametersFromPayload(payload: EncryptedPayloadInterface): EncryptedParameters { +export function encryptedInputParametersFromPayload(payload: EncryptedPayloadInterface): EncryptedInputParameters { return { uuid: payload.uuid, content: payload.content, + content_type: payload.content_type, items_key_id: payload.items_key_id, enc_item_key: payload.enc_item_key as string, version: payload.version, auth_hash: payload.auth_hash, + key_system_identifier: payload.key_system_identifier, + shared_vault_uuid: payload.shared_vault_uuid, + signatureData: payload.signatureData, } } diff --git a/packages/encryption/src/Domain/Types/EncryptionAdditionalData.ts b/packages/encryption/src/Domain/Types/EncryptionAdditionalData.ts new file mode 100644 index 000000000..0f7b80689 --- /dev/null +++ b/packages/encryption/src/Domain/Types/EncryptionAdditionalData.ts @@ -0,0 +1,15 @@ +export type SigningData = { + signature: string + publicKey: string +} + +export type SymmetricItemAdditionalData = { + signingData?: SigningData | undefined +} + +export type AsymmetricItemAdditionalData = { + signingData: SigningData + senderPublicKey: string +} + +export type AdditionalData = SymmetricItemAdditionalData | AsymmetricItemAdditionalData diff --git a/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts b/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts index 92fc43079..b9bf704f3 100644 --- a/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts +++ b/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts @@ -1,6 +1,12 @@ import { ProtocolVersion } from '@standardnotes/common' +type UserUuid = string +type KeySystemIdentifier = string +type SharedVaultUuid = string + export type ItemAuthenticatedData = { - u: string + u: UserUuid v: ProtocolVersion + ksi?: KeySystemIdentifier + svu?: SharedVaultUuid } diff --git a/packages/encryption/src/Domain/Types/KeySystemItemsKeyAuthenticatedData.ts b/packages/encryption/src/Domain/Types/KeySystemItemsKeyAuthenticatedData.ts new file mode 100644 index 000000000..bbf05e44a --- /dev/null +++ b/packages/encryption/src/Domain/Types/KeySystemItemsKeyAuthenticatedData.ts @@ -0,0 +1,7 @@ +import { ItemAuthenticatedData } from './ItemAuthenticatedData' +import { KeySystemRootKeyParamsInterface } from '@standardnotes/models' + +/** Authenticated data for key system items key payloads */ +export type KeySystemItemsKeyAuthenticatedData = ItemAuthenticatedData & { + kp: KeySystemRootKeyParamsInterface +} diff --git a/packages/encryption/src/Domain/Types/KeySystemRootKeyEncryptedAuthenticatedData.ts b/packages/encryption/src/Domain/Types/KeySystemRootKeyEncryptedAuthenticatedData.ts new file mode 100644 index 000000000..fa374784e --- /dev/null +++ b/packages/encryption/src/Domain/Types/KeySystemRootKeyEncryptedAuthenticatedData.ts @@ -0,0 +1,4 @@ +import { ItemAuthenticatedData } from './ItemAuthenticatedData' + +/** Authenticated data for payloads encrypted with a key system root key */ +export type KeySystemRootKeyEncryptedAuthenticatedData = ItemAuthenticatedData diff --git a/packages/encryption/src/Domain/index.ts b/packages/encryption/src/Domain/index.ts index 617a5f8f6..15f668b51 100644 --- a/packages/encryption/src/Domain/index.ts +++ b/packages/encryption/src/Domain/index.ts @@ -1,28 +1,43 @@ export * from './Algorithm' export * from './Backups/BackupFileType' + export * from './Keys/ItemsKey/ItemsKey' export * from './Keys/ItemsKey/ItemsKeyMutator' export * from './Keys/ItemsKey/Registration' + +export * from './Keys/KeySystemItemsKey/KeySystemItemsKey' +export * from './Keys/KeySystemItemsKey/KeySystemItemsKeyMutator' +export * from './Keys/KeySystemItemsKey/Registration' + export * from './Keys/RootKey/Functions' export * from './Keys/RootKey/KeyParamsFunctions' export * from './Keys/RootKey/ProtocolVersionForKeyParams' export * from './Keys/RootKey/RootKey' export * from './Keys/RootKey/RootKeyParams' export * from './Keys/RootKey/ValidKeyParamsKeys' + export * from './Keys/Utils/KeyRecoveryStrings' + export * from './Operator/001/Operator001' export * from './Operator/002/Operator002' export * from './Operator/003/Operator003' export * from './Operator/004/Operator004' -export * from './Operator/005/Operator005' +export * from './Operator/004/V004AlgorithmHelpers' + export * from './Operator/Functions' -export * from './Operator/Operator' +export * from './Operator/OperatorInterface/OperatorInterface' export * from './Operator/OperatorManager' export * from './Operator/OperatorWrapper' +export * from './Operator/Types/PublicKeySet' +export * from './Operator/Types/AsymmetricSignatureVerificationDetachedResult' +export * from './Operator/Types/Types' + export * from './Service/Encryption/EncryptionProviderInterface' +export * from './Service/KeySystemKeyManagerInterface' export * from './Service/Functions' export * from './Service/RootKey/KeyMode' export * from './Service/RootKey/RootKeyServiceEvent' + export * from './Split/AbstractKeySplit' export * from './Split/EncryptionSplit' export * from './Split/EncryptionTypeSplit' @@ -30,8 +45,11 @@ export * from './Split/Functions' export * from './Split/KeyedDecryptionSplit' export * from './Split/KeyedEncryptionSplit' export * from './StandardException' + export * from './Types/EncryptedParameters' +export * from './Types/DecryptedParameters' export * from './Types/ItemAuthenticatedData' export * from './Types/LegacyAttachedData' export * from './Types/RootKeyEncryptedAuthenticatedData' + export * from './Username/PrivateUsername' diff --git a/packages/encryption/tsconfig.json b/packages/encryption/tsconfig.json index f3dac14ef..44c846c10 100644 --- a/packages/encryption/tsconfig.json +++ b/packages/encryption/tsconfig.json @@ -1,13 +1,11 @@ { - "extends": "../../node_modules/@standardnotes/config/src/tsconfig.json", + "extends": "../../UILib.tsconfig.json", "compilerOptions": { "skipLibCheck": true, "rootDir": "./src", "outDir": "./dist", + "noEmit": true }, - "include": [ - "src/**/*" - ], - "references": [], - "exclude": ["**/*.spec.ts", "dist", "node_modules"] + "include": ["src/**/*"], + "exclude": ["node_modules"] } diff --git a/packages/features/package.json b/packages/features/package.json index 299ab07e7..2cde0e343 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -25,7 +25,7 @@ "test": "jest" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/domain-core": "^1.12.0", "@standardnotes/security": "^1.7.6", "reflect-metadata": "^0.1.13" diff --git a/packages/filepicker/package.json b/packages/filepicker/package.json index a099deeac..057b72119 100644 --- a/packages/filepicker/package.json +++ b/packages/filepicker/package.json @@ -26,7 +26,7 @@ "typescript": "*" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/files": "workspace:*", "@standardnotes/utils": "workspace:*", "@types/wicg-file-system-access": "^2020.9.5", diff --git a/packages/files/package.json b/packages/files/package.json index a90e6945a..bffda4dd6 100644 --- a/packages/files/package.json +++ b/packages/files/package.json @@ -28,7 +28,7 @@ "typescript": "*" }, "dependencies": { - "@standardnotes/common": "^1.46.4", + "@standardnotes/common": "^1.48.3", "@standardnotes/encryption": "workspace:*", "@standardnotes/models": "workspace:*", "@standardnotes/responses": "workspace:*", diff --git a/packages/files/src/Domain/Api/DownloadFileParams.ts b/packages/files/src/Domain/Api/DownloadFileParams.ts new file mode 100644 index 000000000..14b96ed04 --- /dev/null +++ b/packages/files/src/Domain/Api/DownloadFileParams.ts @@ -0,0 +1,11 @@ +import { FileContent } from '@standardnotes/models' +import { FileOwnershipType } from './FileOwnershipType' + +export type DownloadFileParams = { + file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] } + chunkIndex: number + valetToken: string + ownershipType: FileOwnershipType + contentRangeStart: number + onBytesReceived: (bytes: Uint8Array) => Promise +} diff --git a/packages/files/src/Domain/Api/FileOwnershipType.ts b/packages/files/src/Domain/Api/FileOwnershipType.ts new file mode 100644 index 000000000..acbb29641 --- /dev/null +++ b/packages/files/src/Domain/Api/FileOwnershipType.ts @@ -0,0 +1 @@ +export type FileOwnershipType = 'user' | 'shared-vault' diff --git a/packages/files/src/Domain/Api/FilesApiInterface.ts b/packages/files/src/Domain/Api/FilesApiInterface.ts index 07bae17ab..f09c72785 100644 --- a/packages/files/src/Domain/Api/FilesApiInterface.ts +++ b/packages/files/src/Domain/Api/FilesApiInterface.ts @@ -1,28 +1,38 @@ -import { StartUploadSessionResponse, HttpResponse, ClientDisplayableError } from '@standardnotes/responses' -import { FileContent } from '@standardnotes/models' +import { + StartUploadSessionResponse, + HttpResponse, + ClientDisplayableError, + ValetTokenOperation, +} from '@standardnotes/responses' +import { DownloadFileParams } from './DownloadFileParams' +import { FileOwnershipType } from './FileOwnershipType' export interface FilesApiInterface { - startUploadSession(apiToken: string): Promise> - - uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise - - closeUploadSession(apiToken: string): Promise - - downloadFile( - file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] }, - chunkIndex: number, - apiToken: string, - contentRangeStart: number, - onBytesReceived: (bytes: Uint8Array) => Promise, - ): Promise - - deleteFile(apiToken: string): Promise - - createFileValetToken( + createUserFileValetToken( remoteIdentifier: string, - operation: 'write' | 'read' | 'delete', + operation: ValetTokenOperation, unencryptedFileSize?: number, ): Promise - getFilesDownloadUrl(): string + startUploadSession( + valetToken: string, + ownershipType: FileOwnershipType, + ): Promise> + + uploadFileBytes( + valetToken: string, + ownershipType: FileOwnershipType, + chunkId: number, + encryptedBytes: Uint8Array, + ): Promise + + closeUploadSession(valetToken: string, ownershipType: FileOwnershipType): Promise + + downloadFile(params: DownloadFileParams): Promise + + moveFile(valetToken: string): Promise + + deleteFile(valetToken: string, ownershipType: FileOwnershipType): Promise + + getFilesDownloadUrl(ownershipType: FileOwnershipType): string } diff --git a/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts b/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts index ba26d4304..372499dbd 100644 --- a/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts +++ b/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts @@ -9,10 +9,12 @@ describe('download and decrypt', () => { let apiService: FilesApiInterface let operation: DownloadAndDecryptFileOperation let file: { + uuid: string encryptedChunkSizes: FileContent['encryptedChunkSizes'] encryptionHeader: FileContent['encryptionHeader'] remoteIdentifier: FileContent['remoteIdentifier'] key: FileContent['key'] + shared_vault_uuid: string | undefined } let crypto: PureCryptoInterface @@ -26,16 +28,16 @@ describe('download and decrypt', () => { apiService.downloadFile = jest .fn() .mockImplementation( - ( - _file: string, - _chunkIndex: number, - _apiToken: string, - _rangeStart: number, - onBytesReceived: (bytes: Uint8Array) => void, - ) => { + (params: { + _file: string + _chunkIndex: number + _apiToken: string + _rangeStart: number + onBytesReceived: (bytes: Uint8Array) => void + }) => { const receiveFile = async () => { for (let i = 0; i < NumChunks; i++) { - onBytesReceived(chunkOfSize(size)) + params.onBytesReceived(chunkOfSize(size)) await sleep(100, false) } @@ -50,7 +52,7 @@ describe('download and decrypt', () => { beforeEach(() => { apiService = {} as jest.Mocked - apiService.createFileValetToken = jest.fn() + apiService.createUserFileValetToken = jest.fn() downloadChunksOfSize(5) crypto = {} as jest.Mocked @@ -62,17 +64,19 @@ describe('download and decrypt', () => { crypto.xchacha20StreamDecryptorPush = jest.fn().mockReturnValue({ message: new Uint8Array([0xaa]), tag: 0 }) file = { + uuid: '123', encryptedChunkSizes: [100_000], remoteIdentifier: '123', key: 'secret', encryptionHeader: 'some-header', + shared_vault_uuid: undefined, } }) it('run should resolve when operation is complete', async () => { let receivedBytes = new Uint8Array() - operation = new DownloadAndDecryptFileOperation(file, crypto, apiService) + operation = new DownloadAndDecryptFileOperation(file, crypto, apiService, 'own') await operation.run(async (result) => { if (result) { @@ -87,15 +91,17 @@ describe('download and decrypt', () => { it('should correctly report progress', async () => { file = { + uuid: '123', encryptedChunkSizes: [100_000, 200_000, 200_000], remoteIdentifier: '123', key: 'secret', encryptionHeader: 'some-header', + shared_vault_uuid: undefined, } downloadChunksOfSize(100_000) - operation = new DownloadAndDecryptFileOperation(file, crypto, apiService) + operation = new DownloadAndDecryptFileOperation(file, crypto, apiService, 'own') const progress: FileDownloadProgress = await new Promise((resolve) => { // eslint-disable-next-line @typescript-eslint/require-await diff --git a/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts b/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts index a2f644332..38504b81c 100644 --- a/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts +++ b/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts @@ -21,6 +21,8 @@ export class DownloadAndDecryptFileOperation { constructor( private readonly file: { + uuid: string + shared_vault_uuid: string | undefined encryptedChunkSizes: FileContent['encryptedChunkSizes'] encryptionHeader: FileContent['encryptionHeader'] remoteIdentifier: FileContent['remoteIdentifier'] @@ -28,8 +30,9 @@ export class DownloadAndDecryptFileOperation { }, private readonly crypto: PureCryptoInterface, private readonly api: FilesApiInterface, + valetToken: string, ) { - this.downloader = new FileDownloader(this.file, this.api) + this.downloader = new FileDownloader(this.file, this.api, valetToken) } private createDecryptor(): FileDecryptor { diff --git a/packages/files/src/Domain/Operations/EncryptAndUpload.ts b/packages/files/src/Domain/Operations/EncryptAndUpload.ts index 71af93a31..6af4fdcc9 100644 --- a/packages/files/src/Domain/Operations/EncryptAndUpload.ts +++ b/packages/files/src/Domain/Operations/EncryptAndUpload.ts @@ -3,7 +3,7 @@ import { FileUploadResult } from '../Types/FileUploadResult' import { FileUploader } from '../UseCase/FileUploader' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { FileEncryptor } from '../UseCase/FileEncryptor' -import { FileContent } from '@standardnotes/models' +import { FileContent, VaultListingInterface } from '@standardnotes/models' import { FilesApiInterface } from '../Api/FilesApiInterface' export class EncryptAndUploadFileOperation { @@ -22,9 +22,10 @@ export class EncryptAndUploadFileOperation { key: FileContent['key'] remoteIdentifier: FileContent['remoteIdentifier'] }, - private apiToken: string, + private valetToken: string, private crypto: PureCryptoInterface, private api: FilesApiInterface, + public readonly vault?: VaultListingInterface, ) { this.encryptor = new FileEncryptor(file, this.crypto) this.uploader = new FileUploader(this.api) @@ -32,8 +33,8 @@ export class EncryptAndUploadFileOperation { this.encryptionHeader = this.encryptor.initializeHeader() } - public getApiToken(): string { - return this.apiToken + public getValetToken(): string { + return this.valetToken } public getProgress(): FileUploadProgress { @@ -79,7 +80,12 @@ export class EncryptAndUploadFileOperation { } private async uploadBytes(encryptedBytes: Uint8Array, chunkId: number): Promise { - const success = await this.uploader.uploadBytes(encryptedBytes, chunkId, this.apiToken) + const success = await this.uploader.uploadBytes( + encryptedBytes, + this.vault && this.vault.sharing ? 'shared-vault' : 'user', + chunkId, + this.valetToken, + ) return success } diff --git a/packages/files/src/Domain/Service/FilesClientInterface.ts b/packages/files/src/Domain/Service/FilesClientInterface.ts index 99c41f104..6f7b462c4 100644 --- a/packages/files/src/Domain/Service/FilesClientInterface.ts +++ b/packages/files/src/Domain/Service/FilesClientInterface.ts @@ -1,5 +1,5 @@ import { EncryptAndUploadFileOperation } from '../Operations/EncryptAndUpload' -import { FileItem, FileMetadata } from '@standardnotes/models' +import { FileItem, FileMetadata, VaultListingInterface, SharedVaultListingInterface } from '@standardnotes/models' import { ClientDisplayableError } from '@standardnotes/responses' import { FileDownloadProgress } from '../Types/FileDownloadProgress' import { FileSystemApi } from '../Api/FileSystemApi' @@ -8,15 +8,18 @@ import { FileSystemNoSelection } from '../Api/FileSystemNoSelection' import { FileBackupMetadataFile } from '../Device/FileBackupMetadataFile' export interface FilesClientInterface { - beginNewFileUpload(sizeInBytes: number): Promise + minimumChunkSize(): number + beginNewFileUpload( + sizeInBytes: number, + vault?: VaultListingInterface, + ): Promise pushBytesForUpload( operation: EncryptAndUploadFileOperation, bytes: Uint8Array, chunkId: number, isFinalChunk: boolean, ): Promise - finishUpload( operation: EncryptAndUploadFileOperation, fileMetadata: FileMetadata, @@ -29,20 +32,21 @@ export interface FilesClientInterface { deleteFile(file: FileItem): Promise - minimumChunkSize(): number - - isFileNameFileBackupRelated(name: string): 'metadata' | 'binary' | false - - decryptBackupMetadataFile(metdataFile: FileBackupMetadataFile): Promise + moveFileToSharedVault( + file: FileItem, + sharedVault: SharedVaultListingInterface, + ): Promise + moveFileOutOfSharedVault(file: FileItem): Promise selectFile(fileSystem: FileSystemApi): Promise + isFileNameFileBackupRelated(name: string): 'metadata' | 'binary' | false + decryptBackupMetadataFile(metdataFile: FileBackupMetadataFile): Promise readBackupFileAndSaveDecrypted( fileHandle: FileHandleRead, file: FileItem, fileSystem: FileSystemApi, ): Promise<'success' | 'aborted' | 'failed'> - readBackupFileBytesDecrypted( fileHandle: FileHandleRead, file: FileItem, diff --git a/packages/files/src/Domain/UseCase/FileDownloader.spec.ts b/packages/files/src/Domain/UseCase/FileDownloader.spec.ts index 2ef9d3cca..abd941ea4 100644 --- a/packages/files/src/Domain/UseCase/FileDownloader.spec.ts +++ b/packages/files/src/Domain/UseCase/FileDownloader.spec.ts @@ -6,6 +6,8 @@ describe('file downloader', () => { let apiService: FilesApiInterface let downloader: FileDownloader let file: { + uuid: string + shared_vault_uuid: string | undefined encryptedChunkSizes: FileContent['encryptedChunkSizes'] remoteIdentifier: FileContent['remoteIdentifier'] } @@ -14,20 +16,20 @@ describe('file downloader', () => { beforeEach(() => { apiService = {} as jest.Mocked - apiService.createFileValetToken = jest.fn() + apiService.createUserFileValetToken = jest.fn() apiService.downloadFile = jest .fn() .mockImplementation( - ( - _file: string, - _chunkIndex: number, - _apiToken: string, - _rangeStart: number, - onBytesReceived: (bytes: Uint8Array) => void, - ) => { + (params: { + _file: string + _chunkIndex: number + _apiToken: string + _rangeStart: number + onBytesReceived: (bytes: Uint8Array) => void + }) => { return new Promise((resolve) => { for (let i = 0; i < numChunks; i++) { - onBytesReceived(Uint8Array.from([0xaa])) + params.onBytesReceived(Uint8Array.from([0xaa])) } resolve() @@ -36,6 +38,8 @@ describe('file downloader', () => { ) file = { + uuid: '123', + shared_vault_uuid: undefined, encryptedChunkSizes: [100_000], remoteIdentifier: '123', } @@ -44,7 +48,7 @@ describe('file downloader', () => { it('should pass back bytes as they are received', async () => { let receivedBytes = new Uint8Array() - downloader = new FileDownloader(file, apiService) + downloader = new FileDownloader(file, apiService, 'valet-token') expect(receivedBytes.length).toBe(0) diff --git a/packages/files/src/Domain/UseCase/FileDownloader.ts b/packages/files/src/Domain/UseCase/FileDownloader.ts index ea1c3f93c..424d5dbda 100644 --- a/packages/files/src/Domain/UseCase/FileDownloader.ts +++ b/packages/files/src/Domain/UseCase/FileDownloader.ts @@ -21,10 +21,13 @@ export class FileDownloader { constructor( private file: { + uuid: string + shared_vault_uuid: string | undefined encryptedChunkSizes: FileContent['encryptedChunkSizes'] remoteIdentifier: FileContent['remoteIdentifier'] }, private readonly api: FilesApiInterface, + private readonly valetToken: string, ) {} private getProgress(): FileDownloadProgress { @@ -40,22 +43,10 @@ export class FileDownloader { } public async run(onEncryptedBytes: OnEncryptedBytes): Promise { - const tokenResult = await this.getValetToken() - - if (tokenResult instanceof ClientDisplayableError) { - return tokenResult - } - - return this.performDownload(tokenResult, onEncryptedBytes) + return this.performDownload(onEncryptedBytes) } - private async getValetToken(): Promise { - const tokenResult = await this.api.createFileValetToken(this.file.remoteIdentifier, 'read') - - return tokenResult - } - - private async performDownload(valetToken: string, onEncryptedBytes: OnEncryptedBytes): Promise { + private async performDownload(onEncryptedBytes: OnEncryptedBytes): Promise { const chunkIndex = 0 const startRange = 0 @@ -69,7 +60,14 @@ export class FileDownloader { await onEncryptedBytes(bytes, this.getProgress(), this.abort) } - const downloadPromise = this.api.downloadFile(this.file, chunkIndex, valetToken, startRange, onRemoteBytesReceived) + const downloadPromise = this.api.downloadFile({ + file: this.file, + chunkIndex, + valetToken: this.valetToken, + contentRangeStart: startRange, + onBytesReceived: onRemoteBytesReceived, + ownershipType: this.file.shared_vault_uuid ? 'shared-vault' : 'user', + }) const result = await Promise.race([this.abortDeferred.promise, downloadPromise]) diff --git a/packages/files/src/Domain/UseCase/FileUploader.spec.ts b/packages/files/src/Domain/UseCase/FileUploader.spec.ts index 4a31d101e..353b2f79d 100644 --- a/packages/files/src/Domain/UseCase/FileUploader.spec.ts +++ b/packages/files/src/Domain/UseCase/FileUploader.spec.ts @@ -2,7 +2,7 @@ import { FilesApiInterface } from '../Api/FilesApiInterface' import { FileUploader } from './FileUploader' describe('file uploader', () => { - let apiService + let apiService: FilesApiInterface let uploader: FileUploader beforeEach(() => { @@ -14,7 +14,7 @@ describe('file uploader', () => { it('should return true when a chunk is uploaded', async () => { const bytes = new Uint8Array() - const success = await uploader.uploadBytes(bytes, 2, 'api-token') + const success = await uploader.uploadBytes(bytes, 'user', 2, 'api-token') expect(success).toEqual(true) }) diff --git a/packages/files/src/Domain/UseCase/FileUploader.ts b/packages/files/src/Domain/UseCase/FileUploader.ts index d1c3acaa2..1141fe839 100644 --- a/packages/files/src/Domain/UseCase/FileUploader.ts +++ b/packages/files/src/Domain/UseCase/FileUploader.ts @@ -1,10 +1,16 @@ +import { FileOwnershipType } from '../Api/FileOwnershipType' import { FilesApiInterface } from '../Api/FilesApiInterface' export class FileUploader { constructor(private apiService: FilesApiInterface) {} - public async uploadBytes(encryptedBytes: Uint8Array, chunkId: number, apiToken: string): Promise { - const result = await this.apiService.uploadFileBytes(apiToken, chunkId, encryptedBytes) + public async uploadBytes( + encryptedBytes: Uint8Array, + ownershipType: FileOwnershipType, + chunkId: number, + apiToken: string, + ): Promise { + const result = await this.apiService.uploadFileBytes(apiToken, ownershipType, chunkId, encryptedBytes) return result } diff --git a/packages/files/src/Domain/index.ts b/packages/files/src/Domain/index.ts index 405e6c28b..07f8bcce6 100644 --- a/packages/files/src/Domain/index.ts +++ b/packages/files/src/Domain/index.ts @@ -5,6 +5,9 @@ export * from './Api/FilesApiInterface' export * from './Api/FileSystemApi' export * from './Api/FileSystemNoSelection' export * from './Api/FileSystemResult' +export * from './Api/DownloadFileParams' +export * from './Api/FileOwnershipType' + export * from './Cache/FileMemoryCache' export * from './Chunker/ByteChunker' export * from './Chunker/OnChunkCallback' diff --git a/packages/icons/src/Icons/ic-group.svg b/packages/icons/src/Icons/ic-group.svg new file mode 100644 index 000000000..8e3e3ddd7 --- /dev/null +++ b/packages/icons/src/Icons/ic-group.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/icons/src/Icons/index.ts b/packages/icons/src/Icons/index.ts index 53b9cb5e6..f83440a96 100644 --- a/packages/icons/src/Icons/index.ts +++ b/packages/icons/src/Icons/index.ts @@ -91,6 +91,7 @@ import FullscreenExitIcon from './ic-fullscreen-exit.svg' import FullscreenIcon from './ic-fullscreen.svg' import GiftOutlineIcon from './ic-gift-outline.svg' import GoogleKeepIcon from './ic-gkeep.svg' +import GroupIcon from './ic-group.svg' import HashtagFilledIcon from './ic-hashtag-filled.svg' import HashtagIcon from './ic-hashtag.svg' import HashtagOffIcon from './ic-hashtag-off.svg' @@ -301,6 +302,7 @@ export { FullscreenExitIcon, FullscreenIcon, GiftOutlineIcon, + GroupIcon, HashtagFilledIcon, HashtagIcon, HashtagOffIcon, diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index ac3cd08b2..a3217a32a 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -637,7 +637,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: 60195509584153283780abdac5569feffb8f08cc @@ -658,7 +658,7 @@ SPEC CHECKSUMS: MMKV: 9c4663aa7ca255d478ff10f2f5cb7d17c1651ccd MMKVCore: 89f5c8a66bba2dcd551779dea4d412eeec8ff5bb OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: bec48f07daf7bcdc2655a0cde84e07d24d2a9e2a RCTTypeSafety: 171394eebacf71e1cfad79dbfae7ee8fc16ca80a React: d7433ccb6a8c36e4cbed59a73c0700fc83c3e98a diff --git a/packages/mobile/ios/StandardNotes/Info.plist b/packages/mobile/ios/StandardNotes/Info.plist index ae73e210c..569f5dff8 100644 --- a/packages/mobile/ios/StandardNotes/Info.plist +++ b/packages/mobile/ios/StandardNotes/Info.plist @@ -66,7 +66,7 @@ NSCameraUsageDescription - Camera is optionally used to upload images and videos and scan QR codes using the TokenVault extension. + Camera is optionally used to upload images and videos and scan QR codes using the Authenticator extension. NSFaceIDUsageDescription Face ID is required to unlock your notes. NSLocationAlwaysUsageDescription diff --git a/packages/mobile/src/Lib/Database/Database.ts b/packages/mobile/src/Lib/Database/Database.ts index de5b14713..35f1d1f56 100644 --- a/packages/mobile/src/Lib/Database/Database.ts +++ b/packages/mobile/src/Lib/Database/Database.ts @@ -84,17 +84,31 @@ export class Database implements DatabaseInterface { metadataItems = this.metadataStore.runMigration(allEntries) } - const sorted = GetSortedPayloadsByPriority(metadataItems, options) + const { + itemsKeyPayloads, + keySystemRootKeyPayloads, + keySystemItemsKeyPayloads, + contentTypePriorityPayloads, + remainingPayloads, + } = GetSortedPayloadsByPriority(metadataItems, options) const itemsKeysChunk: DatabaseKeysLoadChunk = { - keys: sorted.itemsKeyPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), + keys: itemsKeyPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), + } + + const keySystemRootKeysChunk: DatabaseKeysLoadChunk = { + keys: keySystemRootKeyPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), + } + + const keySystemItemsKeysChunk: DatabaseKeysLoadChunk = { + keys: keySystemItemsKeyPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), } const contentTypePriorityChunk: DatabaseKeysLoadChunk = { - keys: sorted.contentTypePriorityPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), + keys: contentTypePriorityPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)), } - const remainingKeys = sorted.remainingPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)) + const remainingKeys = remainingPayloads.map((item) => this.databaseKeyForPayloadId(item.uuid)) const remainingKeysChunks: DatabaseKeysLoadChunk[] = [] for (let i = 0; i < remainingKeys.length; i += options.batchSize) { @@ -106,9 +120,11 @@ export class Database implements DatabaseInterface { const result: DatabaseKeysLoadChunkResponse = { keys: { itemsKeys: itemsKeysChunk, + keySystemRootKeys: keySystemRootKeysChunk, + keySystemItemsKeys: keySystemItemsKeysChunk, remainingChunks: [contentTypePriorityChunk, ...remainingKeysChunks], }, - remainingChunksItemCount: sorted.contentTypePriorityPayloads.length + sorted.remainingPayloads.length, + remainingChunksItemCount: contentTypePriorityPayloads.length + remainingPayloads.length, } return result diff --git a/packages/models/package.json b/packages/models/package.json index b1ffc717c..330a96d95 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -22,9 +22,10 @@ "test": "jest" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/features": "workspace:*", "@standardnotes/responses": "workspace:*", + "@standardnotes/sncrypto-common": "workspace:^", "@standardnotes/utils": "workspace:^", "lodash": "^4.17.21" }, diff --git a/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts b/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts index a3cd11887..256706a19 100644 --- a/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts +++ b/packages/models/src/Domain/Abstract/Contextual/ContextPayload.ts @@ -6,4 +6,9 @@ export interface ContextPayload { content_type: ContentType content: C | string | undefined deleted: boolean + + user_uuid?: string + key_system_identifier?: string | undefined + shared_vault_uuid?: string | undefined + last_edited_by_uuid?: string } diff --git a/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts b/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts index 54c038db7..57c402664 100644 --- a/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts +++ b/packages/models/src/Domain/Abstract/Contextual/FilteredServerItem.ts @@ -5,7 +5,7 @@ export interface FilteredServerItem extends ServerItemResponse { __passed_filter__: true } -export function CreateFilteredServerItem(item: ServerItemResponse): FilteredServerItem { +function CreateFilteredServerItem(item: ServerItemResponse): FilteredServerItem { return { ...item, __passed_filter__: true, diff --git a/packages/models/src/Domain/Abstract/Contextual/Functions.ts b/packages/models/src/Domain/Abstract/Contextual/Functions.ts index 3fd55da51..0dc5e276e 100644 --- a/packages/models/src/Domain/Abstract/Contextual/Functions.ts +++ b/packages/models/src/Domain/Abstract/Contextual/Functions.ts @@ -19,6 +19,8 @@ export function CreateEncryptedBackupFileContextPayload( updated_at_timestamp: fromPayload.updated_at_timestamp, updated_at: fromPayload.updated_at, uuid: fromPayload.uuid, + key_system_identifier: fromPayload.key_system_identifier, + shared_vault_uuid: fromPayload.shared_vault_uuid, } } @@ -35,5 +37,7 @@ export function CreateDecryptedBackupFileContextPayload( updated_at_timestamp: fromPayload.updated_at_timestamp, updated_at: fromPayload.updated_at, uuid: fromPayload.uuid, + key_system_identifier: fromPayload.key_system_identifier, + shared_vault_uuid: fromPayload.shared_vault_uuid, } } diff --git a/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts b/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts index e9c9ee6b1..2fde7044d 100644 --- a/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts +++ b/packages/models/src/Domain/Abstract/Contextual/LocalStorage.ts @@ -3,6 +3,7 @@ import { ItemContent } from '../Content/ItemContent' import { DecryptedPayloadInterface, DeletedPayloadInterface, EncryptedPayloadInterface } from '../Payload' import { useBoolean } from '@standardnotes/utils' import { EncryptedTransferPayload, isEncryptedTransferPayload } from '../TransferPayload' +import { PersistentSignatureData } from '../../Runtime/Encryption/PersistentSignatureData' export function isEncryptedLocalStoragePayload( p: LocalStorageEncryptedContextualPayload | LocalStorageDecryptedContextualPayload, @@ -25,6 +26,7 @@ export interface LocalStorageEncryptedContextualPayload extends ContextPayload { updated_at_timestamp: number updated_at: Date waitingForKey: boolean + signatureData?: PersistentSignatureData } export interface LocalStorageDecryptedContextualPayload extends ContextPayload { @@ -36,6 +38,7 @@ export interface LocalStorageDecryptedContextualPayload diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts index 8ae00ea3d..e1736e015 100644 --- a/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts +++ b/packages/models/src/Domain/Abstract/Item/Implementations/DecryptedItem.ts @@ -50,7 +50,7 @@ export class DecryptedItem return this.payload.content.references || [] } - public isReferencingItem(item: DecryptedItemInterface): boolean { + public isReferencingItem(item: { uuid: string }): boolean { return this.references.find((r) => r.uuid === item.uuid) != undefined } diff --git a/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts b/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts index 327478860..ba974a484 100644 --- a/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts +++ b/packages/models/src/Domain/Abstract/Item/Implementations/GenericItem.ts @@ -10,6 +10,7 @@ import { SingletonStrategy } from '../Types/SingletonStrategy' import { PayloadInterface } from '../../Payload/Interfaces/PayloadInterface' import { HistoryEntryInterface } from '../../../Runtime/History/HistoryEntryInterface' import { isDecryptedItem, isDeletedItem, isEncryptedErroredItem } from '../Interfaces/TypeCheck' +import { PersistentSignatureData } from '../../../Runtime/Encryption/PersistentSignatureData' export abstract class GenericItem

implements ItemInterface

{ payload: P @@ -43,6 +44,26 @@ export abstract class GenericItem

return this.payload.created_at } + get key_system_identifier(): string | undefined { + return this.payload.key_system_identifier + } + + get user_uuid(): string | undefined { + return this.payload.user_uuid + } + + get shared_vault_uuid(): string | undefined { + return this.payload.shared_vault_uuid + } + + get last_edited_by_uuid(): string | undefined { + return this.payload.last_edited_by_uuid + } + + get signatureData(): PersistentSignatureData | undefined { + return this.payload.signatureData + } + /** * The date timestamp the server set for this item upon it being synced * Undefined if never synced to a remote server. diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts index 68ed08d10..91699bbb3 100644 --- a/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/DecryptedItem.ts @@ -33,7 +33,7 @@ export interface DecryptedItemInterface payloadRepresentation(override?: Partial>): DecryptedPayloadInterface - isReferencingItem(item: DecryptedItemInterface): boolean + isReferencingItem(item: { uuid: string }): boolean getDomainData(domain: typeof ComponentDataDomain | typeof DefaultAppDomain): undefined | Record diff --git a/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts b/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts index 393cf7ac5..3e399aecf 100644 --- a/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts +++ b/packages/models/src/Domain/Abstract/Item/Interfaces/ItemInterface.ts @@ -5,6 +5,7 @@ import { PredicateInterface } from '../../../Runtime/Predicate/Interface' import { HistoryEntryInterface } from '../../../Runtime/History' import { ConflictStrategy } from '../Types/ConflictStrategy' import { SingletonStrategy } from '../Types/SingletonStrategy' +import { PersistentSignatureData } from '../../../Runtime/Encryption/PersistentSignatureData' export interface ItemInterface

{ payload: P @@ -14,6 +15,11 @@ export interface ItemInterface

{ readonly updatedAtString?: string uuid: string + get key_system_identifier(): string | undefined + get user_uuid(): string | undefined + get shared_vault_uuid(): string | undefined + get last_edited_by_uuid(): string | undefined + get signatureData(): PersistentSignatureData | undefined content_type: ContentType created_at: Date diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts index deb44e969..d0732847e 100644 --- a/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts +++ b/packages/models/src/Domain/Abstract/Item/Mutator/DecryptedItemMutator.ts @@ -10,13 +10,13 @@ import { DecryptedPayloadInterface } from '../../Payload/Interfaces/DecryptedPay import { ItemInterface } from '../Interfaces/ItemInterface' import { getIncrementedDirtyIndex } from '../../../Runtime/DirtyCounter/DirtyCounter' -export class DecryptedItemMutator extends ItemMutator< - DecryptedPayloadInterface, - DecryptedItemInterface -> { +export class DecryptedItemMutator< + C extends ItemContent = ItemContent, + I extends DecryptedItemInterface = DecryptedItemInterface, +> extends ItemMutator, I> { protected mutableContent: C - constructor(item: DecryptedItemInterface, type: MutationType) { + constructor(item: I, type: MutationType) { super(item, type) const mutableCopy = Copy(this.immutablePayload.content) @@ -43,6 +43,8 @@ export class DecryptedItemMutator extends I content: this.mutableContent, dirty: true, dirtyIndex: getIncrementedDirtyIndex(), + signatureData: undefined, + last_edited_by_uuid: undefined, }) return result diff --git a/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts b/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts index 2214a9d70..5594d1a5d 100644 --- a/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts +++ b/packages/models/src/Domain/Abstract/Item/Mutator/ItemMutator.ts @@ -3,6 +3,8 @@ import { PayloadInterface } from '../../Payload' import { ItemInterface } from '../Interfaces/ItemInterface' import { TransferPayload } from '../../TransferPayload' import { getIncrementedDirtyIndex } from '../../../Runtime/DirtyCounter/DirtyCounter' +import { KeySystemIdentifier } from '../../../Syncable/KeySystemRootKey/KeySystemIdentifier' +import { ContentTypeUsesRootKeyEncryption } from '../../../Runtime/Encryption/ContentTypeUsesRootKeyEncryption' /** * An item mutator takes in an item, and an operation, and returns the resulting payload. @@ -51,6 +53,26 @@ export class ItemMutator< }) } + public set key_system_identifier(keySystemIdentifier: KeySystemIdentifier | undefined) { + if (ContentTypeUsesRootKeyEncryption(this.immutableItem.content_type)) { + throw new Error('Cannot set key_system_identifier on a root key encrypted item') + } + + this.immutablePayload = this.immutablePayload.copy({ + key_system_identifier: keySystemIdentifier, + }) + } + + public set shared_vault_uuid(sharedVaultUuid: string | undefined) { + if (ContentTypeUsesRootKeyEncryption(this.immutableItem.content_type)) { + throw new Error('Cannot set shared_vault_uuid on a root key encrypted item') + } + + this.immutablePayload = this.immutablePayload.copy({ + shared_vault_uuid: sharedVaultUuid, + }) + } + public set errorDecrypting(_: boolean) { throw Error('This method is no longer implemented') } diff --git a/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts b/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts index ccd5a6830..d30c87e32 100644 --- a/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts +++ b/packages/models/src/Domain/Abstract/Payload/Implementations/PurePayload.ts @@ -5,6 +5,8 @@ import { PayloadSource } from '../Types/PayloadSource' import { TransferPayload } from '../../TransferPayload/Interfaces/TransferPayload' import { ItemContent } from '../../Content/ItemContent' import { SyncResolvedParams, SyncResolvedPayload } from '../../../Runtime/Deltas/Utilities/SyncResolvedPayload' +import { PersistentSignatureData } from '../../../Runtime/Encryption/PersistentSignatureData' +import { ContentTypeUsesRootKeyEncryption } from '../../../Runtime/Encryption/ContentTypeUsesRootKeyEncryption' type RequiredKeepUndefined = { [K in keyof T]-?: [T[K]] } extends infer U ? U extends Record @@ -33,18 +35,28 @@ export abstract class PurePayload, C extends ItemCo readonly lastSyncEnd?: Date readonly duplicate_of?: string + readonly user_uuid?: string + readonly key_system_identifier?: string | undefined + readonly shared_vault_uuid?: string | undefined + readonly last_edited_by_uuid?: string + + readonly signatureData?: PersistentSignatureData constructor(rawPayload: T, source = PayloadSource.Constructor) { - this.source = source - this.uuid = rawPayload.uuid - - if (!this.uuid) { + if (!rawPayload.uuid) { throw Error( `Attempting to construct payload with null uuid Content type: ${rawPayload.content_type}`, ) } + if (rawPayload.key_system_identifier && ContentTypeUsesRootKeyEncryption(rawPayload.content_type)) { + throw new Error('Rootkey-encrypted payload should not have a key system identifier') + } + + this.source = source + this.uuid = rawPayload.uuid + this.content = rawPayload.content this.content_type = rawPayload.content_type this.deleted = useBoolean(rawPayload.deleted, false) @@ -63,6 +75,13 @@ export abstract class PurePayload, C extends ItemCo this.dirtyIndex = rawPayload.dirtyIndex this.globalDirtyIndexAtLastSync = rawPayload.globalDirtyIndexAtLastSync + this.user_uuid = rawPayload.user_uuid ?? undefined + this.key_system_identifier = rawPayload.key_system_identifier ?? undefined + this.shared_vault_uuid = rawPayload.shared_vault_uuid ?? undefined + this.last_edited_by_uuid = rawPayload.last_edited_by_uuid ?? undefined + + this.signatureData = rawPayload.signatureData + const timeToAllowSubclassesToFinishConstruction = 0 setTimeout(() => { deepFreeze(this) @@ -85,6 +104,11 @@ export abstract class PurePayload, C extends ItemCo globalDirtyIndexAtLastSync: this.globalDirtyIndexAtLastSync, lastSyncBegan: this.lastSyncBegan, lastSyncEnd: this.lastSyncEnd, + key_system_identifier: this.key_system_identifier, + user_uuid: this.user_uuid, + shared_vault_uuid: this.shared_vault_uuid, + last_edited_by_uuid: this.last_edited_by_uuid, + signatureData: this.signatureData, } return comprehensive diff --git a/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts b/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts index 42d65a0be..1819b11e6 100644 --- a/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts +++ b/packages/models/src/Domain/Abstract/Payload/Interfaces/PayloadInterface.ts @@ -3,6 +3,7 @@ import { ContentType } from '@standardnotes/common' import { ItemContent } from '../../Content/ItemContent' import { TransferPayload } from '../../TransferPayload/Interfaces/TransferPayload' import { PayloadSource } from '../Types/PayloadSource' +import { PersistentSignatureData } from '../../../Runtime/Encryption/PersistentSignatureData' export interface PayloadInterface { readonly source: PayloadSource @@ -22,12 +23,18 @@ export interface PayloadInterface { + if (!vault) { + return {} + } + + return { + key_system_identifier: vault.systemIdentifier, + shared_vault_uuid: vault.isSharedVaultListing() ? vault.sharing.sharedVaultUuid : undefined, + } +} diff --git a/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts b/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts index 4177073db..54bf455dd 100644 --- a/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts +++ b/packages/models/src/Domain/Abstract/Payload/Types/PayloadSource.ts @@ -5,6 +5,8 @@ export enum PayloadSource { */ Constructor = 1, + LocalDatabaseLoaded = 2, + RemoteRetrieved, RemoteSaved, diff --git a/packages/models/src/Domain/Abstract/Payload/index.ts b/packages/models/src/Domain/Abstract/Payload/index.ts index 50604221f..3baf73ad3 100644 --- a/packages/models/src/Domain/Abstract/Payload/index.ts +++ b/packages/models/src/Domain/Abstract/Payload/index.ts @@ -10,4 +10,5 @@ export * from './Interfaces/TypeCheck' export * from './Interfaces/UnionTypes' export * from './Types/PayloadSource' export * from './Types/EmitSource' -export * from './Types/TimestampDefaults' +export * from './Overrides/TimestampDefaults' +export * from './Overrides/VaultOverride' diff --git a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts index b89ee43e1..91ec7be33 100644 --- a/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts +++ b/packages/models/src/Domain/Abstract/TransferPayload/Interfaces/TransferPayload.ts @@ -1,5 +1,6 @@ import { ContentType } from '@standardnotes/common' import { ItemContent } from '../../Content/ItemContent' +import { PersistentSignatureData } from '../../../Runtime/Encryption/PersistentSignatureData' export interface TransferPayload { uuid: string @@ -15,9 +16,16 @@ export interface TransferPayload { dirtyIndex?: number globalDirtyIndexAtLastSync?: number dirty?: boolean + signatureData?: PersistentSignatureData lastSyncBegan?: Date lastSyncEnd?: Date duplicate_of?: string + user_uuid?: string + + key_system_identifier?: string | undefined + shared_vault_uuid?: string | undefined + + last_edited_by_uuid?: string } diff --git a/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyParamsInterface.ts b/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyParamsInterface.ts new file mode 100644 index 000000000..709100c8d --- /dev/null +++ b/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyParamsInterface.ts @@ -0,0 +1,16 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { KeySystemIdentifier } from '../../Syncable/KeySystemRootKey/KeySystemIdentifier' +import { KeySystemRootKeyPasswordType } from './KeySystemRootKeyPasswordType' + +/** + * Key params are public data that contain information about how a root key was created. + * Given a keyParams object and a password, clients can compute a root key that was created + * previously. + */ +export interface KeySystemRootKeyParamsInterface { + systemIdentifier: KeySystemIdentifier + seed: string + version: ProtocolVersion + passwordType: KeySystemRootKeyPasswordType + creationTimestamp: number +} diff --git a/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyPasswordType.ts b/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyPasswordType.ts new file mode 100644 index 000000000..8c5a117c7 --- /dev/null +++ b/packages/models/src/Domain/Local/KeyParams/KeySystemRootKeyPasswordType.ts @@ -0,0 +1,4 @@ +export enum KeySystemRootKeyPasswordType { + UserInputted = 'user_inputted', + Randomized = 'randomized', +} diff --git a/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts b/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts index 97cc09f40..b1be093a6 100644 --- a/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts +++ b/packages/models/src/Domain/Local/RootKey/KeychainTypes.ts @@ -1,5 +1,6 @@ import { ApplicationIdentifier, ProtocolVersion } from '@standardnotes/common' import { RootKeyContentSpecialized } from './RootKeyContent' +import { PkcKeyPair } from '@standardnotes/sncrypto-common' export type RawKeychainValue = Record @@ -7,6 +8,8 @@ export interface NamespacedRootKeyInKeychain { version: ProtocolVersion masterKey: string dataAuthenticationKey?: string + encryptionKeyPair: PkcKeyPair | undefined + signingKeyPair: PkcKeyPair | undefined } export type RootKeyContentInStorage = RootKeyContentSpecialized diff --git a/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts b/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts index f4f1c56c7..fbbf1301b 100644 --- a/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts +++ b/packages/models/src/Domain/Local/RootKey/RootKeyContent.ts @@ -1,3 +1,4 @@ +import { PkcKeyPair } from '@standardnotes/sncrypto-common' import { ItemContent } from '../../Abstract/Content/ItemContent' import { ProtocolVersion, AnyKeyParamsContent } from '@standardnotes/common' @@ -7,6 +8,9 @@ export interface RootKeyContentSpecialized { serverPassword?: string dataAuthenticationKey?: string keyParams: AnyKeyParamsContent + + encryptionKeyPair?: PkcKeyPair + signingKeyPair?: PkcKeyPair } export type RootKeyContent = RootKeyContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts b/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts index cae177525..58ab73b94 100644 --- a/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts +++ b/packages/models/src/Domain/Local/RootKey/RootKeyInterface.ts @@ -1,3 +1,4 @@ +import { PkcKeyPair } from '@standardnotes/sncrypto-common' import { ProtocolVersion } from '@standardnotes/common' import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' import { RootKeyParamsInterface } from '../KeyParams/RootKeyParamsInterface' @@ -6,11 +7,16 @@ import { RootKeyContent } from './RootKeyContent' export interface RootKeyInterface extends DecryptedItemInterface { readonly keyParams: RootKeyParamsInterface + get keyVersion(): ProtocolVersion get itemsKey(): string get masterKey(): string get serverPassword(): string | undefined get dataAuthenticationKey(): string | undefined + + get encryptionKeyPair(): PkcKeyPair | undefined + get signingKeyPair(): PkcKeyPair | undefined + compare(otherKey: RootKeyInterface): boolean persistableValueWhenWrapping(): RootKeyContentInStorage getKeychainValue(): NamespacedRootKeyInKeychain diff --git a/packages/models/src/Domain/Local/RootKey/RootKeyWithKeyPairsInterface.ts b/packages/models/src/Domain/Local/RootKey/RootKeyWithKeyPairsInterface.ts new file mode 100644 index 000000000..32392b108 --- /dev/null +++ b/packages/models/src/Domain/Local/RootKey/RootKeyWithKeyPairsInterface.ts @@ -0,0 +1,7 @@ +import { PkcKeyPair } from '@standardnotes/sncrypto-common' +import { RootKeyInterface } from './RootKeyInterface' + +export interface RootKeyWithKeyPairsInterface extends RootKeyInterface { + get encryptionKeyPair(): PkcKeyPair + get signingKeyPair(): PkcKeyPair +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessageDataCommon.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessageDataCommon.ts new file mode 100644 index 000000000..1ce6c17b6 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessageDataCommon.ts @@ -0,0 +1,3 @@ +export type AsymmetricMessageDataCommon = { + recipientUuid: string +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayload.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayload.ts new file mode 100644 index 000000000..6cecf24f3 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayload.ts @@ -0,0 +1,12 @@ +import { AsymmetricMessageSenderKeypairChanged } from './MessageTypes/AsymmetricMessageSenderKeypairChanged' +import { AsymmetricMessageSharedVaultInvite } from './MessageTypes/AsymmetricMessageSharedVaultInvite' +import { AsymmetricMessageSharedVaultMetadataChanged } from './MessageTypes/AsymmetricMessageSharedVaultMetadataChanged' +import { AsymmetricMessageSharedVaultRootKeyChanged } from './MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged' +import { AsymmetricMessageTrustedContactShare } from './MessageTypes/AsymmetricMessageTrustedContactShare' + +export type AsymmetricMessagePayload = + | AsymmetricMessageSharedVaultRootKeyChanged + | AsymmetricMessageTrustedContactShare + | AsymmetricMessageSenderKeypairChanged + | AsymmetricMessageSharedVaultInvite + | AsymmetricMessageSharedVaultMetadataChanged diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts new file mode 100644 index 000000000..c4dab81c1 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/AsymmetricMessagePayloadType.ts @@ -0,0 +1,7 @@ +export enum AsymmetricMessagePayloadType { + ContactShare = 'contact-share', + SharedVaultRootKeyChanged = 'shared-vault-root-key-changed', + SenderKeypairChanged = 'sender-keypair-changed', + SharedVaultInvite = 'shared-vault-invite', + SharedVaultMetadataChanged = 'shared-vault-metadata-changed', +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSenderKeypairChanged.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSenderKeypairChanged.ts new file mode 100644 index 000000000..254e4fb2a --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSenderKeypairChanged.ts @@ -0,0 +1,10 @@ +import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon' +import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType' + +export type AsymmetricMessageSenderKeypairChanged = { + type: AsymmetricMessagePayloadType.SenderKeypairChanged + data: AsymmetricMessageDataCommon & { + newEncryptionPublicKey: string + newSigningPublicKey: string + } +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultInvite.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultInvite.ts new file mode 100644 index 000000000..0d798fddf --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultInvite.ts @@ -0,0 +1,16 @@ +import { KeySystemRootKeyContentSpecialized } from '../../../Syncable/KeySystemRootKey/KeySystemRootKeyContent' +import { TrustedContactContentSpecialized } from '../../../Syncable/TrustedContact/TrustedContactContent' +import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon' +import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType' + +export type AsymmetricMessageSharedVaultInvite = { + type: AsymmetricMessagePayloadType.SharedVaultInvite + data: AsymmetricMessageDataCommon & { + rootKey: KeySystemRootKeyContentSpecialized + trustedContacts: TrustedContactContentSpecialized[] + metadata: { + name: string + description?: string + } + } +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultMetadataChanged.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultMetadataChanged.ts new file mode 100644 index 000000000..6b3e308a5 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultMetadataChanged.ts @@ -0,0 +1,11 @@ +import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon' +import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType' + +export type AsymmetricMessageSharedVaultMetadataChanged = { + type: AsymmetricMessagePayloadType.SharedVaultMetadataChanged + data: AsymmetricMessageDataCommon & { + sharedVaultUuid: string + name: string + description?: string + } +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged.ts new file mode 100644 index 000000000..c65c6a578 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged.ts @@ -0,0 +1,8 @@ +import { KeySystemRootKeyContentSpecialized } from '../../../Syncable/KeySystemRootKey/KeySystemRootKeyContent' +import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon' +import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType' + +export type AsymmetricMessageSharedVaultRootKeyChanged = { + type: AsymmetricMessagePayloadType.SharedVaultRootKeyChanged + data: AsymmetricMessageDataCommon & { rootKey: KeySystemRootKeyContentSpecialized } +} diff --git a/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageTrustedContactShare.ts b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageTrustedContactShare.ts new file mode 100644 index 000000000..9196df598 --- /dev/null +++ b/packages/models/src/Domain/Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageTrustedContactShare.ts @@ -0,0 +1,8 @@ +import { TrustedContactContentSpecialized } from '../../../Syncable/TrustedContact/TrustedContactContent' +import { AsymmetricMessageDataCommon } from '../AsymmetricMessageDataCommon' +import { AsymmetricMessagePayloadType } from '../AsymmetricMessagePayloadType' + +export type AsymmetricMessageTrustedContactShare = { + type: AsymmetricMessagePayloadType.ContactShare + data: AsymmetricMessageDataCommon & { trustedContact: TrustedContactContentSpecialized } +} diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts b/packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.spec.ts similarity index 94% rename from packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts rename to packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.spec.ts index 0ffbfb733..ba9dd2ec4 100644 --- a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts +++ b/packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.spec.ts @@ -1,10 +1,10 @@ +import { ItemCounter } from './ItemCounter' import { NoteContent } from '../../../Syncable/Note/NoteContent' import { ContentType } from '@standardnotes/common' import { DecryptedItem, EncryptedItem } from '../../../Abstract/Item' import { DecryptedPayload, EncryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload' import { ItemCollection } from './ItemCollection' import { FillItemContent } from '../../../Abstract/Content/ItemContent' -import { TagItemsIndex } from './TagItemsIndex' import { ItemDelta } from '../../Index/ItemDelta' import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes' @@ -48,7 +48,7 @@ describe('tag notes index', () => { it('should count both notes and files', () => { const collection = new ItemCollection() - const index = new TagItemsIndex(collection) + const index = new ItemCounter(collection) const decryptedNote = createDecryptedItem('note') const decryptedFile = createDecryptedItem('file') @@ -61,7 +61,7 @@ describe('tag notes index', () => { it('should decrement count after decrypted note becomes errored', () => { const collection = new ItemCollection() - const index = new TagItemsIndex(collection) + const index = new ItemCounter(collection) const decryptedItem = createDecryptedItem() collection.set(decryptedItem) diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts b/packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.ts similarity index 61% rename from packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts rename to packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.ts index 0dac76acf..869d05e45 100644 --- a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts +++ b/packages/models/src/Domain/Runtime/Collection/Item/ItemCounter.ts @@ -4,25 +4,28 @@ import { isTag, SNTag } from '../../../Syncable/Tag/Tag' import { SNIndex } from '../../Index/SNIndex' import { ItemCollection } from './ItemCollection' import { ItemDelta } from '../../Index/ItemDelta' -import { isDecryptedItem, ItemInterface } from '../../../Abstract/Item' +import { DecryptedItemInterface, isDecryptedItem, ItemInterface } from '../../../Abstract/Item' +import { CriteriaValidatorInterface } from '../../Display/Validator/CriteriaValidatorInterface' +import { CollectionCriteriaValidator } from '../../Display/Validator/CollectionCriteriaValidator' +import { ExcludeVaultsCriteriaValidator } from '../../Display/Validator/ExcludeVaultsCriteriaValidator' +import { ExclusiveVaultCriteriaValidator } from '../../Display/Validator/ExclusiveVaultCriteriaValidator' +import { HiddenContentCriteriaValidator } from '../../Display/Validator/HiddenContentCriteriaValidator' +import { CustomFilterCriteriaValidator } from '../../Display/Validator/CustomFilterCriteriaValidator' +import { AnyDisplayOptions, VaultDisplayOptions } from '../../Display' +import { isExclusioanaryOptionsValue } from '../../Display/VaultDisplayOptionsTypes' type AllNotesUuidSignifier = undefined export type TagItemCountChangeObserver = (tagUuid: string | AllNotesUuidSignifier) => void -export class TagItemsIndex implements SNIndex { +export class ItemCounter implements SNIndex { private tagToItemsMap: Partial>> = {} private allCountableItems = new Set() private countableItemsByType = new Map>() + private displayOptions?: AnyDisplayOptions + private vaultDisplayOptions?: VaultDisplayOptions constructor(private collection: ItemCollection, public observers: TagItemCountChangeObserver[] = []) {} - private isItemCountable = (item: ItemInterface) => { - if (isDecryptedItem(item)) { - return !item.archived && !item.trashed && !item.conflictOf - } - return false - } - public addCountChangeObserver(observer: TagItemCountChangeObserver): () => void { this.observers.push(observer) @@ -32,10 +35,14 @@ export class TagItemsIndex implements SNIndex { } } - private notifyObservers(tagUuid: string | undefined) { - for (const observer of this.observers) { - observer(tagUuid) - } + public setDisplayOptions(options: AnyDisplayOptions) { + this.displayOptions = options + this.receiveItemChanges(this.collection.all()) + } + + public setVaultDisplayOptions(options: VaultDisplayOptions) { + this.vaultDisplayOptions = options + this.receiveItemChanges(this.collection.all()) } public allCountableItemsCount(): number { @@ -64,6 +71,47 @@ export class TagItemsIndex implements SNIndex { this.receiveTagChanges(tags) } + private passesAllFilters(element: DecryptedItemInterface): boolean { + if (!this.displayOptions) { + return true + } + + const filters: CriteriaValidatorInterface[] = [new CollectionCriteriaValidator(this.collection, element)] + + if (this.vaultDisplayOptions) { + const options = this.vaultDisplayOptions.getOptions() + if (isExclusioanaryOptionsValue(options)) { + filters.push(new ExcludeVaultsCriteriaValidator([...options.exclude, ...options.locked], element)) + } else { + filters.push(new ExclusiveVaultCriteriaValidator(options.exclusive, element)) + } + } + + if ('hiddenContentTypes' in this.displayOptions && this.displayOptions.hiddenContentTypes) { + filters.push(new HiddenContentCriteriaValidator(this.displayOptions.hiddenContentTypes, element)) + } + + if ('customFilter' in this.displayOptions && this.displayOptions.customFilter) { + filters.push(new CustomFilterCriteriaValidator(this.displayOptions.customFilter, element)) + } + + return filters.every((f) => f.passes()) + } + + private isItemCountable = (item: ItemInterface) => { + if (isDecryptedItem(item)) { + const passesFilters = this.passesAllFilters(item) + return passesFilters && !item.archived && !item.trashed && !item.conflictOf + } + return false + } + + private notifyObservers(tagUuid: string | undefined) { + for (const observer of this.observers) { + observer(tagUuid) + } + } + private receiveTagChanges(tags: SNTag[]): void { for (const tag of tags) { const uuids = tag.references diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts index e78d27b6d..cf1ce2e21 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteDataConflicts.ts @@ -1,15 +1,16 @@ import { ConflictDelta } from './Conflict' -import { PayloadEmitSource } from '../../Abstract/Payload' +import { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload' import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' import { HistoryMap } from '../History' import { extendSyncDelta, SyncDeltaEmit } from './Abstract/DeltaEmit' import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' import { payloadByFinalizingSyncState } from './Utilities/ApplyDirtyState' +import { ConflictConflictingDataParams } from '@standardnotes/responses' export class DeltaRemoteDataConflicts implements SyncDeltaInterface { constructor( readonly baseCollection: ImmutablePayloadCollection, - readonly applyCollection: ImmutablePayloadCollection, + readonly conflicts: ConflictConflictingDataParams[], readonly historyMap: HistoryMap, ) {} @@ -20,18 +21,18 @@ export class DeltaRemoteDataConflicts implements SyncDeltaInterface { source: PayloadEmitSource.RemoteRetrieved, } - for (const apply of this.applyCollection.all()) { - const base = this.baseCollection.find(apply.uuid) + for (const conflict of this.conflicts) { + const base = this.baseCollection.find(conflict.server_item.uuid) const isBaseDeleted = base == undefined if (isBaseDeleted) { - result.emits.push(payloadByFinalizingSyncState(apply, this.baseCollection)) + result.emits.push(payloadByFinalizingSyncState(conflict.server_item, this.baseCollection)) continue } - const delta = new ConflictDelta(this.baseCollection, base, apply, this.historyMap) + const delta = new ConflictDelta(this.baseCollection, base, conflict.server_item, this.historyMap) extendSyncDelta(result, delta.result()) } diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts index d695d90de..17304f5d9 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.spec.ts @@ -1,10 +1,14 @@ import { ContentType } from '@standardnotes/common' import { FillItemContent } from '../../Abstract/Content/ItemContent' -import { DecryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload' +import { DecryptedPayload, FullyFormedPayloadInterface, PayloadTimestampDefaults } from '../../Abstract/Payload' import { NoteContent } from '../../Syncable/Note' import { PayloadCollection } from '../Collection/Payload/PayloadCollection' import { DeltaRemoteRejected } from './RemoteRejected' import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' +import { ConflictParams, ConflictType } from '@standardnotes/responses' +import { UuidGenerator } from '@standardnotes/utils' + +UuidGenerator.SetGenerator(() => String(Math.random())) describe('remote rejected delta', () => { it('rejected payloads should not map onto app state', async () => { @@ -30,10 +34,12 @@ describe('remote rejected delta', () => { dirty: true, }) - const delta = new DeltaRemoteRejected( - ImmutablePayloadCollection.FromCollection(baseCollection), - ImmutablePayloadCollection.WithPayloads([rejectedPayload]), - ) + const entry: ConflictParams = { + type: ConflictType.ContentTypeError, + unsaved_item: rejectedPayload, + } as unknown as ConflictParams + + const delta = new DeltaRemoteRejected(ImmutablePayloadCollection.FromCollection(baseCollection), [entry]) const result = delta.result() const payload = result.emits[0] as DecryptedPayload diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts index f52f8dc86..aac65c260 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRejected.ts @@ -1,35 +1,48 @@ import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource' -import { PayloadEmitSource } from '../../Abstract/Payload' +import { DeletedPayload, FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload' import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' import { SyncDeltaEmit } from './Abstract/DeltaEmit' import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' -import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload' +import { BuildSyncResolvedParams, SyncResolvedPayload } from './Utilities/SyncResolvedPayload' +import { + ConflictParams, + ConflictParamsWithServerItem, + ConflictParamsWithUnsavedItem, + ConflictParamsWithServerAndUnsavedItem, + conflictParamsHasServerItemAndUnsavedItem, + conflictParamsHasOnlyServerItem, + conflictParamsHasOnlyUnsavedItem, + ConflictType, +} from '@standardnotes/responses' +import { PayloadsByDuplicating } from '../../Utilities/Payload/PayloadsByDuplicating' +import { ContentType } from '@standardnotes/common' export class DeltaRemoteRejected implements SyncDeltaInterface { constructor( readonly baseCollection: ImmutablePayloadCollection, - readonly applyCollection: ImmutablePayloadCollection, + readonly conflicts: ConflictParams[], ) {} public result(): SyncDeltaEmit { const results: SyncResolvedPayload[] = [] - for (const apply of this.applyCollection.all()) { - const base = this.baseCollection.find(apply.uuid) + const vaultErrors: ConflictType[] = [ + ConflictType.SharedVaultInsufficientPermissionsError, + ConflictType.SharedVaultNotMemberError, + ConflictType.SharedVaultInvalidState, + ConflictType.SharedVaultSnjsVersionError, + ] - if (!base) { - continue + for (const conflict of this.conflicts) { + if (vaultErrors.includes(conflict.type)) { + results.push(...this.handleVaultError(conflict)) + } else if (conflictParamsHasServerItemAndUnsavedItem(conflict)) { + results.push(...this.getResultForConflictWithServerItemAndUnsavedItem(conflict)) + } else if (conflictParamsHasOnlyServerItem(conflict)) { + results.push(...this.getResultForConflictWithOnlyServerItem(conflict)) + } else if (conflictParamsHasOnlyUnsavedItem(conflict)) { + results.push(...this.getResultForConflictWithOnlyUnsavedItem(conflict)) } - - const result = base.copyAsSyncResolved( - { - dirty: false, - lastSyncEnd: new Date(), - }, - PayloadSource.RemoteSaved, - ) - - results.push(result) } return { @@ -37,4 +50,177 @@ export class DeltaRemoteRejected implements SyncDeltaInterface { source: PayloadEmitSource.RemoteSaved, } } + + private handleVaultError(conflict: ConflictParams): SyncResolvedPayload[] { + const base = this.baseCollection.find(conflict.unsaved_item.uuid) + if (!base) { + return [] + } + + if (conflict.type === ConflictType.SharedVaultNotMemberError) { + return this.resultByDuplicatingBasePayloadAsNonVaultedAndRemovingBaseItemLocally(base) + } + + if (base.content_type === ContentType.KeySystemItemsKey) { + return this.discardChangesOfBasePayload(base) + } + + if (conflict.server_item) { + return this.resultByDuplicatingBasePayloadAsNonVaultedAndTakingServerPayloadAsCanonical( + base, + conflict.server_item, + ) + } else { + return this.resultByDuplicatingBasePayloadAsNonVaultedAndDiscardingChangesOfOriginal(base) + } + } + + private discardChangesOfBasePayload(base: FullyFormedPayloadInterface): SyncResolvedPayload[] { + const undirtiedPayload = base.copyAsSyncResolved( + { + dirty: false, + lastSyncEnd: new Date(), + }, + PayloadSource.RemoteSaved, + ) + + return [undirtiedPayload] + } + + private getResultForConflictWithOnlyUnsavedItem( + conflict: ConflictParamsWithUnsavedItem, + ): SyncResolvedPayload[] { + const base = this.baseCollection.find(conflict.unsaved_item.uuid) + if (!base) { + return [] + } + + const result = base.copyAsSyncResolved( + { + dirty: false, + lastSyncEnd: new Date(), + }, + PayloadSource.RemoteSaved, + ) + + return [result] + } + + private getResultForConflictWithOnlyServerItem( + conflict: ConflictParamsWithServerItem, + ): SyncResolvedPayload[] { + const base = this.baseCollection.find(conflict.server_item.uuid) + if (!base) { + return [] + } + + return this.resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical(base, conflict.server_item) + } + + private getResultForConflictWithServerItemAndUnsavedItem( + conflict: ConflictParamsWithServerAndUnsavedItem, + ): SyncResolvedPayload[] { + const base = this.baseCollection.find(conflict.server_item.uuid) + if (!base) { + return [] + } + + return this.resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical(base, conflict.server_item) + } + + private resultByDuplicatingBasePayloadIntoNewUuidAndTakingServerPayloadAsCanonical( + basePayload: FullyFormedPayloadInterface, + serverPayload: FullyFormedPayloadInterface, + ): SyncResolvedPayload[] { + const duplicateBasePayloadIntoNewUuid = PayloadsByDuplicating({ + payload: basePayload, + baseCollection: this.baseCollection, + isConflict: true, + source: serverPayload.source, + }) + + const takeServerPayloadAsCanonical = serverPayload.copyAsSyncResolved( + { + lastSyncBegan: basePayload.lastSyncBegan, + dirty: false, + lastSyncEnd: new Date(), + }, + serverPayload.source, + ) + + return duplicateBasePayloadIntoNewUuid.concat([takeServerPayloadAsCanonical]) + } + + private resultByDuplicatingBasePayloadAsNonVaultedAndTakingServerPayloadAsCanonical( + basePayload: FullyFormedPayloadInterface, + serverPayload: FullyFormedPayloadInterface, + ): SyncResolvedPayload[] { + const duplicateBasePayloadIntoNewUuid = PayloadsByDuplicating({ + payload: basePayload.copy({ + key_system_identifier: undefined, + shared_vault_uuid: undefined, + }), + baseCollection: this.baseCollection, + isConflict: true, + source: serverPayload.source, + }) + + const takeServerPayloadAsCanonical = serverPayload.copyAsSyncResolved( + { + lastSyncBegan: basePayload.lastSyncBegan, + dirty: false, + lastSyncEnd: new Date(), + }, + serverPayload.source, + ) + + return duplicateBasePayloadIntoNewUuid.concat([takeServerPayloadAsCanonical]) + } + + private resultByDuplicatingBasePayloadAsNonVaultedAndDiscardingChangesOfOriginal( + basePayload: FullyFormedPayloadInterface, + ): SyncResolvedPayload[] { + const duplicateBasePayloadWithoutVault = PayloadsByDuplicating({ + payload: basePayload.copy({ + key_system_identifier: undefined, + shared_vault_uuid: undefined, + }), + baseCollection: this.baseCollection, + isConflict: true, + source: basePayload.source, + }) + + return [...duplicateBasePayloadWithoutVault, ...this.discardChangesOfBasePayload(basePayload)] + } + + private resultByDuplicatingBasePayloadAsNonVaultedAndRemovingBaseItemLocally( + basePayload: FullyFormedPayloadInterface, + ): SyncResolvedPayload[] { + const duplicateBasePayloadWithoutVault = PayloadsByDuplicating({ + payload: basePayload.copy({ + key_system_identifier: undefined, + shared_vault_uuid: undefined, + }), + baseCollection: this.baseCollection, + isConflict: true, + source: basePayload.source, + }) + + const locallyDeletedBasePayload = new DeletedPayload( + { + ...basePayload, + content: undefined, + deleted: true, + key_system_identifier: undefined, + shared_vault_uuid: undefined, + ...BuildSyncResolvedParams({ + dirty: false, + lastSyncEnd: new Date(), + }), + }, + PayloadSource.RemoteSaved, + ) + + return [...duplicateBasePayloadWithoutVault, locallyDeletedBasePayload as SyncResolvedPayload] + } } diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts index 273b15cfc..d441cba8b 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteRetrieved.ts @@ -36,7 +36,7 @@ export class DeltaRemoteRetrieved implements SyncDeltaInterface { * or if the item is locally dirty, filter it out of retrieved_items, and add to potential conflicts. */ for (const apply of this.applyCollection.all()) { - if (apply.content_type === ContentType.ItemsKey) { + if (apply.content_type === ContentType.ItemsKey || apply.content_type === ContentType.KeySystemItemsKey) { const itemsKeyDeltaEmit = new ItemsKeyDelta(this.baseCollection, [apply]).result() extendSyncDelta(result, itemsKeyDeltaEmit) diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts index f3d74a945..af8af892d 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteSaved.ts @@ -1,4 +1,4 @@ -import { ServerSyncSavedContextualPayload } from './../../Abstract/Contextual/ServerSyncSaved' +import { ServerSyncSavedContextualPayload } from '../../Abstract/Contextual/ServerSyncSaved' import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload' import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' import { PayloadSource } from '../../Abstract/Payload/Types/PayloadSource' diff --git a/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts b/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts index a7d484b2e..5706b1761 100644 --- a/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts +++ b/packages/models/src/Domain/Runtime/Deltas/RemoteUuidConflicts.ts @@ -2,10 +2,11 @@ import { extendArray, filterFromArray, Uuids } from '@standardnotes/utils' import { ImmutablePayloadCollection } from '../Collection/Payload/ImmutablePayloadCollection' import { PayloadsByAlternatingUuid } from '../../Utilities/Payload/PayloadsByAlternatingUuid' import { isDecryptedPayload } from '../../Abstract/Payload/Interfaces/TypeCheck' -import { PayloadEmitSource } from '../../Abstract/Payload' +import { FullyFormedPayloadInterface, PayloadEmitSource } from '../../Abstract/Payload' import { SyncDeltaEmit } from './Abstract/DeltaEmit' import { SyncDeltaInterface } from './Abstract/SyncDeltaInterface' import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload' +import { ConflictUuidConflictParams } from '@standardnotes/responses' /** * UUID conflicts can occur if a user attmpts to import an old data @@ -15,22 +16,22 @@ import { SyncResolvedPayload } from './Utilities/SyncResolvedPayload' export class DeltaRemoteUuidConflicts implements SyncDeltaInterface { constructor( readonly baseCollection: ImmutablePayloadCollection, - readonly applyCollection: ImmutablePayloadCollection, + readonly conflicts: ConflictUuidConflictParams[], ) {} public result(): SyncDeltaEmit { const results: SyncResolvedPayload[] = [] const baseCollectionCopy = this.baseCollection.mutableCopy() - for (const apply of this.applyCollection.all()) { + for (const conflict of this.conflicts) { /** * The payload in question may have been modified as part of alternating a uuid for * another item. For example, alternating a uuid for a note will also affect the * referencing tag, which would be added to `results`, but could also be inside * of this.applyCollection. In this case we'd prefer the most recently modified value. */ - const moreRecent = results.find((r) => r.uuid === apply.uuid) - const useApply = moreRecent || apply + const moreRecent = results.find((r) => r.uuid === conflict.unsaved_item.uuid) + const useApply = moreRecent || conflict.unsaved_item if (!isDecryptedPayload(useApply)) { continue diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts index 0ed818617..2b6d79e11 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts @@ -1,8 +1,8 @@ import { createNoteWithContent } from '../../Utilities/Test/SpecUtils' import { ItemCollection } from '../Collection/Item/ItemCollection' import { SNNote } from '../../Syncable/Note/Note' -import { itemsMatchingOptions } from './Search/SearchUtilities' -import { FilterDisplayOptions } from './DisplayOptions' +import { notesAndFilesMatchingOptions } from './Search/SearchUtilities' +import { NotesAndFilesDisplayOptions } from './DisplayOptions' describe('item display options', () => { const collectionWithNotes = function (titles: (string | undefined)[] = [], bodies: string[] = []) { @@ -23,31 +23,31 @@ describe('item display options', () => { it('string query title', () => { const query = 'foo' - const options: FilterDisplayOptions = { + const options: NotesAndFilesDisplayOptions = { searchQuery: { query: query, includeProtectedNoteText: true }, - } + } as jest.Mocked const collection = collectionWithNotes(['hello', 'fobar', 'foobar', 'foo']) - expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) it('string query text', async function () { const query = 'foo' - const options: FilterDisplayOptions = { + const options: NotesAndFilesDisplayOptions = { searchQuery: { query: query, includeProtectedNoteText: true }, - } + } as jest.Mocked const collection = collectionWithNotes( [undefined, undefined, undefined, undefined], ['hello', 'fobar', 'foobar', 'foo'], ) - expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) it('string query title and text', async function () { const query = 'foo' - const options: FilterDisplayOptions = { + const options: NotesAndFilesDisplayOptions = { searchQuery: { query: query, includeProtectedNoteText: true }, - } + } as jest.Mocked const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar']) - expect(itemsMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) + expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2) }) }) diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts index 64a88771f..25a215398 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts @@ -5,21 +5,28 @@ import { CollectionSortDirection, CollectionSortProperty } from '../Collection/C import { SearchQuery } from './Search/Types' import { DisplayControllerCustomFilter } from './Types' -export type DisplayOptions = FilterDisplayOptions & DisplayControllerOptions - -export interface FilterDisplayOptions { - tags?: SNTag[] - views?: SmartView[] - searchQuery?: SearchQuery +export interface GenericDisplayOptions { includePinned?: boolean includeProtected?: boolean includeTrashed?: boolean includeArchived?: boolean } -export interface DisplayControllerOptions { - sortBy: CollectionSortProperty - sortDirection: CollectionSortDirection +export interface NotesAndFilesDisplayOptions extends GenericDisplayOptions { + tags?: SNTag[] + views?: SmartView[] + searchQuery?: SearchQuery hiddenContentTypes?: ContentType[] customFilter?: DisplayControllerCustomFilter } + +export type TagsDisplayOptions = GenericDisplayOptions + +export interface DisplayControllerDisplayOptions extends GenericDisplayOptions { + sortBy: CollectionSortProperty + sortDirection: CollectionSortDirection +} + +export type NotesAndFilesDisplayControllerOptions = NotesAndFilesDisplayOptions & DisplayControllerDisplayOptions +export type TagsDisplayControllerOptions = TagsDisplayOptions & DisplayControllerDisplayOptions +export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsDisplayOptions | GenericDisplayOptions diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts index 90a029d45..4c51e32dc 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts @@ -5,11 +5,11 @@ import { CompoundPredicate } from '../Predicate/CompoundPredicate' import { ItemWithTags } from './Search/ItemWithTags' import { itemMatchesQuery, itemPassesFilters } from './Search/SearchUtilities' import { ItemFilter, ReferenceLookupCollection, SearchableDecryptedItem } from './Search/Types' -import { FilterDisplayOptions } from './DisplayOptions' +import { NotesAndFilesDisplayOptions } from './DisplayOptions' import { SystemViewId } from '../../Syncable/SmartView' export function computeUnifiedFilterForDisplayOptions( - options: FilterDisplayOptions, + options: NotesAndFilesDisplayOptions, collection: ReferenceLookupCollection, additionalFilters: ItemFilter[] = [], ): ItemFilter { @@ -21,7 +21,7 @@ export function computeUnifiedFilterForDisplayOptions( } export function computeFiltersForDisplayOptions( - options: FilterDisplayOptions, + options: NotesAndFilesDisplayOptions, collection: ReferenceLookupCollection, ): ItemFilter[] { const filters: ItemFilter[] = [] diff --git a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts index 60c35fe13..f438860e4 100644 --- a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts +++ b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.ts @@ -2,11 +2,19 @@ import { ContentType } from '@standardnotes/common' import { compareValues } from '@standardnotes/utils' import { isDeletedItem, isEncryptedItem } from '../../Abstract/Item' import { ItemDelta } from '../Index/ItemDelta' -import { DisplayControllerOptions } from './DisplayOptions' +import { AnyDisplayOptions, DisplayControllerDisplayOptions, GenericDisplayOptions } from './DisplayOptions' import { sortTwoItems } from './SortTwoItems' import { UuidToSortedPositionMap, DisplayItem, ReadonlyItemCollection } from './Types' +import { CriteriaValidatorInterface } from './Validator/CriteriaValidatorInterface' +import { CollectionCriteriaValidator } from './Validator/CollectionCriteriaValidator' +import { CustomFilterCriteriaValidator } from './Validator/CustomFilterCriteriaValidator' +import { ExcludeVaultsCriteriaValidator } from './Validator/ExcludeVaultsCriteriaValidator' +import { ExclusiveVaultCriteriaValidator } from './Validator/ExclusiveVaultCriteriaValidator' +import { HiddenContentCriteriaValidator } from './Validator/HiddenContentCriteriaValidator' +import { VaultDisplayOptions } from './VaultDisplayOptions' +import { isExclusioanaryOptionsValue } from './VaultDisplayOptionsTypes' -export class ItemDisplayController { +export class ItemDisplayController { private sortMap: UuidToSortedPositionMap = {} private sortedItems: I[] = [] private needsSort = true @@ -14,7 +22,8 @@ export class ItemDisplayController { constructor( private readonly collection: ReadonlyItemCollection, public readonly contentTypes: ContentType[], - private options: DisplayControllerOptions, + private options: DisplayControllerDisplayOptions & O, + private vaultOptions?: VaultDisplayOptions, ) { this.filterThenSortElements(this.collection.all(this.contentTypes) as I[]) } @@ -23,7 +32,18 @@ export class ItemDisplayController { return this.sortedItems } - setDisplayOptions(displayOptions: Partial): void { + public getDisplayOptions(): DisplayControllerDisplayOptions & O { + return this.options + } + + setVaultDisplayOptions(vaultOptions?: VaultDisplayOptions): void { + this.vaultOptions = vaultOptions + this.needsSort = true + + this.filterThenSortElements(this.collection.all(this.contentTypes) as I[]) + } + + setDisplayOptions(displayOptions: Partial): void { this.options = { ...this.options, ...displayOptions } this.needsSort = true @@ -37,6 +57,29 @@ export class ItemDisplayController { this.filterThenSortElements(items as I[]) } + private passesAllFilters(element: I): boolean { + const filters: CriteriaValidatorInterface[] = [new CollectionCriteriaValidator(this.collection, element)] + + if (this.vaultOptions) { + const options = this.vaultOptions.getOptions() + if (isExclusioanaryOptionsValue(options)) { + filters.push(new ExcludeVaultsCriteriaValidator([...options.exclude, ...options.locked], element)) + } else { + filters.push(new ExclusiveVaultCriteriaValidator(options.exclusive, element)) + } + } + + if ('hiddenContentTypes' in this.options && this.options.hiddenContentTypes) { + filters.push(new HiddenContentCriteriaValidator(this.options.hiddenContentTypes, element)) + } + + if ('customFilter' in this.options && this.options.customFilter) { + filters.push(new CustomFilterCriteriaValidator(this.options.customFilter, element)) + } + + return filters.every((f) => f.passes()) + } + private filterThenSortElements(elements: I[]): void { for (const element of elements) { const previousIndex = this.sortMap[element.uuid] @@ -61,13 +104,7 @@ export class ItemDisplayController { continue } - const passes = !this.collection.has(element.uuid) - ? false - : this.options.hiddenContentTypes?.includes(element.content_type) - ? false - : this.options.customFilter - ? this.options.customFilter(element) - : true + const passes = this.passesAllFilters(element) if (passes) { if (previousElement != undefined) { diff --git a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts index b8b2db815..7f97237a1 100644 --- a/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts +++ b/packages/models/src/Domain/Runtime/Display/Search/SearchUtilities.ts @@ -1,6 +1,6 @@ import { ContentType } from '@standardnotes/common' import { SNTag } from '../../../Syncable/Tag' -import { FilterDisplayOptions } from '../DisplayOptions' +import { NotesAndFilesDisplayOptions } from '../DisplayOptions' import { computeFiltersForDisplayOptions } from '../DisplayOptionsToFilters' import { SearchableItem } from './SearchableItem' import { ReferenceLookupCollection, ItemFilter, SearchQuery, SearchableDecryptedItem } from './Types' @@ -13,8 +13,8 @@ enum MatchResult { Uuid = 5, } -export function itemsMatchingOptions( - options: FilterDisplayOptions, +export function notesAndFilesMatchingOptions( + options: NotesAndFilesDisplayOptions, fromItems: SearchableDecryptedItem[], collection: ReferenceLookupCollection, ): SearchableItem[] { diff --git a/packages/models/src/Domain/Runtime/Display/Validator/CollectionCriteriaValidator.ts b/packages/models/src/Domain/Runtime/Display/Validator/CollectionCriteriaValidator.ts new file mode 100644 index 000000000..d82a56df7 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/CollectionCriteriaValidator.ts @@ -0,0 +1,11 @@ +import { ItemInterface } from '../../../Abstract/Item' +import { ReadonlyItemCollection } from '../Types' +import { CriteriaValidatorInterface } from './CriteriaValidatorInterface' + +export class CollectionCriteriaValidator implements CriteriaValidatorInterface { + constructor(private collection: ReadonlyItemCollection, private element: ItemInterface) {} + + public passes(): boolean { + return this.collection.has(this.element.uuid) + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Validator/CriteriaValidatorInterface.ts b/packages/models/src/Domain/Runtime/Display/Validator/CriteriaValidatorInterface.ts new file mode 100644 index 000000000..4b8ecc808 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/CriteriaValidatorInterface.ts @@ -0,0 +1,3 @@ +export interface CriteriaValidatorInterface { + passes(): boolean +} diff --git a/packages/models/src/Domain/Runtime/Display/Validator/CustomFilterCriteriaValidator.ts b/packages/models/src/Domain/Runtime/Display/Validator/CustomFilterCriteriaValidator.ts new file mode 100644 index 000000000..16050c77a --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/CustomFilterCriteriaValidator.ts @@ -0,0 +1,11 @@ +import { DecryptedItemInterface } from '../../../Abstract/Item' +import { DisplayControllerCustomFilter } from '../Types' +import { CriteriaValidatorInterface } from './CriteriaValidatorInterface' + +export class CustomFilterCriteriaValidator implements CriteriaValidatorInterface { + constructor(private customFilter: DisplayControllerCustomFilter, private element: DecryptedItemInterface) {} + + public passes(): boolean { + return this.customFilter(this.element) + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Validator/ExcludeVaultsCriteriaValidator.ts b/packages/models/src/Domain/Runtime/Display/Validator/ExcludeVaultsCriteriaValidator.ts new file mode 100644 index 000000000..ea781161a --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/ExcludeVaultsCriteriaValidator.ts @@ -0,0 +1,15 @@ +import { CriteriaValidatorInterface } from './CriteriaValidatorInterface' +import { DecryptedItemInterface } from '../../../Abstract/Item' +import { KeySystemIdentifier } from '../../../Syncable/KeySystemRootKey/KeySystemIdentifier' + +export class ExcludeVaultsCriteriaValidator implements CriteriaValidatorInterface { + constructor(private excludeVaults: KeySystemIdentifier[], private element: DecryptedItemInterface) {} + + public passes(): boolean { + const doesElementBelongToExcludedVault = this.excludeVaults.some( + (vault) => this.element.key_system_identifier === vault, + ) + + return !doesElementBelongToExcludedVault + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Validator/ExclusiveVaultCriteriaValidator.ts b/packages/models/src/Domain/Runtime/Display/Validator/ExclusiveVaultCriteriaValidator.ts new file mode 100644 index 000000000..7bc65241f --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/ExclusiveVaultCriteriaValidator.ts @@ -0,0 +1,11 @@ +import { CriteriaValidatorInterface } from './CriteriaValidatorInterface' +import { DecryptedItemInterface } from '../../../Abstract/Item' +import { KeySystemIdentifier } from '../../../Syncable/KeySystemRootKey/KeySystemIdentifier' + +export class ExclusiveVaultCriteriaValidator implements CriteriaValidatorInterface { + constructor(private exclusiveVault: KeySystemIdentifier, private element: DecryptedItemInterface) {} + + public passes(): boolean { + return this.element.key_system_identifier === this.exclusiveVault + } +} diff --git a/packages/models/src/Domain/Runtime/Display/Validator/HiddenContentCriteriaValidator.ts b/packages/models/src/Domain/Runtime/Display/Validator/HiddenContentCriteriaValidator.ts new file mode 100644 index 000000000..8ed5b8a2c --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/Validator/HiddenContentCriteriaValidator.ts @@ -0,0 +1,11 @@ +import { DecryptedItemInterface } from './../../../Abstract/Item/Interfaces/DecryptedItem' +import { ContentType } from '@standardnotes/common' +import { CriteriaValidatorInterface } from './CriteriaValidatorInterface' + +export class HiddenContentCriteriaValidator implements CriteriaValidatorInterface { + constructor(private hiddenContentTypes: ContentType[], private element: DecryptedItemInterface) {} + + public passes(): boolean { + return !this.hiddenContentTypes.includes(this.element.content_type) + } +} diff --git a/packages/models/src/Domain/Runtime/Display/VaultDisplayOptions.ts b/packages/models/src/Domain/Runtime/Display/VaultDisplayOptions.ts new file mode 100644 index 000000000..f1fc1f1e7 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/VaultDisplayOptions.ts @@ -0,0 +1,109 @@ +import { VaultListingInterface } from '../../Syncable/VaultListing/VaultListingInterface' +import { uniqueArray } from '@standardnotes/utils' +import { + ExclusioanaryOptions, + ExclusiveOptions, + VaultDisplayOptionsPersistable, + isExclusioanaryOptionsValue, +} from './VaultDisplayOptionsTypes' +import { KeySystemIdentifier } from '../../Syncable/KeySystemRootKey/KeySystemIdentifier' + +function KeySystemIdentifiers(vaults: VaultListingInterface[]): KeySystemIdentifier[] { + return vaults.map((vault) => vault.systemIdentifier) +} + +export class VaultDisplayOptions { + constructor(private readonly options: ExclusioanaryOptions | ExclusiveOptions) {} + + public getOptions(): ExclusioanaryOptions | ExclusiveOptions { + return this.options + } + + public getExclusivelyShownVault(): KeySystemIdentifier { + if (isExclusioanaryOptionsValue(this.options)) { + throw new Error('Not in exclusive display mode') + } + + return this.options.exclusive + } + + public isInExclusiveDisplayMode(): boolean { + return !isExclusioanaryOptionsValue(this.options) + } + + public isVaultExplicitelyExcluded(vault: VaultListingInterface): boolean { + if (isExclusioanaryOptionsValue(this.options)) { + return this.options.exclude.some((excludedVault) => excludedVault === vault.systemIdentifier) + } else if (this.options.exclusive) { + return this.options.exclusive !== vault.systemIdentifier + } + + throw new Error('Invalid vault display options') + } + + isVaultExclusivelyShown(vault: VaultListingInterface): boolean { + return !isExclusioanaryOptionsValue(this.options) && this.options.exclusive === vault.systemIdentifier + } + + isVaultDisabledOrLocked(vault: VaultListingInterface): boolean { + if (isExclusioanaryOptionsValue(this.options)) { + const matchingLocked = this.options.locked.find((lockedVault) => lockedVault === vault.systemIdentifier) + if (matchingLocked) { + return true + } + } + + return this.isVaultExplicitelyExcluded(vault) + } + + getPersistableValue(): VaultDisplayOptionsPersistable { + return this.options + } + + newOptionsByIntakingLockedVaults(lockedVaults: VaultListingInterface[]): VaultDisplayOptions { + if (isExclusioanaryOptionsValue(this.options)) { + return new VaultDisplayOptions({ exclude: this.options.exclude, locked: KeySystemIdentifiers(lockedVaults) }) + } else { + return new VaultDisplayOptions({ exclusive: this.options.exclusive }) + } + } + + newOptionsByExcludingVault(vault: VaultListingInterface, lockedVaults: VaultListingInterface[]): VaultDisplayOptions { + return this.newOptionsByExcludingVaults([vault], lockedVaults) + } + + newOptionsByExcludingVaults( + vaults: VaultListingInterface[], + lockedVaults: VaultListingInterface[], + ): VaultDisplayOptions { + if (isExclusioanaryOptionsValue(this.options)) { + return new VaultDisplayOptions({ + exclude: uniqueArray([...this.options.exclude, ...KeySystemIdentifiers(vaults)]), + locked: KeySystemIdentifiers(lockedVaults), + }) + } else { + return new VaultDisplayOptions({ + exclude: KeySystemIdentifiers(vaults), + locked: KeySystemIdentifiers(lockedVaults), + }) + } + } + + newOptionsByUnexcludingVault( + vault: VaultListingInterface, + lockedVaults: VaultListingInterface[], + ): VaultDisplayOptions { + if (isExclusioanaryOptionsValue(this.options)) { + return new VaultDisplayOptions({ + exclude: this.options.exclude.filter((excludedVault) => excludedVault !== vault.systemIdentifier), + locked: KeySystemIdentifiers(lockedVaults), + }) + } else { + return new VaultDisplayOptions({ exclude: [], locked: KeySystemIdentifiers(lockedVaults) }) + } + } + + static FromPersistableValue(value: VaultDisplayOptionsPersistable): VaultDisplayOptions { + return new VaultDisplayOptions(value) + } +} diff --git a/packages/models/src/Domain/Runtime/Display/VaultDisplayOptionsTypes.ts b/packages/models/src/Domain/Runtime/Display/VaultDisplayOptionsTypes.ts new file mode 100644 index 000000000..78a964fad --- /dev/null +++ b/packages/models/src/Domain/Runtime/Display/VaultDisplayOptionsTypes.ts @@ -0,0 +1,12 @@ +import { KeySystemIdentifier } from '../../Syncable/KeySystemRootKey/KeySystemIdentifier' + +export type ExclusioanaryOptions = { exclude: KeySystemIdentifier[]; locked: KeySystemIdentifier[] } +export type ExclusiveOptions = { exclusive: KeySystemIdentifier } + +export function isExclusioanaryOptionsValue( + options: ExclusioanaryOptions | ExclusiveOptions, +): options is ExclusioanaryOptions { + return 'exclude' in options || 'locked' in options +} + +export type VaultDisplayOptionsPersistable = ExclusioanaryOptions | ExclusiveOptions diff --git a/packages/models/src/Domain/Runtime/Display/index.ts b/packages/models/src/Domain/Runtime/Display/index.ts index 6e66f2c27..dd86731f0 100644 --- a/packages/models/src/Domain/Runtime/Display/index.ts +++ b/packages/models/src/Domain/Runtime/Display/index.ts @@ -6,3 +6,5 @@ export * from './Search/SearchableItem' export * from './Search/SearchUtilities' export * from './Search/Types' export * from './Types' +export * from './VaultDisplayOptions' +export * from './VaultDisplayOptionsTypes' diff --git a/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesKeySystemRootKeyEncryption.ts b/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesKeySystemRootKeyEncryption.ts new file mode 100644 index 000000000..a2471569d --- /dev/null +++ b/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesKeySystemRootKeyEncryption.ts @@ -0,0 +1,5 @@ +import { ContentType } from '@standardnotes/common' + +export function ContentTypeUsesKeySystemRootKeyEncryption(contentType: ContentType): boolean { + return contentType === ContentType.KeySystemItemsKey +} diff --git a/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesRootKeyEncryption.ts b/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesRootKeyEncryption.ts new file mode 100644 index 000000000..727036543 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Encryption/ContentTypeUsesRootKeyEncryption.ts @@ -0,0 +1,6 @@ +import { ContentType } from '@standardnotes/common' +import { ContentTypesUsingRootKeyEncryption } from './ContentTypesUsingRootKeyEncryption' + +export function ContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean { + return ContentTypesUsingRootKeyEncryption().includes(contentType) +} diff --git a/packages/models/src/Domain/Runtime/Encryption/ContentTypesUsingRootKeyEncryption.ts b/packages/models/src/Domain/Runtime/Encryption/ContentTypesUsingRootKeyEncryption.ts new file mode 100644 index 000000000..46c2b82cb --- /dev/null +++ b/packages/models/src/Domain/Runtime/Encryption/ContentTypesUsingRootKeyEncryption.ts @@ -0,0 +1,11 @@ +import { ContentType } from '@standardnotes/common' + +export function ContentTypesUsingRootKeyEncryption(): ContentType[] { + return [ + ContentType.RootKey, + ContentType.ItemsKey, + ContentType.EncryptedStorage, + ContentType.TrustedContact, + ContentType.KeySystemRootKey, + ] +} diff --git a/packages/models/src/Domain/Runtime/Encryption/PersistentSignatureData.ts b/packages/models/src/Domain/Runtime/Encryption/PersistentSignatureData.ts new file mode 100644 index 000000000..4c1b7b8cc --- /dev/null +++ b/packages/models/src/Domain/Runtime/Encryption/PersistentSignatureData.ts @@ -0,0 +1,19 @@ +export type PersistentSignatureData = + | { + required: true + contentHash: string + result: { + passes: boolean + publicKey: string + signature: string + } + } + | { + required: false + contentHash: string + result?: { + passes: boolean + publicKey: string + signature: string + } + } diff --git a/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyContent.ts b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyContent.ts new file mode 100644 index 000000000..7fb19d50c --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyContent.ts @@ -0,0 +1,11 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { ItemContent, SpecializedContent } from '../../Abstract/Content/ItemContent' + +export interface KeySystemItemsKeyContentSpecialized extends SpecializedContent { + version: ProtocolVersion + creationTimestamp: number + itemsKey: string + rootKeyToken: string +} + +export type KeySystemItemsKeyContent = KeySystemItemsKeyContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyInterface.ts b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyInterface.ts new file mode 100644 index 000000000..46d7c23e2 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyInterface.ts @@ -0,0 +1,11 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { KeySystemItemsKeyContent } from './KeySystemItemsKeyContent' + +export interface KeySystemItemsKeyInterface extends DecryptedItemInterface { + readonly creationTimestamp: number + readonly rootKeyToken: string + + get keyVersion(): ProtocolVersion + get itemsKey(): string +} diff --git a/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyMutatorInterface.ts b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyMutatorInterface.ts new file mode 100644 index 000000000..7e3c9041e --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemItemsKey/KeySystemItemsKeyMutatorInterface.ts @@ -0,0 +1,4 @@ +import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface KeySystemItemsKeyMutatorInterface extends DecryptedItemMutator {} diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemIdentifier.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemIdentifier.ts new file mode 100644 index 000000000..6cd652860 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemIdentifier.ts @@ -0,0 +1 @@ +export type KeySystemIdentifier = string diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKey.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKey.ts new file mode 100644 index 000000000..d7207efe2 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKey.ts @@ -0,0 +1,54 @@ +import { ContentType, ProtocolVersion } from '@standardnotes/common' +import { ConflictStrategy, DecryptedItem } from '../../Abstract/Item' +import { DecryptedPayloadInterface } from '../../Abstract/Payload' +import { HistoryEntryInterface } from '../../Runtime/History' +import { KeySystemRootKeyContent } from './KeySystemRootKeyContent' +import { KeySystemRootKeyInterface } from './KeySystemRootKeyInterface' +import { KeySystemIdentifier } from './KeySystemIdentifier' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' + +export function isKeySystemRootKey(x: { content_type: ContentType }): x is KeySystemRootKey { + return x.content_type === ContentType.KeySystemRootKey +} + +export class KeySystemRootKey extends DecryptedItem implements KeySystemRootKeyInterface { + keyParams: KeySystemRootKeyParamsInterface + systemIdentifier: KeySystemIdentifier + + key: string + keyVersion: ProtocolVersion + token: string + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.keyParams = payload.content.keyParams + this.systemIdentifier = payload.content.systemIdentifier + + this.key = payload.content.key + this.keyVersion = payload.content.keyVersion + this.token = payload.content.token + } + + override strategyWhenConflictingWithItem( + item: KeySystemRootKey, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + const baseKeyTimestamp = this.keyParams.creationTimestamp + const incomingKeyTimestamp = item.keyParams.creationTimestamp + + return incomingKeyTimestamp > baseKeyTimestamp ? ConflictStrategy.KeepApply : ConflictStrategy.KeepBase + } + + get itemsKey(): string { + return this.key + } + + override get key_system_identifier(): undefined { + return undefined + } + + override get shared_vault_uuid(): undefined { + return undefined + } +} diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyContent.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyContent.ts new file mode 100644 index 000000000..4c370dec0 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyContent.ts @@ -0,0 +1,16 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { KeySystemIdentifier } from './KeySystemIdentifier' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' + +export type KeySystemRootKeyContentSpecialized = { + keyParams: KeySystemRootKeyParamsInterface + systemIdentifier: KeySystemIdentifier + + key: string + keyVersion: ProtocolVersion + + token: string +} + +export type KeySystemRootKeyContent = KeySystemRootKeyContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyInterface.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyInterface.ts new file mode 100644 index 000000000..a65b5d67c --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyInterface.ts @@ -0,0 +1,38 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { KeySystemRootKeyContent } from './KeySystemRootKeyContent' +import { KeySystemIdentifier } from './KeySystemIdentifier' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' + +export interface KeySystemRootKeyInterface extends DecryptedItemInterface { + keyParams: KeySystemRootKeyParamsInterface + + systemIdentifier: KeySystemIdentifier + + key: string + keyVersion: ProtocolVersion + + /** + * A token is passed to all items keys created while this root key was active. + * When determining which items key a client should use to encrypt new items or new changes, + * it should look for items keys which have the current root key token. This prevents + * the server from dictating which items key a client should use, and also prevents a server from withholding + * items keys from sync results, which would otherwise compel a client to choose between its available items keys, + * which may be old or rotated. + * + * This token is part of the encrypted payload of both the root key and corresponding items keys. While not + * necessarily destructive if leaked, it prevents a malicious server from creating a compromised items key for a vault. + */ + token: string + + get itemsKey(): string + + /** + * Key system root keys pertain to a key system, but they are not actually encrypted inside a key system, but rather + * saved as a normal item in the user's account. An item's key_system_identifier tells the cryptographic system which + * keys to use to encrypt, but a key system rootkey's systemIdentifier is just a reference to that identifier that doesn't + * bind the item to a specific vault system's cryptographic keys. + */ + get key_system_identifier(): undefined + get shared_vault_uuid(): undefined +} diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyMutator.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyMutator.ts new file mode 100644 index 000000000..9a8e95f51 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyMutator.ts @@ -0,0 +1,4 @@ +import { DecryptedItemMutator } from '../../Abstract/Item' +import { KeySystemRootKeyContent } from './KeySystemRootKeyContent' + +export class KeySystemRootKeyMutator extends DecryptedItemMutator {} diff --git a/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyStorageMode.ts b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyStorageMode.ts new file mode 100644 index 000000000..dcab50554 --- /dev/null +++ b/packages/models/src/Domain/Syncable/KeySystemRootKey/KeySystemRootKeyStorageMode.ts @@ -0,0 +1,5 @@ +export enum KeySystemRootKeyStorageMode { + Synced = 'synced', + Local = 'local', + Ephemeral = 'ephemeral', +} diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts index b1da2982c..5e492685e 100644 --- a/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts +++ b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts @@ -9,10 +9,10 @@ import { FillItemContent } from '../../Abstract/Content/ItemContent' import { Predicate } from '../../Runtime/Predicate/Predicate' import { CompoundPredicate } from '../../Runtime/Predicate/CompoundPredicate' import { PayloadTimestampDefaults } from '../../Abstract/Payload' -import { FilterDisplayOptions } from '../../Runtime/Display' +import { NotesAndFilesDisplayOptions } from '../../Runtime/Display' import { FileItem } from '../File' -export function BuildSmartViews(options: FilterDisplayOptions): SmartView[] { +export function BuildSmartViews(options: NotesAndFilesDisplayOptions): SmartView[] { const notes = new SmartView( new DecryptedPayload({ uuid: SystemViewId.AllNotes, @@ -100,7 +100,7 @@ export function BuildSmartViews(options: FilterDisplayOptions): SmartView[] { return [notes, files, starred, archived, trash, untagged, conflicts] } -function allNotesPredicate(options: FilterDisplayOptions) { +function allNotesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.Note)] if (options.includeTrashed === false) { @@ -120,7 +120,7 @@ function allNotesPredicate(options: FilterDisplayOptions) { return predicate } -function filesPredicate(options: FilterDisplayOptions) { +function filesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.File)] if (options.includeTrashed === false) { @@ -140,7 +140,7 @@ function filesPredicate(options: FilterDisplayOptions) { return predicate } -function archivedNotesPredicate(options: FilterDisplayOptions) { +function archivedNotesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [ new Predicate('archived', '=', true), new Predicate('content_type', '=', ContentType.Note), @@ -159,7 +159,7 @@ function archivedNotesPredicate(options: FilterDisplayOptions) { return predicate } -function trashedNotesPredicate(options: FilterDisplayOptions) { +function trashedNotesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [ new Predicate('trashed', '=', true), new Predicate('content_type', '=', ContentType.Note), @@ -178,7 +178,7 @@ function trashedNotesPredicate(options: FilterDisplayOptions) { return predicate } -function untaggedNotesPredicate(options: FilterDisplayOptions) { +function untaggedNotesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates = [ new Predicate('content_type', '=', ContentType.Note), new Predicate('tagsCount', '=', 0), @@ -197,7 +197,7 @@ function untaggedNotesPredicate(options: FilterDisplayOptions) { return predicate } -function starredNotesPredicate(options: FilterDisplayOptions) { +function starredNotesPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [ new Predicate('starred', '=', true), new Predicate('content_type', '=', ContentType.Note), @@ -216,7 +216,7 @@ function starredNotesPredicate(options: FilterDisplayOptions) { return predicate } -function conflictsPredicate(options: FilterDisplayOptions) { +function conflictsPredicate(options: NotesAndFilesDisplayOptions) { const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.Note)] if (options.includeTrashed === false) { diff --git a/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySet.ts b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySet.ts new file mode 100644 index 000000000..c7ea7e622 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySet.ts @@ -0,0 +1,73 @@ +import { ContactPublicKeySetInterface } from './ContactPublicKeySetInterface' +import { ContactPublicKeySetJsonInterface } from './ContactPublicKeySetJsonInterface' + +export class ContactPublicKeySet implements ContactPublicKeySetInterface { + encryption: string + signing: string + timestamp: Date + isRevoked: boolean + previousKeySet?: ContactPublicKeySet + + constructor( + encryption: string, + signing: string, + timestamp: Date, + isRevoked: boolean, + previousKeySet: ContactPublicKeySet | undefined, + ) { + this.encryption = encryption + this.signing = signing + this.timestamp = timestamp + this.isRevoked = isRevoked + this.previousKeySet = previousKeySet + } + + public findKeySet(params: { + targetEncryptionPublicKey: string + targetSigningPublicKey: string + }): ContactPublicKeySetInterface | undefined { + if (this.encryption === params.targetEncryptionPublicKey && this.signing === params.targetSigningPublicKey) { + return this + } + + if (this.previousKeySet) { + return this.previousKeySet.findKeySet(params) + } + + return undefined + } + + public findKeySetWithSigningKey(signingKey: string): ContactPublicKeySetInterface | undefined { + if (this.signing === signingKey) { + return this + } + + if (this.previousKeySet) { + return this.previousKeySet.findKeySetWithSigningKey(signingKey) + } + + return undefined + } + + findKeySetWithPublicKey(publicKey: string): ContactPublicKeySetInterface | undefined { + if (this.encryption === publicKey) { + return this + } + + if (this.previousKeySet) { + return this.previousKeySet.findKeySetWithPublicKey(publicKey) + } + + return undefined + } + + static FromJson(json: ContactPublicKeySetJsonInterface): ContactPublicKeySetInterface { + return new ContactPublicKeySet( + json.encryption, + json.signing, + new Date(json.timestamp), + json.isRevoked, + json.previousKeySet ? ContactPublicKeySet.FromJson(json.previousKeySet) : undefined, + ) + } +} diff --git a/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetInterface.ts b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetInterface.ts new file mode 100644 index 000000000..031aeb779 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetInterface.ts @@ -0,0 +1,15 @@ +export interface ContactPublicKeySetInterface { + encryption: string + signing: string + timestamp: Date + isRevoked: boolean + previousKeySet?: ContactPublicKeySetInterface + + findKeySet(params: { + targetEncryptionPublicKey: string + targetSigningPublicKey: string + }): ContactPublicKeySetInterface | undefined + + findKeySetWithPublicKey(publicKey: string): ContactPublicKeySetInterface | undefined + findKeySetWithSigningKey(signingKey: string): ContactPublicKeySetInterface | undefined +} diff --git a/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetJsonInterface.ts b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetJsonInterface.ts new file mode 100644 index 000000000..667ba4526 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetJsonInterface.ts @@ -0,0 +1,7 @@ +export interface ContactPublicKeySetJsonInterface { + encryption: string + signing: string + timestamp: Date + isRevoked: boolean + previousKeySet?: ContactPublicKeySetJsonInterface +} diff --git a/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/FindPublicKeySetResult.ts b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/FindPublicKeySetResult.ts new file mode 100644 index 000000000..9860a7549 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/PublicKeySet/FindPublicKeySetResult.ts @@ -0,0 +1,8 @@ +import { ContactPublicKeySetInterface } from './ContactPublicKeySetInterface' + +export type FindPublicKeySetResult = + | { + publicKeySet: ContactPublicKeySetInterface + current: boolean + } + | undefined diff --git a/packages/models/src/Domain/Syncable/TrustedContact/TrustedContact.ts b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContact.ts new file mode 100644 index 000000000..0bafb968f --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContact.ts @@ -0,0 +1,77 @@ +import { ConflictStrategy, DecryptedItem, DecryptedItemInterface } from '../../Abstract/Item' +import { DecryptedPayloadInterface } from '../../Abstract/Payload' +import { HistoryEntryInterface } from '../../Runtime/History' +import { TrustedContactContent } from './TrustedContactContent' +import { TrustedContactInterface } from './TrustedContactInterface' +import { FindPublicKeySetResult } from './PublicKeySet/FindPublicKeySetResult' +import { ContactPublicKeySet } from './PublicKeySet/ContactPublicKeySet' +import { ContactPublicKeySetInterface } from './PublicKeySet/ContactPublicKeySetInterface' +import { Predicate } from '../../Runtime/Predicate/Predicate' + +export class TrustedContact extends DecryptedItem implements TrustedContactInterface { + static singletonPredicate = new Predicate('isMe', '=', true) + + name: string + contactUuid: string + publicKeySet: ContactPublicKeySetInterface + isMe: boolean + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.name = payload.content.name + this.contactUuid = payload.content.contactUuid + this.publicKeySet = ContactPublicKeySet.FromJson(payload.content.publicKeySet) + this.isMe = payload.content.isMe + } + + override get isSingleton(): true { + return true + } + + override singletonPredicate(): Predicate { + return TrustedContact.singletonPredicate + } + + public findKeySet(params: { + targetEncryptionPublicKey: string + targetSigningPublicKey: string + }): FindPublicKeySetResult { + const set = this.publicKeySet.findKeySet(params) + if (!set) { + return undefined + } + + return { + publicKeySet: set, + current: set === this.publicKeySet, + } + } + + isPublicKeyTrusted(encryptionPublicKey: string): boolean { + const keySet = this.publicKeySet.findKeySetWithPublicKey(encryptionPublicKey) + + if (keySet && !keySet.isRevoked) { + return true + } + + return false + } + + isSigningKeyTrusted(signingKey: string): boolean { + const keySet = this.publicKeySet.findKeySetWithSigningKey(signingKey) + + if (keySet && !keySet.isRevoked) { + return true + } + + return false + } + + override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } +} diff --git a/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactContent.ts b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactContent.ts new file mode 100644 index 000000000..2cc5406d5 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactContent.ts @@ -0,0 +1,11 @@ +import { ItemContent } from '../../Abstract/Content/ItemContent' +import { ContactPublicKeySetInterface } from './PublicKeySet/ContactPublicKeySetInterface' + +export type TrustedContactContentSpecialized = { + name: string + contactUuid: string + publicKeySet: ContactPublicKeySetInterface + isMe: boolean +} + +export type TrustedContactContent = TrustedContactContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactInterface.ts b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactInterface.ts new file mode 100644 index 000000000..5cc5841d5 --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactInterface.ts @@ -0,0 +1,16 @@ +import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' +import { FindPublicKeySetResult } from './PublicKeySet/FindPublicKeySetResult' +import { TrustedContactContent } from './TrustedContactContent' +import { ContactPublicKeySetInterface } from './PublicKeySet/ContactPublicKeySetInterface' + +export interface TrustedContactInterface extends DecryptedItemInterface { + name: string + contactUuid: string + publicKeySet: ContactPublicKeySetInterface + isMe: boolean + + findKeySet(params: { targetEncryptionPublicKey: string; targetSigningPublicKey: string }): FindPublicKeySetResult + + isPublicKeyTrusted(encryptionPublicKey: string): boolean + isSigningKeyTrusted(signingKey: string): boolean +} diff --git a/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactMutator.ts b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactMutator.ts new file mode 100644 index 000000000..56ddc9f9c --- /dev/null +++ b/packages/models/src/Domain/Syncable/TrustedContact/TrustedContactMutator.ts @@ -0,0 +1,26 @@ +import { DecryptedItemMutator } from '../../Abstract/Item' +import { TrustedContactContent } from './TrustedContactContent' +import { TrustedContactInterface } from './TrustedContactInterface' +import { ContactPublicKeySet } from './PublicKeySet/ContactPublicKeySet' + +export class TrustedContactMutator extends DecryptedItemMutator { + set name(newName: string) { + this.mutableContent.name = newName + } + + addPublicKey(params: { encryption: string; signing: string }): void { + const newKey = new ContactPublicKeySet( + params.encryption, + params.signing, + new Date(), + false, + this.immutableItem.publicKeySet, + ) + + this.mutableContent.publicKeySet = newKey + } + + replacePublicKeySet(publicKeySet: ContactPublicKeySet): void { + this.mutableContent.publicKeySet = publicKeySet + } +} diff --git a/packages/models/src/Domain/Syncable/VaultListing/VaultListing.ts b/packages/models/src/Domain/Syncable/VaultListing/VaultListing.ts new file mode 100644 index 000000000..92f316d3f --- /dev/null +++ b/packages/models/src/Domain/Syncable/VaultListing/VaultListing.ts @@ -0,0 +1,62 @@ +import { ConflictStrategy, DecryptedItem } from '../../Abstract/Item' +import { DecryptedPayloadInterface } from '../../Abstract/Payload' +import { HistoryEntryInterface } from '../../Runtime/History' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' +import { KeySystemRootKeyPasswordType } from '../../Local/KeyParams/KeySystemRootKeyPasswordType' +import { SharedVaultListingInterface, VaultListingInterface } from './VaultListingInterface' +import { VaultListingContent } from './VaultListingContent' +import { KeySystemRootKeyStorageMode } from '../KeySystemRootKey/KeySystemRootKeyStorageMode' +import { VaultListingSharingInfo } from './VaultListingSharingInfo' +import { KeySystemIdentifier } from '../KeySystemRootKey/KeySystemIdentifier' + +export class VaultListing extends DecryptedItem implements VaultListingInterface { + systemIdentifier: KeySystemIdentifier + + rootKeyParams: KeySystemRootKeyParamsInterface + keyStorageMode: KeySystemRootKeyStorageMode + + name: string + description?: string + + sharing?: VaultListingSharingInfo + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.systemIdentifier = payload.content.systemIdentifier + + this.rootKeyParams = payload.content.rootKeyParams + this.keyStorageMode = payload.content.keyStorageMode + + this.name = payload.content.name + this.description = payload.content.description + + this.sharing = payload.content.sharing + } + + override strategyWhenConflictingWithItem( + item: VaultListing, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + const baseKeyTimestamp = this.rootKeyParams.creationTimestamp + const incomingKeyTimestamp = item.rootKeyParams.creationTimestamp + + return incomingKeyTimestamp > baseKeyTimestamp ? ConflictStrategy.KeepApply : ConflictStrategy.KeepBase + } + + get keyPasswordType(): KeySystemRootKeyPasswordType { + return this.rootKeyParams.passwordType + } + + isSharedVaultListing(): this is SharedVaultListingInterface { + return this.sharing != undefined + } + + override get key_system_identifier(): undefined { + return undefined + } + + override get shared_vault_uuid(): undefined { + return undefined + } +} diff --git a/packages/models/src/Domain/Syncable/VaultListing/VaultListingContent.ts b/packages/models/src/Domain/Syncable/VaultListing/VaultListingContent.ts new file mode 100644 index 000000000..e6022acda --- /dev/null +++ b/packages/models/src/Domain/Syncable/VaultListing/VaultListingContent.ts @@ -0,0 +1,19 @@ +import { ItemContent, SpecializedContent } from '../../Abstract/Content/ItemContent' +import { KeySystemIdentifier } from '../KeySystemRootKey/KeySystemIdentifier' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' +import { KeySystemRootKeyStorageMode } from '../KeySystemRootKey/KeySystemRootKeyStorageMode' +import { VaultListingSharingInfo } from './VaultListingSharingInfo' + +export interface VaultListingContentSpecialized extends SpecializedContent { + systemIdentifier: KeySystemIdentifier + + rootKeyParams: KeySystemRootKeyParamsInterface + keyStorageMode: KeySystemRootKeyStorageMode + + name: string + description?: string + + sharing?: VaultListingSharingInfo +} + +export type VaultListingContent = VaultListingContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/VaultListing/VaultListingInterface.ts b/packages/models/src/Domain/Syncable/VaultListing/VaultListingInterface.ts new file mode 100644 index 000000000..709a3c933 --- /dev/null +++ b/packages/models/src/Domain/Syncable/VaultListing/VaultListingInterface.ts @@ -0,0 +1,29 @@ +import { KeySystemIdentifier } from '../KeySystemRootKey/KeySystemIdentifier' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' +import { KeySystemRootKeyPasswordType } from '../../Local/KeyParams/KeySystemRootKeyPasswordType' +import { KeySystemRootKeyStorageMode } from '../KeySystemRootKey/KeySystemRootKeyStorageMode' +import { VaultListingSharingInfo } from './VaultListingSharingInfo' +import { VaultListingContent } from './VaultListingContent' +import { DecryptedItemInterface } from '../../Abstract/Item' + +export interface VaultListingInterface extends DecryptedItemInterface { + systemIdentifier: KeySystemIdentifier + + rootKeyParams: KeySystemRootKeyParamsInterface + keyStorageMode: KeySystemRootKeyStorageMode + + name: string + description?: string + + sharing?: VaultListingSharingInfo + + get keyPasswordType(): KeySystemRootKeyPasswordType + isSharedVaultListing(): this is SharedVaultListingInterface + + get key_system_identifier(): undefined + get shared_vault_uuid(): undefined +} + +export interface SharedVaultListingInterface extends VaultListingInterface { + sharing: VaultListingSharingInfo +} diff --git a/packages/models/src/Domain/Syncable/VaultListing/VaultListingMutator.ts b/packages/models/src/Domain/Syncable/VaultListing/VaultListingMutator.ts new file mode 100644 index 000000000..da6f7b359 --- /dev/null +++ b/packages/models/src/Domain/Syncable/VaultListing/VaultListingMutator.ts @@ -0,0 +1,27 @@ +import { DecryptedItemMutator } from '../../Abstract/Item' +import { KeySystemRootKeyParamsInterface } from '../../Local/KeyParams/KeySystemRootKeyParamsInterface' +import { KeySystemRootKeyStorageMode } from '../KeySystemRootKey/KeySystemRootKeyStorageMode' +import { VaultListingContent } from './VaultListingContent' +import { VaultListingSharingInfo } from './VaultListingSharingInfo' + +export class VaultListingMutator extends DecryptedItemMutator { + set name(name: string) { + this.mutableContent.name = name + } + + set description(description: string | undefined) { + this.mutableContent.description = description + } + + set sharing(sharing: VaultListingSharingInfo | undefined) { + this.mutableContent.sharing = sharing + } + + set rootKeyParams(rootKeyParams: KeySystemRootKeyParamsInterface) { + this.mutableContent.rootKeyParams = rootKeyParams + } + + set keyStorageMode(keyStorageMode: KeySystemRootKeyStorageMode) { + this.mutableContent.keyStorageMode = keyStorageMode + } +} diff --git a/packages/models/src/Domain/Syncable/VaultListing/VaultListingSharingInfo.ts b/packages/models/src/Domain/Syncable/VaultListing/VaultListingSharingInfo.ts new file mode 100644 index 000000000..f35657e32 --- /dev/null +++ b/packages/models/src/Domain/Syncable/VaultListing/VaultListingSharingInfo.ts @@ -0,0 +1,4 @@ +export type VaultListingSharingInfo = { + sharedVaultUuid: string + ownerUserUuid: string +} diff --git a/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts b/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts index 24eaa20a0..92d87ae46 100644 --- a/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts +++ b/packages/models/src/Domain/Utilities/Item/ItemGenerator.ts @@ -23,6 +23,16 @@ import { NoteMutator } from '../../Syncable/Note/NoteMutator' import { DecryptedItemInterface } from '../../Abstract/Item/Interfaces/DecryptedItem' import { ItemContent } from '../../Abstract/Content/ItemContent' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { DeletedItem } from '../../Abstract/Item/Implementations/DeletedItem' +import { EncryptedItemInterface } from '../../Abstract/Item/Interfaces/EncryptedItem' +import { DeletedItemInterface } from '../../Abstract/Item/Interfaces/DeletedItem' +import { SmartViewMutator } from '../../Syncable/SmartView' +import { TrustedContact } from '../../Syncable/TrustedContact/TrustedContact' +import { TrustedContactMutator } from '../../Syncable/TrustedContact/TrustedContactMutator' +import { KeySystemRootKey } from '../../Syncable/KeySystemRootKey/KeySystemRootKey' +import { KeySystemRootKeyMutator } from '../../Syncable/KeySystemRootKey/KeySystemRootKeyMutator' +import { VaultListing } from '../../Syncable/VaultListing/VaultListing' +import { VaultListingMutator } from '../../Syncable/VaultListing/VaultListingMutator' import { DeletedPayloadInterface, EncryptedPayloadInterface, @@ -30,10 +40,6 @@ import { isDeletedPayload, isEncryptedPayload, } from '../../Abstract/Payload' -import { DeletedItem } from '../../Abstract/Item/Implementations/DeletedItem' -import { EncryptedItemInterface } from '../../Abstract/Item/Interfaces/EncryptedItem' -import { DeletedItemInterface } from '../../Abstract/Item/Interfaces/DeletedItem' -import { SmartViewMutator } from '../../Syncable/SmartView' type ItemClass = new (payload: DecryptedPayloadInterface) => DecryptedItem @@ -53,6 +59,9 @@ const ContentTypeClassMapping: Partial> = { mutatorClass: ActionsExtensionMutator, }, [ContentType.Component]: { itemClass: SNComponent, mutatorClass: ComponentMutator }, + [ContentType.KeySystemRootKey]: { itemClass: KeySystemRootKey, mutatorClass: KeySystemRootKeyMutator }, + [ContentType.TrustedContact]: { itemClass: TrustedContact, mutatorClass: TrustedContactMutator }, + [ContentType.VaultListing]: { itemClass: VaultListing, mutatorClass: VaultListingMutator }, [ContentType.Editor]: { itemClass: SNEditor }, [ContentType.ExtensionRepo]: { itemClass: SNFeatureRepo }, [ContentType.File]: { itemClass: FileItem, mutatorClass: FileMutator }, @@ -65,13 +74,13 @@ const ContentTypeClassMapping: Partial> = { export function CreateDecryptedMutatorForItem< I extends DecryptedItemInterface, - M extends DecryptedItemMutator = DecryptedItemMutator, + M extends DecryptedItemMutator = DecryptedItemMutator, >(item: I, type: MutationType): M { const lookupValue = ContentTypeClassMapping[item.content_type]?.mutatorClass if (lookupValue) { return new lookupValue(item, type) as M } else { - return new DecryptedItemMutator(item, type) as M + return new DecryptedItemMutator(item, type) as M } } diff --git a/packages/models/src/Domain/Utilities/Test/SpecUtils.ts b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts index 02694c94a..4747d3d65 100644 --- a/packages/models/src/Domain/Utilities/Test/SpecUtils.ts +++ b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts @@ -13,13 +13,13 @@ export const mockUuid = () => { return `${currentId++}` } -export const createNote = (payload?: Partial): SNNote => { +export const createNote = (content?: Partial): SNNote => { return new SNNote( new DecryptedPayload( { uuid: mockUuid(), content_type: ContentType.Note, - content: FillItemContent({ ...payload }), + content: FillItemContent({ ...content }), ...PayloadTimestampDefaults(), }, PayloadSource.Constructor, diff --git a/packages/models/src/Domain/index.ts b/packages/models/src/Domain/index.ts index c95f30c38..c6dcc78dc 100644 --- a/packages/models/src/Domain/index.ts +++ b/packages/models/src/Domain/index.ts @@ -15,6 +15,7 @@ export * from './Abstract/Contextual/ComponentCreate' export * from './Abstract/Contextual/ComponentRetrieved' export * from './Abstract/Contextual/ContextPayload' export * from './Abstract/Contextual/FilteredServerItem' +export * from './Abstract/Contextual/TrustedConflictParams' export * from './Abstract/Contextual/Functions' export * from './Abstract/Contextual/LocalStorage' export * from './Abstract/Contextual/OfflineSyncPush' @@ -25,19 +26,26 @@ export * from './Abstract/Contextual/SessionHistory' export * from './Abstract/Item' export * from './Abstract/Payload' export * from './Abstract/TransferPayload' + export * from './Api/Subscription/Invitation' export * from './Api/Subscription/InvitationStatus' export * from './Api/Subscription/InviteeIdentifierType' export * from './Api/Subscription/InviterIdentifierType' + export * from './Device/Environment' export * from './Device/Platform' + export * from './Local/KeyParams/RootKeyParamsInterface' +export * from './Local/KeyParams/KeySystemRootKeyParamsInterface' +export * from './Local/KeyParams/KeySystemRootKeyPasswordType' export * from './Local/RootKey/KeychainTypes' export * from './Local/RootKey/RootKeyContent' export * from './Local/RootKey/RootKeyInterface' +export * from './Local/RootKey/RootKeyWithKeyPairsInterface' + export * from './Runtime/Collection/CollectionSort' export * from './Runtime/Collection/Item/ItemCollection' -export * from './Runtime/Collection/Item/TagItemsIndex' +export * from './Runtime/Collection/Item/ItemCounter' export * from './Runtime/Collection/Payload/ImmutablePayloadCollection' export * from './Runtime/Collection/Payload/PayloadCollection' export * from './Runtime/Deltas' @@ -57,6 +65,20 @@ export * from './Runtime/Predicate/NotPredicate' export * from './Runtime/Predicate/Operator' export * from './Runtime/Predicate/Predicate' export * from './Runtime/Predicate/Utils' + +export * from './Runtime/AsymmetricMessage/AsymmetricMessagePayload' +export * from './Runtime/AsymmetricMessage/AsymmetricMessagePayloadType' +export * from './Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSenderKeypairChanged' +export * from './Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultInvite' +export * from './Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultMetadataChanged' +export * from './Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageSharedVaultRootKeyChanged' +export * from './Runtime/AsymmetricMessage/MessageTypes/AsymmetricMessageTrustedContactShare' + +export * from './Runtime/Encryption/PersistentSignatureData' +export * from './Runtime/Encryption/ContentTypeUsesRootKeyEncryption' +export * from './Runtime/Encryption/ContentTypesUsingRootKeyEncryption' +export * from './Runtime/Encryption/ContentTypeUsesKeySystemRootKeyEncryption' + export * from './Syncable/ActionsExtension' export * from './Syncable/Component' export * from './Syncable/Editor' @@ -69,6 +91,30 @@ export * from './Syncable/SmartView' export * from './Syncable/Tag' export * from './Syncable/Theme' export * from './Syncable/UserPrefs' + +export * from './Syncable/TrustedContact/TrustedContact' +export * from './Syncable/TrustedContact/TrustedContactMutator' +export * from './Syncable/TrustedContact/TrustedContactContent' +export * from './Syncable/TrustedContact/TrustedContactInterface' +export * from './Syncable/TrustedContact/PublicKeySet/ContactPublicKeySetInterface' +export * from './Syncable/TrustedContact/PublicKeySet/ContactPublicKeySet' + +export * from './Syncable/KeySystemRootKey/KeySystemRootKey' +export * from './Syncable/KeySystemRootKey/KeySystemRootKeyMutator' +export * from './Syncable/KeySystemRootKey/KeySystemRootKeyContent' +export * from './Syncable/KeySystemRootKey/KeySystemRootKeyInterface' +export * from './Syncable/KeySystemRootKey/KeySystemRootKeyStorageMode' + +export * from './Syncable/KeySystemItemsKey/KeySystemItemsKeyInterface' +export * from './Syncable/KeySystemItemsKey/KeySystemItemsKeyContent' +export * from './Syncable/KeySystemItemsKey/KeySystemItemsKeyMutatorInterface' + +export * from './Syncable/VaultListing/VaultListing' +export * from './Syncable/VaultListing/VaultListingContent' +export * from './Syncable/VaultListing/VaultListingInterface' +export * from './Syncable/VaultListing/VaultListingMutator' +export * from './Syncable/VaultListing/VaultListingSharingInfo' + export * from './Utilities/Icon/IconType' export * from './Utilities/Item/FindItem' export * from './Utilities/Item/ItemContentsDiffer' @@ -81,3 +127,4 @@ export * from './Utilities/Payload/PayloadContentsEqual' export * from './Utilities/Payload/PayloadsByAlternatingUuid' export * from './Utilities/Payload/PayloadsByDuplicating' export * from './Utilities/Payload/PayloadSplit' +export * from './Syncable/KeySystemRootKey/KeySystemIdentifier' diff --git a/packages/responses/package.json b/packages/responses/package.json index 912ee92c3..4c6ba490f 100644 --- a/packages/responses/package.json +++ b/packages/responses/package.json @@ -33,7 +33,7 @@ "typescript": "*" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/features": "workspace:*", "@standardnotes/security": "^1.7.6", "reflect-metadata": "^0.1.13" diff --git a/packages/responses/src/Domain/AsymmetricMessage/AsymmetricMessageServerHash.ts b/packages/responses/src/Domain/AsymmetricMessage/AsymmetricMessageServerHash.ts new file mode 100644 index 000000000..a22444dcf --- /dev/null +++ b/packages/responses/src/Domain/AsymmetricMessage/AsymmetricMessageServerHash.ts @@ -0,0 +1,8 @@ +export interface AsymmetricMessageServerHash { + uuid: string + user_uuid: string + sender_uuid: string + encrypted_message: string + created_at_timestamp: number + updated_at_timestamp: number +} diff --git a/packages/responses/src/Domain/Error/ClientDisplayableError.ts b/packages/responses/src/Domain/Error/ClientDisplayableError.ts new file mode 100644 index 000000000..e42138d7a --- /dev/null +++ b/packages/responses/src/Domain/Error/ClientDisplayableError.ts @@ -0,0 +1,23 @@ +import { HttpErrorResponse } from '../Http' + +export class ClientDisplayableError { + constructor(public text: string, public title?: string, public tag?: string) { + console.error('Client Displayable Error:', text, title || '', tag || '') + } + + static FromError(error: { message: string; tag?: string }) { + return new ClientDisplayableError(error.message, undefined, error.tag) + } + + static FromString(text: string) { + return new ClientDisplayableError(text) + } + + static FromNetworkError(error: HttpErrorResponse) { + return new ClientDisplayableError(error.data.error.message) + } +} + +export function isClientDisplayableError(error: unknown): error is ClientDisplayableError { + return error instanceof ClientDisplayableError +} diff --git a/packages/responses/src/Domain/Error/ClientError.ts b/packages/responses/src/Domain/Error/ClientError.ts deleted file mode 100644 index fa04f0055..000000000 --- a/packages/responses/src/Domain/Error/ClientError.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class ClientDisplayableError { - constructor(public text: string, public title?: string, public tag?: string) { - console.error('Client Displayable Error:', text, title || '', tag || '') - } - - static FromError(error: { message: string; tag?: string }) { - return new ClientDisplayableError(error.message, undefined, error.tag) - } -} diff --git a/packages/responses/src/Domain/Files/CreateValetTokenPayload.ts b/packages/responses/src/Domain/Files/CreateValetTokenPayload.ts index c2ad37a19..dc92c78ed 100644 --- a/packages/responses/src/Domain/Files/CreateValetTokenPayload.ts +++ b/packages/responses/src/Domain/Files/CreateValetTokenPayload.ts @@ -1,5 +1,7 @@ +import { ValetTokenOperation } from './ValetTokenOperation' + export type CreateValetTokenPayload = { - operation: 'read' | 'write' | 'delete' + operation: ValetTokenOperation resources: Array<{ remoteIdentifier: string unencryptedFileSize?: number diff --git a/packages/responses/src/Domain/Files/MoveFileResponse.ts b/packages/responses/src/Domain/Files/MoveFileResponse.ts new file mode 100644 index 000000000..4b7aa6d32 --- /dev/null +++ b/packages/responses/src/Domain/Files/MoveFileResponse.ts @@ -0,0 +1,3 @@ +export type MoveFileResponse = { + success: boolean +} diff --git a/packages/responses/src/Domain/Files/ValetTokenOperation.ts b/packages/responses/src/Domain/Files/ValetTokenOperation.ts new file mode 100644 index 000000000..ea41f853c --- /dev/null +++ b/packages/responses/src/Domain/Files/ValetTokenOperation.ts @@ -0,0 +1 @@ +export type ValetTokenOperation = 'read' | 'write' | 'delete' | 'move' diff --git a/packages/responses/src/Domain/Http/ErrorTag.ts b/packages/responses/src/Domain/Http/ErrorTag.ts index 715c3a4be..15ab6bc41 100644 --- a/packages/responses/src/Domain/Http/ErrorTag.ts +++ b/packages/responses/src/Domain/Http/ErrorTag.ts @@ -9,6 +9,7 @@ export enum ErrorTag { RevokedSession = 'revoked-session', AuthInvalid = 'invalid-auth', ReadOnlyAccess = 'read-only-access', + ExpiredItemShare = 'expired-item-share', ClientValidationError = 'client-validation-error', ClientCanceledMfa = 'client-canceled-mfa', diff --git a/packages/responses/src/Domain/Http/HttpResponse.ts b/packages/responses/src/Domain/Http/HttpResponse.ts index 0f3536d8b..19af81258 100644 --- a/packages/responses/src/Domain/Http/HttpResponse.ts +++ b/packages/responses/src/Domain/Http/HttpResponse.ts @@ -22,5 +22,5 @@ export interface HttpSuccessResponse extends HttpResponseB export type HttpResponse = HttpErrorResponse | HttpSuccessResponse export function isErrorResponse(response: HttpResponse): response is HttpErrorResponse { - return (response.data as HttpErrorResponseBody)?.error != undefined + return (response.data as HttpErrorResponseBody)?.error != undefined || response.status >= 400 } diff --git a/packages/responses/src/Domain/Item/ApiEndpointParam.ts b/packages/responses/src/Domain/Item/ApiEndpointParam.ts index 007ded326..38ea6e58f 100644 --- a/packages/responses/src/Domain/Item/ApiEndpointParam.ts +++ b/packages/responses/src/Domain/Item/ApiEndpointParam.ts @@ -4,4 +4,5 @@ export enum ApiEndpointParam { SyncDlLimit = 'limit', SyncPayloads = 'items', ApiVersion = 'api', + SharedVaultUuids = 'shared_vault_uuids', } diff --git a/packages/responses/src/Domain/Item/ConflictParams.ts b/packages/responses/src/Domain/Item/ConflictParams.ts index 9530f8595..a2eb6201a 100644 --- a/packages/responses/src/Domain/Item/ConflictParams.ts +++ b/packages/responses/src/Domain/Item/ConflictParams.ts @@ -1,11 +1,98 @@ import { ConflictType } from './ConflictType' import { ServerItemResponse } from './ServerItemResponse' -export type ConflictParams = { +type BaseConflictParams = { type: ConflictType - server_item?: ServerItemResponse - unsaved_item?: ServerItemResponse - - /** @legacay */ - item?: ServerItemResponse + server_item?: T + unsaved_item?: T } + +export type ConflictParamsWithServerItem = BaseConflictParams & { + server_item: T + unsaved_item: never +} + +export type ConflictParamsWithUnsavedItem = BaseConflictParams & { + unsaved_item: T + server_item: never +} + +export type ConflictParamsWithServerAndUnsavedItem = BaseConflictParams & { + server_item: T + unsaved_item: T +} + +export type ConflictConflictingDataParams = BaseConflictParams & { + type: ConflictType.ConflictingData + server_item: T + unsaved_item: never +} + +export type ConflictUuidConflictParams = BaseConflictParams & { + type: ConflictType.UuidConflict + server_item: never + unsaved_item: T +} + +export type ConflictContentTypeErrorParams = BaseConflictParams & { + type: ConflictType.ContentTypeError + server_item: never + unsaved_item: T +} + +export type ConflictContentErrorParams = BaseConflictParams & { + type: ConflictType.ContentError + server_item: never + unsaved_item: T +} + +export type ConflictReadOnlyErrorParams = BaseConflictParams & { + type: ConflictType.ReadOnlyError + server_item: T + unsaved_item: T +} + +export type ConflictUuidErrorParams = BaseConflictParams & { + type: ConflictType.UuidError + server_item: never + unsaved_item: T +} + +export type ConflictSharedVaultNotMemberErrorParams = BaseConflictParams & { + type: ConflictType.SharedVaultNotMemberError + server_item: never + unsaved_item: T +} + +export type ConflictSharedVaultInsufficientPermissionsErrorParams = BaseConflictParams & { + type: ConflictType.SharedVaultInsufficientPermissionsError + unsaved_item: T +} + +export function conflictParamsHasServerItemAndUnsavedItem( + params: BaseConflictParams, +): params is ConflictParamsWithServerAndUnsavedItem { + return params.server_item !== undefined && params.unsaved_item !== undefined +} + +export function conflictParamsHasOnlyServerItem( + params: BaseConflictParams, +): params is ConflictParamsWithServerItem { + return params.server_item !== undefined +} + +export function conflictParamsHasOnlyUnsavedItem( + params: BaseConflictParams, +): params is ConflictParamsWithUnsavedItem { + return params.unsaved_item !== undefined +} + +export type ConflictParams = + | ConflictConflictingDataParams + | ConflictUuidConflictParams + | ConflictContentTypeErrorParams + | ConflictContentErrorParams + | ConflictReadOnlyErrorParams + | ConflictUuidErrorParams + | ConflictSharedVaultNotMemberErrorParams + | ConflictSharedVaultInsufficientPermissionsErrorParams diff --git a/packages/responses/src/Domain/Item/ConflictType.ts b/packages/responses/src/Domain/Item/ConflictType.ts index 3618a24a1..c3f80a782 100644 --- a/packages/responses/src/Domain/Item/ConflictType.ts +++ b/packages/responses/src/Domain/Item/ConflictType.ts @@ -5,5 +5,9 @@ export enum ConflictType { ContentError = 'content_error', ReadOnlyError = 'readonly_error', UuidError = 'uuid_error', - SyncError = 'sync_error', + + SharedVaultSnjsVersionError = 'shared_vault_snjs_version_error', + SharedVaultInsufficientPermissionsError = 'shared_vault_insufficient_permissions_error', + SharedVaultNotMemberError = 'shared_vault_not_member_error', + SharedVaultInvalidState = 'shared_vault_invalid_state', } diff --git a/packages/responses/src/Domain/Item/RawSyncData.ts b/packages/responses/src/Domain/Item/RawSyncData.ts index 9db1004d9..ec8c9bb78 100644 --- a/packages/responses/src/Domain/Item/RawSyncData.ts +++ b/packages/responses/src/Domain/Item/RawSyncData.ts @@ -1,6 +1,10 @@ +import { SharedVaultInviteServerHash } from '../SharedVaults/SharedVaultInviteServerHash' import { ApiEndpointParam } from './ApiEndpointParam' import { ConflictParams } from './ConflictParams' import { ServerItemResponse } from './ServerItemResponse' +import { SharedVaultServerHash } from '../SharedVaults/SharedVaultServerHash' +import { UserEventServerHash } from '../UserEvent/UserEventServerHash' +import { AsymmetricMessageServerHash } from '../AsymmetricMessage/AsymmetricMessageServerHash' export type RawSyncData = { error?: unknown @@ -10,5 +14,9 @@ export type RawSyncData = { saved_items?: ServerItemResponse[] conflicts?: ConflictParams[] unsaved?: ConflictParams[] + shared_vaults?: SharedVaultServerHash[] + shared_vault_invites?: SharedVaultInviteServerHash[] + user_events?: UserEventServerHash[] + asymmetric_messages?: AsymmetricMessageServerHash[] status?: number } diff --git a/packages/responses/src/Domain/Item/ServerItemResponse.ts b/packages/responses/src/Domain/Item/ServerItemResponse.ts index 7d6a27cdc..4221e2858 100644 --- a/packages/responses/src/Domain/Item/ServerItemResponse.ts +++ b/packages/responses/src/Domain/Item/ServerItemResponse.ts @@ -12,4 +12,8 @@ export interface ServerItemResponse { updated_at_timestamp: number updated_at: Date uuid: string + user_uuid: string + shared_vault_uuid: string | undefined + key_system_identifier: string | undefined + last_edited_by_uuid?: string } diff --git a/packages/responses/src/Domain/SharedVaults/SharedVaultInviteServerHash.ts b/packages/responses/src/Domain/SharedVaults/SharedVaultInviteServerHash.ts new file mode 100644 index 000000000..562eea2de --- /dev/null +++ b/packages/responses/src/Domain/SharedVaults/SharedVaultInviteServerHash.ts @@ -0,0 +1,13 @@ +import { AsymmetricMessageServerHash } from '../AsymmetricMessage/AsymmetricMessageServerHash' +import { SharedVaultPermission } from './SharedVaultPermission' + +export interface SharedVaultInviteServerHash extends AsymmetricMessageServerHash { + uuid: string + shared_vault_uuid: string + user_uuid: string + sender_uuid: string + encrypted_message: string + permissions: SharedVaultPermission + created_at_timestamp: number + updated_at_timestamp: number +} diff --git a/packages/responses/src/Domain/SharedVaults/SharedVaultPermission.ts b/packages/responses/src/Domain/SharedVaults/SharedVaultPermission.ts new file mode 100644 index 000000000..877fd4031 --- /dev/null +++ b/packages/responses/src/Domain/SharedVaults/SharedVaultPermission.ts @@ -0,0 +1,5 @@ +export enum SharedVaultPermission { + Read = 'read', + Write = 'write', + Admin = 'admin', +} diff --git a/packages/responses/src/Domain/SharedVaults/SharedVaultServerHash.ts b/packages/responses/src/Domain/SharedVaults/SharedVaultServerHash.ts new file mode 100644 index 000000000..aefd70529 --- /dev/null +++ b/packages/responses/src/Domain/SharedVaults/SharedVaultServerHash.ts @@ -0,0 +1,4 @@ +export interface SharedVaultServerHash { + uuid: string + user_uuid: string +} diff --git a/packages/responses/src/Domain/SharedVaults/SharedVaultUserServerHash.ts b/packages/responses/src/Domain/SharedVaults/SharedVaultUserServerHash.ts new file mode 100644 index 000000000..8b6aa4487 --- /dev/null +++ b/packages/responses/src/Domain/SharedVaults/SharedVaultUserServerHash.ts @@ -0,0 +1,9 @@ +import { SharedVaultPermission } from './SharedVaultPermission' + +export interface SharedVaultUserServerHash { + uuid: string + shared_vault_uuid: string + user_uuid: string + permissions: SharedVaultPermission + updated_at_timestamp: number +} diff --git a/packages/responses/src/Domain/UserEvent/UserEventPayload.ts b/packages/responses/src/Domain/UserEvent/UserEventPayload.ts new file mode 100644 index 000000000..bbb34778e --- /dev/null +++ b/packages/responses/src/Domain/UserEvent/UserEventPayload.ts @@ -0,0 +1,14 @@ +import { UserEventType } from './UserEventType' + +export type UserEventPayload = + | { + eventType: UserEventType.SharedVaultItemRemoved + itemUuid: string + sharedVaultUuid: string + version: string + } + | { + eventType: UserEventType.RemovedFromSharedVault + sharedVaultUuid: string + version: string + } diff --git a/packages/responses/src/Domain/UserEvent/UserEventServerHash.ts b/packages/responses/src/Domain/UserEvent/UserEventServerHash.ts new file mode 100644 index 000000000..d72d42842 --- /dev/null +++ b/packages/responses/src/Domain/UserEvent/UserEventServerHash.ts @@ -0,0 +1,10 @@ +import { UserEventType } from './UserEventType' + +export type UserEventServerHash = { + uuid: string + user_uuid: string + event_type: UserEventType + event_payload: string + created_at_timestamp?: number + updated_at_timestamp?: number +} diff --git a/packages/responses/src/Domain/UserEvent/UserEventType.ts b/packages/responses/src/Domain/UserEvent/UserEventType.ts new file mode 100644 index 000000000..1e9008520 --- /dev/null +++ b/packages/responses/src/Domain/UserEvent/UserEventType.ts @@ -0,0 +1,4 @@ +export enum UserEventType { + SharedVaultItemRemoved = 'shared_vault_item_removed', + RemovedFromSharedVault = 'removed_from_shared_vault', +} diff --git a/packages/responses/src/Domain/index.ts b/packages/responses/src/Domain/index.ts index f926d5b39..4b863fb64 100644 --- a/packages/responses/src/Domain/index.ts +++ b/packages/responses/src/Domain/index.ts @@ -13,7 +13,9 @@ export * from './Auth/SignInData' export * from './Auth/SignInResponse' export * from './Auth/SignOutResponse' export * from './Auth/User' -export * from './Error/ClientError' + +export * from './Error/ClientDisplayableError' + export * from './Files/CloseUploadSessionResponse' export * from './Files/CreateValetTokenPayload' export * from './Files/CreateValetTokenResponse' @@ -21,7 +23,18 @@ export * from './Files/CreateValetTokenResponseData' export * from './Files/DownloadFileChunkResponse' export * from './Files/StartUploadSessionResponse' export * from './Files/UploadFileChunkResponse' +export * from './Files/MoveFileResponse' +export * from './Files/ValetTokenOperation' + export * from './Http' + +export * from './SharedVaults/SharedVaultInviteServerHash' +export * from './SharedVaults/SharedVaultUserServerHash' +export * from './SharedVaults/SharedVaultServerHash' +export * from './SharedVaults/SharedVaultPermission' + +export * from './AsymmetricMessage/AsymmetricMessageServerHash' + export * from './Item/ApiEndpointParam' export * from './Item/CheckIntegrityResponse' export * from './Item/ConflictParams' @@ -31,11 +44,13 @@ export * from './Item/RawSyncData' export * from './Item/RawSyncResponse' export * from './Item/ServerItemResponse' export * from './Item/IntegrityPayload' + export * from './Listed/ActionResponse' export * from './Listed/ListedAccount' export * from './Listed/ListedAccountInfo' export * from './Listed/ListedAccountInfoResponse' export * from './Listed/ListedRegistrationResponse' + export * from './User/AvailableSubscriptions' export * from './User/DeleteSettingResponse' export * from './User/GetAvailableSubscriptionsResponse' @@ -48,3 +63,7 @@ export * from './User/SettingData' export * from './User/UpdateSettingResponse' export * from './User/UserFeaturesData' export * from './User/UserFeaturesResponse' + +export * from './UserEvent/UserEventServerHash' +export * from './UserEvent/UserEventType' +export * from './UserEvent/UserEventPayload' diff --git a/packages/services/package.json b/packages/services/package.json index 593d83a7c..3839a8577 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -11,13 +11,13 @@ "license": "AGPL-3.0-or-later", "scripts": { "tsc": "tsc --project tsconfig.json", - "lint": "eslint src --ext .ts", + "lint": "eslint src --ext .ts && yarn tsc", "lint:fix": "eslint src --ext .ts --fix", - "test": "jest --coverage" + "test": "jest" }, "dependencies": { "@standardnotes/api": "workspace:^", - "@standardnotes/common": "^1.46.4", + "@standardnotes/common": "^1.48.3", "@standardnotes/domain-core": "^1.12.0", "@standardnotes/encryption": "workspace:^", "@standardnotes/features": "workspace:^", diff --git a/packages/services/src/Domain/Alert/AlertService.ts b/packages/services/src/Domain/Alert/AlertService.ts index ec4b4bcdf..5c305fc97 100644 --- a/packages/services/src/Domain/Alert/AlertService.ts +++ b/packages/services/src/Domain/Alert/AlertService.ts @@ -18,8 +18,18 @@ export abstract class AlertService { cancelButtonText?: string, ): Promise + abstract confirmV2(dto: { + text: string + title?: string + confirmButtonText?: string + confirmButtonType?: ButtonType + cancelButtonText?: string + }): Promise + abstract alert(text: string, title?: string, closeButtonText?: string): Promise + abstract alertV2(dto: { text: string; title?: string; closeButtonText?: string }): Promise + abstract blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise showErrorAlert(error: ClientDisplayableError): Promise { diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 3a8f4ce83..bcd7c4bc2 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -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 createEncryptedBackupFile(): Promise @@ -32,7 +46,7 @@ export interface ApplicationInterface { lock(): Promise softLockBiometrics(): void setValue(key: string, value: unknown, mode?: StorageValueModes): void - getValue(key: string, mode?: StorageValueModes): unknown + getValue(key: string, mode?: StorageValueModes): T removeValue(key: string, mode?: StorageValueModes): Promise isLocked(): Promise getPreference(key: K): PrefValue[K] | undefined @@ -44,15 +58,42 @@ export interface ApplicationInterface { stream: ItemStream, ): () => void hasAccount(): boolean + + importData(data: BackupFile, awaitSync?: boolean): Promise + /** + * Mutates a pre-existing item, marks it as dirty, and syncs it + */ + changeAndSaveItem( + itemToLookupUuidFor: DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + emitSource?: PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise + + /** + * Mutates pre-existing items, marks them as dirty, and syncs + */ + changeAndSaveItems( + itemsToLookupUuidsFor: DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + emitSource?: PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise + 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 diff --git a/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.spec.ts b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.spec.ts new file mode 100644 index 000000000..f85892890 --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.spec.ts @@ -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 + http.delete = jest.fn() + + const encryption = {} as jest.Mocked + const contacts = {} as jest.Mocked + const items = {} as jest.Mocked + const sync = {} as jest.Mocked + const mutator = {} as jest.Mocked + + const eventBus = {} as jest.Mocked + 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]) + }) +}) diff --git a/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts new file mode 100644 index 000000000..40d1f2fc7 --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/AsymmetricMessageService.ts @@ -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 { + 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 { + const usecase = new GetOutboundAsymmetricMessages(this.messageServer) + return usecase.execute() + } + + public async getInboundMessages(): Promise { + const usecase = new GetInboundAsymmetricMessages(this.messageServer) + return usecase.execute() + } + + async sendOwnContactChangeEventToAllContacts(data: UserKeyPairChangedEventData): Promise { + 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 { + 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 { + await this.messageServer.deleteMessage({ messageUuid: message.uuid }) + } + + async handleVaultMetadataChangedMessage( + _message: AsymmetricMessageServerHash, + trustedPayload: AsymmetricMessageSharedVaultMetadataChanged, + ): Promise { + const vault = new GetVaultUseCase(this.items).execute({ sharedVaultUuid: trustedPayload.data.sharedVaultUuid }) + if (!vault) { + return + } + + await this.mutator.changeItem(vault, (mutator) => { + mutator.name = trustedPayload.data.name + mutator.description = trustedPayload.data.description + }) + } + + async handleTrustedContactShareMessage( + _message: AsymmetricMessageServerHash, + trustedPayload: AsymmetricMessageTrustedContactShare, + ): Promise { + await this.contacts.createOrUpdateTrustedContactFromContactShare(trustedPayload.data.trustedContact) + } + + private async handleTrustedSenderKeypairChangedMessage( + message: AsymmetricMessageServerHash, + trustedPayload: AsymmetricMessageSenderKeypairChanged, + ): Promise { + await this.contacts.createOrEditTrustedContact({ + contactUuid: message.sender_uuid, + publicKey: trustedPayload.data.newEncryptionPublicKey, + signingPublicKey: trustedPayload.data.newSigningPublicKey, + }) + } + + private async handleTrustedSharedVaultRootKeyChangedMessage( + _message: AsymmetricMessageServerHash, + trustedPayload: AsymmetricMessageSharedVaultRootKeyChanged, + ): Promise { + const useCase = new HandleTrustedSharedVaultRootKeyChangedMessage( + this.mutator, + this.items, + this.sync, + this.encryption, + ) + await useCase.execute(trustedPayload) + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageTrustedPayload.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageTrustedPayload.ts new file mode 100644 index 000000000..99258e02e --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageTrustedPayload.ts @@ -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 { + 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({ + encryptedString: dto.message.encrypted_message, + trustedSender: trustedContact, + privateKey: dto.privateKey, + }) + + return decryptionResult + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageUntrustedPayload.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageUntrustedPayload.ts new file mode 100644 index 000000000..a69d228cb --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetAsymmetricMessageUntrustedPayload.ts @@ -0,0 +1,17 @@ +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { AsymmetricMessageServerHash } from '@standardnotes/responses' +import { AsymmetricMessagePayload } from '@standardnotes/models' + +export class GetAsymmetricMessageUntrustedPayload { + constructor(private encryption: EncryptionProviderInterface) {} + + execute(dto: { privateKey: string; message: AsymmetricMessageServerHash }): M | undefined { + const decryptionResult = this.encryption.asymmetricallyDecryptMessage({ + encryptedString: dto.message.encrypted_message, + trustedSender: undefined, + privateKey: dto.privateKey, + }) + + return decryptionResult + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/GetInboundAsymmetricMessages.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetInboundAsymmetricMessages.ts new file mode 100644 index 000000000..91bd668ae --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetInboundAsymmetricMessages.ts @@ -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 { + const response = await this.messageServer.getMessages() + + if (isErrorResponse(response)) { + return ClientDisplayableError.FromError(response.data.error) + } + + return response.data.messages + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/GetOutboundAsymmetricMessages.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetOutboundAsymmetricMessages.ts new file mode 100644 index 000000000..5ee97691a --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/GetOutboundAsymmetricMessages.ts @@ -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 { + const response = await this.messageServer.getOutboundUserMessages() + + if (isErrorResponse(response)) { + return ClientDisplayableError.FromError(response.data.error) + } + + return response.data.messages + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.spec.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.spec.ts new file mode 100644 index 000000000..c789c835f --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.spec.ts @@ -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 + let syncServiceMock: jest.Mocked + let contactServiceMock: jest.Mocked + + 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, + metadata: { + name: 'test-name', + }, + trustedContacts: [], + }, + } as jest.Mocked + + 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) + }) +}) diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.ts new file mode 100644 index 000000000..1a3cebdfb --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage.ts @@ -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 { + 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( + ContentType.KeySystemRootKey, + FillItemContent(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' }) + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultRootKeyChangedMessage.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultRootKeyChangedMessage.ts new file mode 100644 index 000000000..1bc493b84 --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/HandleTrustedSharedVaultRootKeyChangedMessage.ts @@ -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 { + const rootKeyContent = message.data.rootKey + + await this.mutator.createItem( + ContentType.KeySystemRootKey, + FillItemContent(rootKeyContent), + true, + ) + + const vault = new GetVaultUseCase(this.items).execute({ keySystemIdentifier: rootKeyContent.systemIdentifier }) + if (vault) { + await this.mutator.changeItem(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' }) + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase.ts new file mode 100644 index 000000000..175903e27 --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/AsymmetricMessage/UseCase/SendOwnContactChangeMessage.ts b/packages/services/src/Domain/AsymmetricMessage/UseCase/SendOwnContactChangeMessage.ts new file mode 100644 index 000000000..3c3fe9efc --- /dev/null +++ b/packages/services/src/Domain/AsymmetricMessage/UseCase/SendOwnContactChangeMessage.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/Backups/BackupService.spec.ts b/packages/services/src/Domain/Backups/BackupService.spec.ts index adcdbdea1..892742077 100644 --- a/packages/services/src/Domain/Backups/BackupService.spec.ts +++ b/packages/services/src/Domain/Backups/BackupService.spec.ts @@ -32,16 +32,13 @@ describe('backup service', () => { beforeEach(() => { apiService = {} as jest.Mocked 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 - 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 diff --git a/packages/services/src/Domain/Backups/BackupService.ts b/packages/services/src/Domain/Backups/BackupService.ts index 7e80d84fd..a19695159 100644 --- a/packages/services/src/Domain/Backups/BackupService.ts +++ b/packages/services/src/Domain/Backups/BackupService.ts @@ -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, }) diff --git a/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts b/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts index 11aa4b380..10020248e 100644 --- a/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts +++ b/packages/services/src/Domain/Challenge/ChallengeServiceInterface.ts @@ -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 + promptForAccountPassword(): Promise 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 } diff --git a/packages/services/src/Domain/Challenge/Types/ChallengeObserver.ts b/packages/services/src/Domain/Challenge/Types/ChallengeObserver.ts new file mode 100644 index 000000000..3390180ce --- /dev/null +++ b/packages/services/src/Domain/Challenge/Types/ChallengeObserver.ts @@ -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 +} diff --git a/packages/services/src/Domain/Challenge/Types/ChallengeValueCallback.ts b/packages/services/src/Domain/Challenge/Types/ChallengeValueCallback.ts new file mode 100644 index 000000000..d6bd685f3 --- /dev/null +++ b/packages/services/src/Domain/Challenge/Types/ChallengeValueCallback.ts @@ -0,0 +1,3 @@ +import { ChallengeValue } from './ChallengeValue' + +export type ChallengeValueCallback = (value: ChallengeValue) => void diff --git a/packages/services/src/Domain/Challenge/index.ts b/packages/services/src/Domain/Challenge/index.ts index 20cf444bc..e5e200006 100644 --- a/packages/services/src/Domain/Challenge/index.ts +++ b/packages/services/src/Domain/Challenge/index.ts @@ -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' diff --git a/packages/services/src/Domain/Component/ComponentManagerInterface.ts b/packages/services/src/Domain/Component/ComponentManagerInterface.ts index ad19279a8..8ae47a091 100644 --- a/packages/services/src/Domain/Component/ComponentManagerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentManagerInterface.ts @@ -21,4 +21,6 @@ export interface ComponentManagerInterface { presentPermissionsDialog(_dialog: PermissionDialog): void legacyGetDefaultEditor(): SNComponent | undefined componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined + toggleTheme(uuid: string): Promise + toggleComponent(uuid: string): Promise } diff --git a/packages/services/src/Domain/Contacts/CollaborationID.ts b/packages/services/src/Domain/Contacts/CollaborationID.ts new file mode 100644 index 000000000..580344c38 --- /dev/null +++ b/packages/services/src/Domain/Contacts/CollaborationID.ts @@ -0,0 +1,8 @@ +export const Version1CollaborationId = '1' + +export type CollaborationIDData = { + version: string + userUuid: string + publicKey: string + signingPublicKey: string +} diff --git a/packages/services/src/Domain/Contacts/ContactService.ts b/packages/services/src/Domain/Contacts/ContactService.ts new file mode 100644 index 000000000..0d139583a --- /dev/null +++ b/packages/services/src/Domain/Contacts/ContactService.ts @@ -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 + 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 { + await super.handleApplicationStage(stage) + await this.selfContactManager.handleApplicationStage(stage) + } + + async handleEvent(event: InternalEventInterface): Promise { + 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 { + 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 { + 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 { + 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( + 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 { + const usecase = new UpdateTrustedContactUseCase(this.mutator, this.sync) + const updatedContact = await usecase.execute(contact, params) + + return updatedContact + } + + async createOrUpdateTrustedContactFromContactShare( + data: TrustedContactContentSpecialized, + ): Promise { + 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(contact, (mutator) => { + mutator.name = data.name + mutator.replacePublicKeySet(data.publicKeySet) + }) + } else { + contact = await this.mutator.createItem( + ContentType.TrustedContact, + FillItemContent(data), + true, + ) + } + + await this.sync.sync() + + return contact + } + + async createOrEditTrustedContact(params: { + name?: string + contactUuid: string + publicKey: string + signingPublicKey: string + isMe?: boolean + }): Promise { + const usecase = new CreateOrEditTrustedContactUseCase(this.items, this.mutator, this.sync) + const contact = await usecase.execute(params) + return contact + } + + async deleteContact(contact: TrustedContactInterface): Promise { + 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 + } +} diff --git a/packages/services/src/Domain/Contacts/ContactServiceInterface.ts b/packages/services/src/Domain/Contacts/ContactServiceInterface.ts new file mode 100644 index 000000000..6a24825bf --- /dev/null +++ b/packages/services/src/Domain/Contacts/ContactServiceInterface.ts @@ -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 { + isCollaborationEnabled(): boolean + enableCollaboration(): Promise + getCollaborationID(): string + getCollaborationIDFromInvite(invite: SharedVaultInviteServerHash): string + addTrustedContactFromCollaborationID( + collaborationID: string, + name?: string, + ): Promise + getCollaborationIDForTrustedContact(contact: TrustedContactInterface): string + + createOrEditTrustedContact(params: { + contactUuid: string + name?: string + publicKey: string + signingPublicKey: string + }): Promise + createOrUpdateTrustedContactFromContactShare(data: TrustedContactContentSpecialized): Promise + editTrustedContactFromCollaborationID( + contact: TrustedContactInterface, + params: { name: string; collaborationID: string }, + ): Promise + deleteContact(contact: TrustedContactInterface): Promise + + getAllContacts(): TrustedContactInterface[] + getSelfContact(): TrustedContactInterface | undefined + findTrustedContact(userUuid: string): TrustedContactInterface | undefined + findTrustedContactForServerUser(user: SharedVaultUserServerHash): TrustedContactInterface | undefined + findTrustedContactForInvite(invite: SharedVaultInviteServerHash): TrustedContactInterface | undefined + + isItemAuthenticallySigned(item: DecryptedItemInterface): ValidateItemSignerResult +} diff --git a/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts b/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts new file mode 100644 index 000000000..ace8c11fe --- /dev/null +++ b/packages/services/src/Domain/Contacts/Managers/SelfContactManager.ts @@ -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 { + if (stage === ApplicationStage.LoadedDatabase_12) { + this.selfContact = this.singletons.findSingleton( + 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( + TrustedContact.singletonPredicate, + ContentType.TrustedContact, + FillItemContent(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 + } +} diff --git a/packages/services/src/Domain/Contacts/UnknownContactName.ts b/packages/services/src/Domain/Contacts/UnknownContactName.ts new file mode 100644 index 000000000..5e112bf51 --- /dev/null +++ b/packages/services/src/Domain/Contacts/UnknownContactName.ts @@ -0,0 +1 @@ +export const UnknownContactName = 'Unnamed contact' diff --git a/packages/services/src/Domain/Contacts/UseCase/CreateOrEditTrustedContact.ts b/packages/services/src/Domain/Contacts/UseCase/CreateOrEditTrustedContact.ts new file mode 100644 index 000000000..27940adba --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/CreateOrEditTrustedContact.ts @@ -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 { + 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( + ContentType.TrustedContact, + FillItemContent(content), + true, + ) + + await this.sync.sync() + + return contact + } +} diff --git a/packages/services/src/Domain/Contacts/UseCase/FindContactQuery.ts b/packages/services/src/Domain/Contacts/UseCase/FindContactQuery.ts new file mode 100644 index 000000000..31a2bf652 --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/FindContactQuery.ts @@ -0,0 +1 @@ +export type FindContactQuery = { userUuid: string } | { signingPublicKey: string } | { publicKey: string } diff --git a/packages/services/src/Domain/Contacts/UseCase/FindTrustedContact.ts b/packages/services/src/Domain/Contacts/UseCase/FindTrustedContact.ts new file mode 100644 index 000000000..4240fd852 --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/FindTrustedContact.ts @@ -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( + ContentType.TrustedContact, + new Predicate('contactUuid', '=', query.userUuid), + )[0] + } + + if ('signingPublicKey' in query && query.signingPublicKey) { + const allContacts = this.items.getItems(ContentType.TrustedContact) + return allContacts.find((contact) => contact.isSigningKeyTrusted(query.signingPublicKey)) + } + + if ('publicKey' in query && query.publicKey) { + const allContacts = this.items.getItems(ContentType.TrustedContact) + return allContacts.find((contact) => contact.isPublicKeyTrusted(query.publicKey)) + } + + throw new Error('Invalid query') + } +} diff --git a/packages/services/src/Domain/Contacts/UseCase/UpdateTrustedContact.ts b/packages/services/src/Domain/Contacts/UseCase/UpdateTrustedContact.ts new file mode 100644 index 000000000..a071b1bba --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/UpdateTrustedContact.ts @@ -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 { + const updatedContact = await this.mutator.changeItem( + 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 + } +} diff --git a/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.spec.ts b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.spec.ts new file mode 100644 index 000000000..6c6c5aa5a --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.spec.ts @@ -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 + trustedContact.isSigningKeyTrusted = jest.fn().mockReturnValue(true) + + beforeEach(() => { + items = {} as jest.Mocked + 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 => { + const payload = { + source: params.source ?? PayloadSource.RemoteRetrieved, + } as jest.Mocked + + 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 + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + items.getItems = jest.fn().mockReturnValue([trustedContact]) + + const result = usecase.execute(item) + expect(result).toEqual('yes') + }) + }) + }) + }) +}) diff --git a/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.ts b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.ts new file mode 100644 index 000000000..3d5449d1f --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSigner.ts @@ -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' + } +} diff --git a/packages/services/src/Domain/Contacts/UseCase/ValidateItemSignerResult.ts b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSignerResult.ts new file mode 100644 index 000000000..6d28bd992 --- /dev/null +++ b/packages/services/src/Domain/Contacts/UseCase/ValidateItemSignerResult.ts @@ -0,0 +1 @@ +export type ValidateItemSignerResult = 'not-applicable' | 'yes' | 'no' diff --git a/packages/services/src/Domain/Device/DatabaseLoadOptions.ts b/packages/services/src/Domain/Device/DatabaseLoadOptions.ts index daad481b8..cebd73e11 100644 --- a/packages/services/src/Domain/Device/DatabaseLoadOptions.ts +++ b/packages/services/src/Domain/Device/DatabaseLoadOptions.ts @@ -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 diff --git a/packages/services/src/Domain/Device/DatabaseLoadSorter.ts b/packages/services/src/Domain/Device/DatabaseLoadSorter.ts index 6aa874680..dfdede807 100644 --- a/packages/services/src/Domain/Device/DatabaseLoadSorter.ts +++ b/packages/services/src/Domain/Device/DatabaseLoadSorter.ts @@ -83,17 +83,25 @@ export function GetSortedPayloadsByPriority { - 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[] = [] - const encryptedPayloads: EncryptedPayloadInterface[] = [] - - allPayloads.forEach((payload) => { - if (payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload)) { - decryptedItemsKeys.push(payload as DecryptedPayloadInterface) - } else if (isEncryptedPayload(payload)) { - encryptedPayloads.push(payload) - } - }) - - const itemsKeys = decryptedItemsKeys.map((p) => CreateDecryptedItemFromPayload(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 -} diff --git a/packages/services/src/Domain/Encryption/DecryptBackupFileUseCase.ts b/packages/services/src/Domain/Encryption/DecryptBackupFileUseCase.ts new file mode 100644 index 000000000..ec3dc5d7e --- /dev/null +++ b/packages/services/src/Domain/Encryption/DecryptBackupFileUseCase.ts @@ -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 { + 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[] = [] + const encryptedPayloads: EncryptedPayloadInterface[] = [] + + payloads.forEach((payload) => { + if (payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload)) { + decryptedItemsKeys.push(payload as DecryptedPayloadInterface) + } else if (isEncryptedPayload(payload)) { + encryptedPayloads.push(payload) + } + }) + + const itemsKeys = decryptedItemsKeys.map((p) => CreateDecryptedItemFromPayload(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 + } +} diff --git a/packages/services/src/Domain/Encryption/EncryptionService.ts b/packages/services/src/Domain/Encryption/EncryptionService.ts index bb1da6216..0892f544f 100644 --- a/packages/services/src/Domain/Encryption/EncryptionService.ts +++ b/packages/services/src/Domain/Encryption/EncryptionService.ts @@ -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 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 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 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 i return this.itemsEncryption.repersistAllItems() } - public async reencryptItemsKeys(): Promise { - await this.rootKeyEncryption.reencryptItemsKeys() + public async reencryptApplicableItemsAfterUserRootKeyChange(): Promise { + await this.rootKeyEncryption.reencryptApplicableItemsAfterUserRootKeyChange() + } + + public reencryptKeySystemItemsKeysForVault(keySystemIdentifier: KeySystemIdentifier): Promise { + return this.rootKeyEncryption.reencryptKeySystemItemsKeysForVault(keySystemIdentifier) } public async createNewItemsKeyWithRollback(): Promise<() => Promise> { @@ -222,11 +269,14 @@ export class EncryptionService extends AbstractService i } public async decryptErroredPayloads(): Promise { - 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 i } public async encryptSplit(split: KeyedEncryptionSplit): Promise { - 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 i >(split: KeyedDecryptionSplit): Promise<(P | EncryptedPayloadInterface)[]> { const resultParams: (DecryptedParameters | ErrorDecryptingParameters)[] = [] - if (split.usesRootKey) { - const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads( - split.usesRootKey.items, - split.usesRootKey.key, - ) + const { + usesRootKey, + usesItemsKey, + usesKeySystemRootKey, + usesRootKeyWithKeyLookup, + usesItemsKeyWithKeyLookup, + usesKeySystemRootKeyWithKeyLookup, + } = split + + if (usesRootKey) { + const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads(usesRootKey.items, usesRootKey.key) extendArray(resultParams, rootKeyDecrypted) } - if (split.usesRootKeyWithKeyLookup) { + if (usesRootKeyWithKeyLookup) { const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloadsWithKeyLookup( - split.usesRootKeyWithKeyLookup.items, + usesRootKeyWithKeyLookup.items, ) extendArray(resultParams, rootKeyDecrypted) } - - if (split.usesItemsKey) { - const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloads( - split.usesItemsKey.items, - split.usesItemsKey.key, + if (usesKeySystemRootKey) { + const keySystemRootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads( + usesKeySystemRootKey.items, + usesKeySystemRootKey.key, ) + extendArray(resultParams, keySystemRootKeyDecrypted) + } + if (usesKeySystemRootKeyWithKeyLookup) { + const keySystemRootKeyDecrypted = await this.rootKeyEncryption.decryptPayloadsWithKeyLookup( + usesKeySystemRootKeyWithKeyLookup.items, + ) + extendArray(resultParams, keySystemRootKeyDecrypted) + } + + if (usesItemsKey) { + const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloads(usesItemsKey.items, usesItemsKey.key) extendArray(resultParams, itemsKeyDecrypted) } - if (split.usesItemsKeyWithKeyLookup) { + if (usesItemsKeyWithKeyLookup) { const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloadsWithKeyLookup( - split.usesItemsKeyWithKeyLookup.items, + usesItemsKeyWithKeyLookup.items, ) extendArray(resultParams, itemsKeyDecrypted) } @@ -349,6 +447,36 @@ export class EncryptionService extends AbstractService i return packagedResults } + async decryptPayloadWithKeyLookup< + C extends ItemContent = ItemContent, + P extends DecryptedPayloadInterface = DecryptedPayloadInterface, + >( + payload: EncryptedPayloadInterface, + ): Promise<{ + parameters: DecryptedParameters | ErrorDecryptingParameters + payload: P | EncryptedPayloadInterface + }> { + const decryptedParameters = await this.itemsEncryption.decryptPayloadWithKeyLookup(payload) + + if (isErrorDecryptingParameters(decryptedParameters)) { + return { + parameters: decryptedParameters, + payload: new EncryptedPayload({ + ...payload.ejected(), + ...decryptedParameters, + }), + } + } else { + return { + parameters: decryptedParameters, + payload: new DecryptedPayload({ + ...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 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 { + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { return this.rootKeyEncryption.computeRootKey(password, keyParams) } /** * Creates a root key using the latest protocol version */ - public async createRootKey( + public async createRootKey( identifier: string, password: string, origination: KeyParamsOrigination, version?: ProtocolVersion, - ) { + ): Promise { 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(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)[]> { - 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 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 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 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 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 i return this.rootKeyEncryption.validatePasscode(passcode) } - public getEmbeddedPayloadAuthenticatedData( + public getEmbeddedPayloadAuthenticatedData( 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 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 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 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 { - 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) } } } diff --git a/packages/services/src/Domain/Encryption/Functions.ts b/packages/services/src/Domain/Encryption/Functions.ts index 41e464e91..aa3ef03e8 100644 --- a/packages/services/src/Domain/Encryption/Functions.ts +++ b/packages/services/src/Domain/Encryption/Functions.ts @@ -49,7 +49,7 @@ export async function DecryptItemsKeyByPromptingUser( | 'aborted' > { if (!keyParams) { - keyParams = encryptor.getKeyEmbeddedKeyParams(itemsKey) + keyParams = encryptor.getKeyEmbeddedKeyParamsFromItemsKey(itemsKey) } if (!keyParams) { diff --git a/packages/services/src/Domain/Encryption/ItemsEncryption.ts b/packages/services/src/Domain/Encryption/ItemsEncryption.ts index 9e1f6a124..d7475446f 100644 --- a/packages/services/src/Domain/Encryption/ItemsEncryption.ts +++ b/packages/services/src/Domain/Encryption/ItemsEncryption.ts @@ -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(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 { - const key = this.keyToUseForItemEncryption() + public async encryptPayloadWithKeyLookup( + payload: DecryptedPayloadInterface, + signingKeyPair?: PkcKeyPair, + ): Promise { + 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 { + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): Promise { 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 { - return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key))) + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): Promise { + return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key, signingKeyPair))) } - public async encryptPayloadsWithKeyLookup(payloads: DecryptedPayloadInterface[]): Promise { - return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload))) + public async encryptPayloadsWithKeyLookup( + payloads: DecryptedPayloadInterface[], + signingKeyPair?: PkcKeyPair, + ): Promise { + return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload, signingKeyPair))) } public async decryptPayloadWithKeyLookup( @@ -173,7 +206,7 @@ export class ItemsEncryptionService extends AbstractService { public async decryptPayload( payload: EncryptedPayloadInterface, - key: ItemsKeyInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface, ): Promise | ErrorDecryptingParameters> { if (!payload.content) { return { @@ -193,21 +226,24 @@ export class ItemsEncryptionService extends AbstractService { public async decryptPayloads( payloads: EncryptedPayloadInterface[], - key: ItemsKeyInterface, + key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface, ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { return Promise.all(payloads.map((payload) => this.decryptPayload(payload, key))) } - public async decryptErroredPayloads(): Promise { - const payloads = this.payloadManager.invalidPayloads.filter((i) => i.content_type !== ContentType.ItemsKey) - if (payloads.length === 0) { + public async decryptErroredItemPayloads(): Promise { + 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 { - const keyForItems = this.keyToUseForItemEncryption() - return { - itemsEncryption: { - itemsKeysIds: Uuids(this.getItemsKeys()), - defaultItemsKeyId: this.getDefaultItemsKey()?.uuid, - keyToUseForItemEncryptionId: keyForItems instanceof StandardException ? undefined : keyForItems.uuid, - }, - } - } } diff --git a/packages/services/src/Domain/Encryption/RootKeyEncryption.ts b/packages/services/src/Domain/Encryption/RootKeyEncryption.ts index 67e97f57e..c5994840f 100644 --- a/packages/services/src/Domain/Encryption/RootKeyEncryption.ts +++ b/packages/services/src/Domain/Encryption/RootKeyEncryption.ts @@ -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 { private rootKey?: RootKeyInterface @@ -60,10 +69,13 @@ export class RootKeyEncryptionService extends AbstractService { - 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 + public getSureRootKeyWrapperKeyParams() { + return this.getRootKeyWrapperKeyParams() as SNRootKeyParams } - public async getRootKeyParams(): Promise { + 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 { - return this.getRootKeyParams() as Promise + public getSureRootKeyParams(): SNRootKeyParams { + return this.getRootKeyParams() as SNRootKeyParams } - public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { const version = keyParams.version const operator = this.operatorManager.operatorForVersion(version) return operator.computeRootKey(password, keyParams) } - public async createRootKey( + public async createRootKey( identifier: string, password: string, origination: KeyParamsOrigination, version?: ProtocolVersion, - ) { + ): Promise { 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 { - 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 { - const key = this.getRootKey() + private async encryptPayloadWithKeyLookup( + payload: DecryptedPayloadInterface, + signingKeyPair?: PkcKeyPair, + ): Promise { + 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 { - return Promise.all(payloads.map((payload) => this.encrypPayloadWithKeyLookup(payload))) + public async encryptPayloadsWithKeyLookup( + payloads: DecryptedPayloadInterface[], + signingKeyPair?: PkcKeyPair, + ): Promise { + return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload, signingKeyPair))) } - public async encryptPayload(payload: DecryptedPayloadInterface, key: RootKeyInterface): Promise { - return encryptPayload(payload, key, this.operatorManager) + public async encryptPayload( + payload: DecryptedPayloadInterface, + key: RootKeyInterface | KeySystemRootKeyInterface, + signingKeyPair?: PkcKeyPair, + ): Promise { + 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( payload: EncryptedPayloadInterface, ): Promise | 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( payload: EncryptedPayloadInterface, - key: RootKeyInterface, + key: RootKeyInterface | KeySystemRootKeyInterface, ): Promise | ErrorDecryptingParameters> { return decryptPayload(payload, key, this.operatorManager) } @@ -543,25 +590,63 @@ export class RootKeyEncryptionService extends AbstractService( payloads: EncryptedPayloadInterface[], - key: RootKeyInterface, + key: RootKeyInterface | KeySystemRootKeyInterface, ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { return Promise.all(payloads.map((payload) => this.decryptPayload(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 { - const itemsKeys = this.getItemsKeys() + public async decryptErroredRootPayloads(): Promise { + 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 { + 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 { + const keySystemItemsKeys = this.keys.getKeySystemItemsKeys(keySystemIdentifier) + if (keySystemItemsKeys.length > 0) { + await this.mutator.setItemsDirty(keySystemItemsKeys) } } @@ -599,14 +684,13 @@ export class RootKeyEncryptionService extends AbstractService { + 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(itemTemplate) + await this.mutator.changeItemsKey(itemsKey, (mutator) => { mutator.isDefault = true }) @@ -618,10 +702,10 @@ export class RootKeyEncryptionService extends AbstractService { - await this.itemManager.setItemToBeDeleted(newDefaultItemsKey) + await this.mutator.setItemToBeDeleted(newDefaultItemsKey) if (currentDefaultItemsKey) { - await this.itemManager.changeItem(currentDefaultItemsKey, (mutator) => { + await this.mutator.changeItem(currentDefaultItemsKey, (mutator) => { mutator.isDefault = true }) } @@ -629,19 +713,4 @@ export class RootKeyEncryptionService extends AbstractService { - 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(), - }, - } - } } diff --git a/packages/services/src/Domain/Event/ApplicationEvent.ts b/packages/services/src/Domain/Event/ApplicationEvent.ts index df4d8d7b3..b30f3dd43 100644 --- a/packages/services/src/Domain/Event/ApplicationEvent.ts +++ b/packages/services/src/Domain/Event/ApplicationEvent.ts @@ -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 } diff --git a/packages/services/src/Domain/Event/SyncEvent.ts b/packages/services/src/Domain/Event/SyncEvent.ts index 6e6ff3dc4..3fddfa3a7 100644 --- a/packages/services/src/Domain/Event/SyncEvent.ts +++ b/packages/services/src/Domain/Event/SyncEvent.ts @@ -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[] diff --git a/packages/services/src/Domain/Files/FileService.spec.ts b/packages/services/src/Domain/Files/FileService.spec.ts index f17e797df..49b674484 100644 --- a/packages/services/src/Domain/Files/FileService.spec.ts +++ b/packages/services/src/Domain/Files/FileService.spec.ts @@ -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 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((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 - 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 + mutator.createItem = jest.fn() + mutator.setItemToBeDeleted = jest.fn() + mutator.changeItem = jest.fn() challengor = {} as jest.Mocked @@ -75,12 +81,15 @@ describe('fileService', () => { backupService.readEncryptedFileFromBackup = jest.fn() backupService.getFileBackupInfo = jest.fn() + http = {} as jest.Mocked + fileService = new FileService( apiService, - itemManager, + mutator, syncService, encryptor, challengor, + http, alertService, crypto, internalEventBus, @@ -152,7 +161,7 @@ describe('fileService', () => { } as jest.Mocked 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 } }) diff --git a/packages/services/src/Domain/Files/FileService.ts b/packages/services/src/Domain/Files/FileService.ts index 6483cc787..87a311b0c 100644 --- a/packages/services/src/Domain/Files/FileService.ts +++ b/packages/services/src/Domain/Files/FileService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { 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 { - 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( + const file = await this.mutator.createItem( 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 => { if (addToCache) { @@ -235,13 +371,20 @@ export class FileService extends AbstractService implements FilesClientInterface public async deleteFile(file: FileItem): Promise { 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 diff --git a/packages/services/src/Domain/InternalFeatures/InternalFeature.ts b/packages/services/src/Domain/InternalFeatures/InternalFeature.ts new file mode 100644 index 000000000..1dfa14b22 --- /dev/null +++ b/packages/services/src/Domain/InternalFeatures/InternalFeature.ts @@ -0,0 +1,3 @@ +export enum InternalFeature { + Vaults = 'vaults', +} diff --git a/packages/services/src/Domain/InternalFeatures/InternalFeatureService.ts b/packages/services/src/Domain/InternalFeatures/InternalFeatureService.ts new file mode 100644 index 000000000..e30dd2ae5 --- /dev/null +++ b/packages/services/src/Domain/InternalFeatures/InternalFeatureService.ts @@ -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 = 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) + } +} diff --git a/packages/services/src/Domain/InternalFeatures/InternalFeatureServiceInterface.ts b/packages/services/src/Domain/InternalFeatures/InternalFeatureServiceInterface.ts new file mode 100644 index 000000000..8c363334f --- /dev/null +++ b/packages/services/src/Domain/InternalFeatures/InternalFeatureServiceInterface.ts @@ -0,0 +1,6 @@ +import { InternalFeature } from './InternalFeature' + +export interface InternalFeatureServiceInterface { + isFeatureEnabled(feature: InternalFeature): boolean + enableFeature(feature: InternalFeature): void +} diff --git a/packages/services/src/Domain/Item/ItemCounterInterface.ts b/packages/services/src/Domain/Item/ItemCounterInterface.ts deleted file mode 100644 index 4c5647d96..000000000 --- a/packages/services/src/Domain/Item/ItemCounterInterface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SNNote, SNTag, ItemCounts } from '@standardnotes/models' - -export interface ItemCounterInterface { - countNotesAndTags(items: Array): ItemCounts -} diff --git a/packages/services/src/Domain/Item/ItemManagerInterface.ts b/packages/services/src/Domain/Item/ItemManagerInterface.ts index 95fde44fa..45d271151 100644 --- a/packages/services/src/Domain/Item/ItemManagerInterface.ts +++ b/packages/services/src/Domain/Item/ItemManagerInterface.ts @@ -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 void export interface ItemManagerInterface extends AbstractService { + getCollection(): ItemCollection + addObserver( contentType: ContentType | ContentType[], callback: ItemManagerChangeObserverCallback, ): () => void - setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise - setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise - setItemsDirty( - itemsToLookupUuidsFor: DecryptedItemInterface[], - isUserModified?: boolean, - ): Promise + get items(): DecryptedItemInterface[] - insertItem(item: DecryptedItemInterface): Promise - emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise + getItems(contentType: ContentType | ContentType[]): T[] + get invalidItems(): EncryptedItemInterface[] + allTrackedItems(): ItemInterface[] getDisplayableItemsKeys(): ItemsKeyInterface[] - createItem( - contentType: ContentType, - content: C, - needsSync?: boolean, - ): Promise + createTemplateItem< C extends ItemContent = ItemContent, I extends DecryptedItemInterface = DecryptedItemInterface, @@ -69,23 +73,7 @@ export interface ItemManagerInterface extends AbstractService { content?: C, override?: Partial>, ): I - changeItem< - M extends DecryptedItemMutator = DecryptedItemMutator, - I extends DecryptedItemInterface = DecryptedItemInterface, - >( - itemToLookupUuidFor: I, - mutate?: (mutator: M) => void, - mutationType?: MutationType, - emitSource?: PayloadEmitSource, - payloadSourceKey?: string, - ): Promise - changeItemsKey( - itemToLookupUuidFor: ItemsKeyInterface, - mutate: (mutator: ItemsKeyMutatorInterface) => void, - mutationType?: MutationType, - emitSource?: PayloadEmitSource, - payloadSourceKey?: string, - ): Promise + itemsMatchingPredicate( contentType: ContentType, predicate: PredicateInterface, @@ -96,12 +84,47 @@ export interface ItemManagerInterface extends AbstractService { ): T[] subItemsMatchingPredicates(items: T[], predicates: PredicateInterface[]): T[] removeAllItemsFromMemory(): Promise + removeItemsLocally(items: AnyItemInterface[]): void getDirtyItems(): (DecryptedItemInterface | DeletedItemInterface)[] getTagLongTitle(tag: SNTag): string getSortedTagsForItem(item: DecryptedItemInterface): SNTag[] + itemsReferencingItem( + itemToLookupUuidFor: { uuid: string }, + contentType?: ContentType, + ): I[] referencesForItem( itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType, ): I[] findItem(uuid: string): T | undefined + findItems(uuids: string[]): T[] + findSureItem(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[] } diff --git a/packages/services/src/Domain/Item/ItemsClientInterface.ts b/packages/services/src/Domain/Item/ItemsClientInterface.ts deleted file mode 100644 index 38add2623..000000000 --- a/packages/services/src/Domain/Item/ItemsClientInterface.ts +++ /dev/null @@ -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 - - disassociateFileWithNote(file: FileItem, note: SNNote): Promise - - renameFile(file: FileItem, name: string): Promise - - addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise - - addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise - - /** 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 = DecryptedItemInterface, - >( - contentType: ContentType, - content?: C, - override?: Partial>, - ): I - - get trashedItems(): SNNote[] - - setPrimaryItemDisplayOptions(options: DisplayOptions): void - - getDisplayableNotes(): SNNote[] - - getDisplayableTags(): SNTag[] - - getDisplayableItemsKeys(): ItemsKeyInterface[] - - getDisplayableFiles(): FileItem[] - - getDisplayableNotesAndFiles(): (SNNote | FileItem)[] - - getDisplayableComponents(): (SNComponent | SNTheme)[] - - getItems(contentType: ContentType | ContentType[]): T[] - - insertItem(item: DecryptedItemInterface): Promise - - 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 - linkFileToFile(file: FileItem, otherFile: FileItem): Promise - - unlinkItems( - itemOne: DecryptedItemInterface, - itemTwo: DecryptedItemInterface, - ): Promise> - - /** - * 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): SNTag[] - - isSmartViewTitle(title: string): boolean - - getSmartViews(): SmartView[] - - getNoteCount(): number - - /** - * Finds an item by UUID. - */ - findItem(uuid: string): T | undefined - - /** - * Finds an item by predicate. - */ - findItems(uuids: string[]): T[] - - findSureItem(uuid: string): T - - /** - * Finds an item by predicate. - */ - itemsMatchingPredicate( - contentType: ContentType, - predicate: PredicateInterface, - ): T[] - - /** - * @param item item to be checked - * @returns Whether the item is a template (unmanaged) - */ - isTemplateItem(item: DecryptedItemInterface): boolean - - createSmartView>( - title: string, - predicate: PredicateInterface, - iconString?: string, - ): Promise - - conflictsOf(uuid: string): AnyItemInterface[] - numberOfNotesWithConflicts(): number -} diff --git a/packages/services/src/Domain/Item/ItemCounter.spec.ts b/packages/services/src/Domain/Item/StaticItemCounter.spec.ts similarity index 86% rename from packages/services/src/Domain/Item/ItemCounter.spec.ts rename to packages/services/src/Domain/Item/StaticItemCounter.spec.ts index ac00acc44..f64536991 100644 --- a/packages/services/src/Domain/Item/ItemCounter.spec.ts +++ b/packages/services/src/Domain/Item/StaticItemCounter.spec.ts @@ -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 = [ diff --git a/packages/services/src/Domain/Item/ItemCounter.ts b/packages/services/src/Domain/Item/StaticItemCounter.ts similarity index 85% rename from packages/services/src/Domain/Item/ItemCounter.ts rename to packages/services/src/Domain/Item/StaticItemCounter.ts index 6b678cfae..d8721d12d 100644 --- a/packages/services/src/Domain/Item/ItemCounter.ts +++ b/packages/services/src/Domain/Item/StaticItemCounter.ts @@ -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): ItemCounts { const counts: ItemCounts = { notes: 0, diff --git a/packages/services/src/Domain/KeySystem/KeySystemKeyManager.ts b/packages/services/src/Domain/KeySystem/KeySystemKeyManager.ts new file mode 100644 index 000000000..e6b737c43 --- /dev/null +++ b/packages/services/src/Domain/KeySystem/KeySystemKeyManager.ts @@ -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 = {} + + constructor( + private readonly items: ItemManagerInterface, + private readonly mutator: MutatorClientInterface, + private readonly storage: StorageServiceInterface, + eventBus: InternalEventBusInterface, + ) { + super(eventBus) + } + + public override async handleApplicationStage(stage: ApplicationStage): Promise { + 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>(key), + ) + + const keyPayloads = keyRawPayloads.map((rawPayload) => new DecryptedPayload(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( + ContentType.KeySystemRootKey, + new Predicate('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 { + delete this.rootKeyMemoryCache[systemIdentifier] + + await this.storage.removeValue(this.storageKeyForRootKey(systemIdentifier)) + } + + public async deleteAllSyncedKeySystemRootKeys(systemIdentifier: KeySystemIdentifier): Promise { + 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(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(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] + } +} diff --git a/packages/services/src/Domain/Mutator/ImportDataUseCase.ts b/packages/services/src/Domain/Mutator/ImportDataUseCase.ts new file mode 100644 index 000000000..6812c71bb --- /dev/null +++ b/packages/services/src/Domain/Mutator/ImportDataUseCase.ts @@ -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 { + 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 + 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, + } + } +} diff --git a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts index b8626ad17..131081277 100644 --- a/packages/services/src/Domain/Mutator/MutatorClientInterface.ts +++ b/packages/services/src/Domain/Mutator/MutatorClientInterface.ts @@ -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 + insertItem(item: DecryptedItemInterface, setDirty?: boolean): Promise + emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise - /** - * Mutates a pre-existing item, marks it as dirty, and syncs it - */ - changeAndSaveItem( - itemToLookupUuidFor: DecryptedItemInterface, - mutate: (mutator: M) => void, - updateTimestamps?: boolean, - emitSource?: PayloadEmitSource, - syncOptions?: SyncOptions, - ): Promise - - /** - * Mutates pre-existing items, marks them as dirty, and syncs - */ - changeAndSaveItems( + setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise + setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise + setItemsDirty( itemsToLookupUuidsFor: DecryptedItemInterface[], - mutate: (mutator: M) => void, - updateTimestamps?: boolean, + isUserModified?: boolean, + ): Promise + createItem( + contentType: ContentType, + content: C, + needsSync?: boolean, + vault?: VaultListingInterface, + ): Promise + + changeItem< + M extends DecryptedItemMutator = DecryptedItemMutator, + I extends DecryptedItemInterface = DecryptedItemInterface, + >( + itemToLookupUuidFor: I, + mutate?: (mutator: M) => void, + mutationType?: MutationType, emitSource?: PayloadEmitSource, - syncOptions?: SyncOptions, - ): Promise + payloadSourceKey?: string, + ): Promise + changeItems< + M extends DecryptedItemMutator = DecryptedItemMutator, + I extends DecryptedItemInterface = DecryptedItemInterface, + >( + itemsToLookupUuidsFor: I[], + mutate?: (mutator: M) => void, + mutationType?: MutationType, + emitSource?: PayloadEmitSource, + payloadSourceKey?: string, + ): Promise - /** - * Mutates a pre-existing item and marks it as dirty. Does not sync changes. - */ - changeItem( - itemToLookupUuidFor: DecryptedItemInterface, - mutate: (mutator: M) => void, - updateTimestamps?: boolean, - ): Promise + changeItemsKey( + itemToLookupUuidFor: ItemsKeyInterface, + mutate: (mutator: ItemsKeyMutatorInterface) => void, + mutationType?: MutationType, + emitSource?: PayloadEmitSource, + payloadSourceKey?: string, + ): Promise - /** - * Mutates a pre-existing items and marks them as dirty. Does not sync changes. - */ - changeItems( - 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 + + changeFeatureRepo( + itemToLookupUuidFor: SNFeatureRepo, + mutate: (mutator: FeatureRepoMutator) => void, + mutationType?: MutationType, + emitSource?: PayloadEmitSource, + payloadSourceKey?: string, + ): Promise /** * Run unique mutations per each item in the array, then only propagate all changes @@ -83,44 +105,11 @@ export interface MutatorClientInterface { payloadSourceKey?: string, ): Promise - protectItems<_M extends DecryptedItemMutator, I extends DecryptedItemInterface>( - items: I[], - ): Promise - - unprotectItems<_M extends DecryptedItemMutator, I extends DecryptedItemInterface>( - items: I[], - reason: ChallengeReason, - ): Promise - - protectNote(note: SNNote): Promise - - unprotectNote(note: SNNote): Promise - - protectNotes(notes: SNNote[]): Promise - - unprotectNotes(notes: SNNote[]): Promise - - protectFile(file: FileItem): Promise - - unprotectFile(file: FileItem): Promise - /** * Takes the values of the input item and emits it onto global state. */ mergeItem(item: DecryptedItemInterface, source: PayloadEmitSource): Promise - /** - * Creates an unmanaged item that can be added later. - */ - createTemplateItem< - C extends ItemContent = ItemContent, - I extends DecryptedItemInterface = DecryptedItemInterface, - >( - contentType: ContentType, - content?: C, - override?: Partial>, - ): I - /** * @param isUserModified Whether to change the modified date the user * sees of the item. @@ -135,7 +124,13 @@ export interface MutatorClientInterface { emptyTrash(): Promise - duplicateItem(item: T, additionalContent?: Partial): Promise + duplicateItem( + itemToLookupUuidFor: T, + isConflict?: boolean, + additionalContent?: Partial, + ): Promise + + addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise /** * 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 + setTagParent(parentTag: SNTag, childTag: SNTag): Promise /** * Remove the tag parent. */ - unsetTagParent(childTag: SNTag): Promise + unsetTagParent(childTag: SNTag): Promise - findOrCreateTag(title: string): Promise + findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise /** Creates and returns the tag but does not run sync. Callers must perform sync. */ - createTagOrSmartView(title: string): Promise + createTagOrSmartView(title: string, vault?: VaultListingInterface): Promise + findOrCreateTagParentChain(titlesHierarchy: string[]): Promise - /** - * Activates or deactivates a component, depending on its - * current state, and syncs. - */ - toggleComponent(component: SNComponent): Promise + associateFileWithNote(file: FileItem, note: SNNote): Promise - toggleTheme(theme: SNComponent): Promise + disassociateFileWithNote(file: FileItem, note: SNNote): Promise + renameFile(file: FileItem, name: string): Promise - /** - * @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, + itemB: DecryptedItemInterface, + ): Promise> + createSmartView(dto: { + title: string + predicate: PredicateInterface + iconString?: string + vault?: VaultListingInterface + }): Promise + linkNoteToNote(note: SNNote, otherNote: SNNote): Promise + linkFileToFile(file: FileItem, otherFile: FileItem): Promise + addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise } diff --git a/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts b/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts index d72ce4d06..24b8e2245 100644 --- a/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts +++ b/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts @@ -25,4 +25,6 @@ export interface PayloadManagerInterface { get nonDeletedItems(): FullyFormedPayloadInterface[] importPayloads(payloads: DecryptedPayloadInterface[], historyMap: HistoryMap): Promise + + removePayloadLocally(payload: FullyFormedPayloadInterface | FullyFormedPayloadInterface[]): void } diff --git a/packages/services/src/Domain/Protection/ProtectionClientInterface.ts b/packages/services/src/Domain/Protection/ProtectionClientInterface.ts index a6f5fd1c0..2be79c934 100644 --- a/packages/services/src/Domain/Protection/ProtectionClientInterface.ts +++ b/packages/services/src/Domain/Protection/ProtectionClientInterface.ts @@ -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 authorizeRemovingPasscode(): Promise authorizeChangingPasscode(): Promise + authorizeFileImport(): Promise + protectItems(items: I[]): Promise + unprotectItems(items: I[], reason: ChallengeReason): Promise + protectNote(note: SNNote): Promise + unprotectNote(note: SNNote): Promise + protectNotes(notes: SNNote[]): Promise + unprotectNotes(notes: SNNote[]): Promise + protectFile(file: FileItem): Promise + unprotectFile(file: FileItem): Promise } diff --git a/packages/services/src/Domain/Revision/RevisionClientInterface.ts b/packages/services/src/Domain/Revision/RevisionClientInterface.ts index 95d446cc5..21b6b19bf 100644 --- a/packages/services/src/Domain/Revision/RevisionClientInterface.ts +++ b/packages/services/src/Domain/Revision/RevisionClientInterface.ts @@ -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 - 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 } diff --git a/packages/services/src/Domain/Revision/RevisionManager.ts b/packages/services/src/Domain/Revision/RevisionManager.ts index a3b174dc3..5b6dbd9b0 100644 --- a/packages/services/src/Domain/Revision/RevisionManager.ts +++ b/packages/services/src/Domain/Revision/RevisionManager.ts @@ -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 { const result = await this.revisionApiService.getRevision(itemUuid.value, revisionUuid.value) if (isErrorResponse(result)) { diff --git a/packages/services/src/Domain/Revision/RevisionPayload.ts b/packages/services/src/Domain/Revision/RevisionPayload.ts new file mode 100644 index 000000000..39140cb1f --- /dev/null +++ b/packages/services/src/Domain/Revision/RevisionPayload.ts @@ -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 +} diff --git a/packages/services/src/Domain/Service/AbstractService.ts b/packages/services/src/Domain/Service/AbstractService.ts index 47448a0f3..a6c0aa7f3 100644 --- a/packages/services/src/Domain/Service/AbstractService.ts +++ b/packages/services/src/Domain/Service/AbstractService.ts @@ -8,13 +8,15 @@ import { ApplicationStage } from '../Application/ApplicationStage' import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy' import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics' -export abstract class AbstractService +export abstract class AbstractService implements ServiceInterface { private eventObservers: EventObserver[] = [] public loggingEnabled = false private criticalPromises: Promise[] = [] + protected eventDisposers: (() => void)[] = [] + constructor(protected internalEventBus: InternalEventBusInterface) {} public addEventObserver(observer: EventObserver): () => void { @@ -71,6 +73,11 @@ export abstract class AbstractService this.eventObservers.length = 0 ;(this.internalEventBus as unknown) = undefined ;(this.criticalPromises as unknown) = undefined + + for (const disposer of this.eventDisposers) { + disposer() + } + this.eventDisposers = [] } /** diff --git a/packages/services/src/Domain/Session/SessionEvent.ts b/packages/services/src/Domain/Session/SessionEvent.ts new file mode 100644 index 000000000..aa5ad855b --- /dev/null +++ b/packages/services/src/Domain/Session/SessionEvent.ts @@ -0,0 +1,5 @@ +export enum SessionEvent { + Restored = 'SessionRestored', + Revoked = 'SessionRevoked', + UserKeyPairChanged = 'UserKeyPairChanged', +} diff --git a/packages/services/src/Domain/Session/SessionsClientInterface.ts b/packages/services/src/Domain/Session/SessionsClientInterface.ts index ef61a58a1..fb289bb6f 100644 --- a/packages/services/src/Domain/Session/SessionsClientInterface.ts +++ b/packages/services/src/Domain/Session/SessionsClientInterface.ts @@ -10,7 +10,11 @@ import { SessionManagerResponse } from './SessionManagerResponse' export interface SessionsClientInterface { getWorkspaceDisplayIdentifier(): string populateSessionFromDemoShareToken(token: Base64String): Promise + getUser(): User | undefined + get userUuid(): string + getSureUser(): User + isCurrentSessionReadOnly(): boolean | undefined register(email: string, password: string, ephemeral: boolean): Promise signIn( @@ -20,7 +24,7 @@ export interface SessionsClientInterface { ephemeral: boolean, minAllowedVersion?: ProtocolVersion, ): Promise - getSureUser(): User + isSignedIn(): boolean bypassChecksAndSignInWithRootKey( email: string, rootKey: RootKeyInterface, @@ -42,4 +46,8 @@ export interface SessionsClientInterface { rootKey: SNRootKey wrappingKey?: SNRootKey }): Promise + + getPublicKey(): string + getSigningPublicKey(): string + isUserMissingKeyPair(): boolean } diff --git a/packages/services/src/Domain/Session/UserKeyPairChangedEventData.ts b/packages/services/src/Domain/Session/UserKeyPairChangedEventData.ts new file mode 100644 index 000000000..9a56d6a20 --- /dev/null +++ b/packages/services/src/Domain/Session/UserKeyPairChangedEventData.ts @@ -0,0 +1,9 @@ +import { PkcKeyPair } from '@standardnotes/sncrypto-common' + +export type UserKeyPairChangedEventData = { + oldKeyPair: PkcKeyPair | undefined + oldSigningKeyPair: PkcKeyPair | undefined + + newKeyPair: PkcKeyPair + newSigningKeyPair: PkcKeyPair +} diff --git a/packages/services/src/Domain/SharedVaults/PendingSharedVaultInviteRecord.ts b/packages/services/src/Domain/SharedVaults/PendingSharedVaultInviteRecord.ts new file mode 100644 index 000000000..f2260571e --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/PendingSharedVaultInviteRecord.ts @@ -0,0 +1,8 @@ +import { AsymmetricMessageSharedVaultInvite } from '@standardnotes/models' +import { SharedVaultInviteServerHash } from '@standardnotes/responses' + +export type PendingSharedVaultInviteRecord = { + invite: SharedVaultInviteServerHash + message: AsymmetricMessageSharedVaultInvite + trusted: boolean +} diff --git a/packages/services/src/Domain/SharedVaults/SharedVaultService.ts b/packages/services/src/Domain/SharedVaults/SharedVaultService.ts new file mode 100644 index 000000000..ae3356ce7 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/SharedVaultService.ts @@ -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 + implements SharedVaultServiceInterface, InternalEventHandlerInterface +{ + private server: SharedVaultServerInterface + private usersServer: SharedVaultUsersServerInterface + private invitesServer: SharedVaultInvitesServerInterface + private messageServer: AsymmetricMessageServerInterface + + private pendingInvites: Record = {} + + 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(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(ContentType.VaultListing, ({ changed, source }) => { + if (source === PayloadEmitSource.LocalChanged && changed.length > 0) { + void this.handleVaultListingsChange(changed) + } + }), + ) + } + + async handleEvent(event: InternalEventInterface): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.downloadInboundInvites() + } + + private async handleTrustedContactsChange(contacts: TrustedContactInterface[]): Promise { + await this.reprocessCachedInvitesTrustStatusAfterTrustedContactsChange() + + for (const contact of contacts) { + await this.shareContactWithUserAdministeredSharedVaults(contact) + } + } + + private async handleVaultListingsChange(vaults: VaultListingInterface[]): Promise { + 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 { + 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 { + 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 { + 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 { + const useCase = new DeleteSharedVaultUseCase(this.server, this.items, this.mutator, this.sync, this.encryption) + return useCase.execute({ sharedVault }) + } + + private async reprocessCachedInvitesTrustStatusAfterTrustedContactsChange(): Promise { + const cachedInvites = this.getCachedPendingInviteRecords() + + for (const record of cachedInvites) { + if (record.trusted) { + continue + } + + const trustedMessageUseCase = new GetAsymmetricMessageTrustedPayload( + 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 { + if (invites.length === 0) { + return + } + + for (const invite of invites) { + const trustedMessageUseCase = new GetAsymmetricMessageTrustedPayload( + 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( + 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 { + await this.notifyEventSync(SharedVaultServiceEvent.SharedVaultStatusChanged) + } + + async acceptPendingSharedVaultInvite(pendingInvite: PendingSharedVaultInviteRecord): Promise { + 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 { + await this.encryption.decryptErroredPayloads() + } + + public async getInvitableContactsForSharedVault( + sharedVault: SharedVaultListingInterface, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const useCase = new GetSharedVaultUsersUseCase(this.usersServer) + return useCase.execute({ sharedVaultUuid: sharedVault.sharing.sharedVaultUuid }) + } + + private async shareContactWithUserAdministeredSharedVaults(contact: TrustedContactInterface): Promise { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/SharedVaultServiceEvent.ts b/packages/services/src/Domain/SharedVaults/SharedVaultServiceEvent.ts new file mode 100644 index 000000000..ac210e91f --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/SharedVaultServiceEvent.ts @@ -0,0 +1,10 @@ +import { KeySystemIdentifier } from '@standardnotes/models' + +export enum SharedVaultServiceEvent { + SharedVaultStatusChanged = 'SharedVaultStatusChanged', +} + +export type SharedVaultServiceEventPayload = { + sharedVaultUuid: string + keySystemIdentifier: KeySystemIdentifier +} diff --git a/packages/services/src/Domain/SharedVaults/SharedVaultServiceInterface.ts b/packages/services/src/Domain/SharedVaults/SharedVaultServiceInterface.ts new file mode 100644 index 000000000..8de042513 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/SharedVaultServiceInterface.ts @@ -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 { + createSharedVault(dto: { + name: string + description?: string + userInputtedPassword: string | undefined + storagePreference?: KeySystemRootKeyStorageMode + }): Promise + deleteSharedVault(sharedVault: SharedVaultListingInterface): Promise + + convertVaultToSharedVault(vault: VaultListingInterface): Promise + + inviteContactToSharedVault( + sharedVault: SharedVaultListingInterface, + contact: TrustedContactInterface, + permissions: SharedVaultPermission, + ): Promise + removeUserFromSharedVault( + sharedVault: SharedVaultListingInterface, + userUuid: string, + ): Promise + leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise + getSharedVaultUsers(sharedVault: SharedVaultListingInterface): Promise + isSharedVaultUserSharedVaultOwner(user: SharedVaultUserServerHash): boolean + isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean + + getItemLastEditedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined + getItemSharedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined + + downloadInboundInvites(): Promise + getOutboundInvites( + sharedVault?: SharedVaultListingInterface, + ): Promise + acceptPendingSharedVaultInvite(pendingInvite: PendingSharedVaultInviteRecord): Promise + getCachedPendingInviteRecords(): PendingSharedVaultInviteRecord[] + getInvitableContactsForSharedVault(sharedVault: SharedVaultListingInterface): Promise + deleteInvite(invite: SharedVaultInviteServerHash): Promise +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/AcceptTrustedSharedVaultInvite.ts b/packages/services/src/Domain/SharedVaults/UseCase/AcceptTrustedSharedVaultInvite.ts new file mode 100644 index 000000000..55ef52c67 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/AcceptTrustedSharedVaultInvite.ts @@ -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 { + 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, + }) + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/ConvertToSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/ConvertToSharedVault.ts new file mode 100644 index 000000000..e6cce9687 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/ConvertToSharedVault.ts @@ -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 { + 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( + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/CreateSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/CreateSharedVault.ts new file mode 100644 index 000000000..d13282db1 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/CreateSharedVault.ts @@ -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 { + 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( + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts new file mode 100644 index 000000000..78819adb0 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/DeleteExternalSharedVault.ts @@ -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 { + 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 { + 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 { + const rootKeys = this.encryption.keys.getSyncedKeySystemRootKeysForVault(vault.systemIdentifier) + await this.mutator.setItemsToBeDeleted(rootKeys) + + await this.mutator.setItemToBeDeleted(vault) + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/DeleteSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/DeleteSharedVault.ts new file mode 100644 index 000000000..3950c75b2 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/DeleteSharedVault.ts @@ -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 { + 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() + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultTrustedContacts.ts b/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultTrustedContacts.ts new file mode 100644 index 000000000..580b71074 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultTrustedContacts.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultUsers.ts b/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultUsers.ts new file mode 100644 index 000000000..eb022c6e4 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/GetSharedVaultUsers.ts @@ -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 { + const response = await this.vaultUsersServer.getSharedVaultUsers({ sharedVaultUuid: params.sharedVaultUuid }) + + if (isErrorResponse(response)) { + return undefined + } + + return response.data.users + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/InviteContactToSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/InviteContactToSharedVault.ts new file mode 100644 index 000000000..264b453e3 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/InviteContactToSharedVault.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/LeaveSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/LeaveSharedVault.ts new file mode 100644 index 000000000..8f3cea34b --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/LeaveSharedVault.ts @@ -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 { + const latestVaultListing = this.items.findItem(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) + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/NotifySharedVaultUsersOfRootKeyRotation.ts b/packages/services/src/Domain/SharedVaults/UseCase/NotifySharedVaultUsersOfRootKeyRotation.ts new file mode 100644 index 000000000..079ddf20a --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/NotifySharedVaultUsersOfRootKeyRotation.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/RemoveSharedVaultMember.ts b/packages/services/src/Domain/SharedVaults/UseCase/RemoveSharedVaultMember.ts new file mode 100644 index 000000000..abb03f348 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/RemoveSharedVaultMember.ts @@ -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 { + const response = await this.vaultUserServer.deleteSharedVaultUser({ + sharedVaultUuid: params.sharedVaultUuid, + userUuid: params.userUuid, + }) + + if (isErrorResponse(response)) { + return ClientDisplayableError.FromNetworkError(response) + } + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/ReuploadSharedVaultInvitesAfterKeyRotation.ts b/packages/services/src/Domain/SharedVaults/UseCase/ReuploadSharedVaultInvitesAfterKeyRotation.ts new file mode 100644 index 000000000..12847005e --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/ReuploadSharedVaultInvitesAfterKeyRotation.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + } + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultInviteUseCase.ts b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultInviteUseCase.ts new file mode 100644 index 000000000..445d7f5e3 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultInviteUseCase.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultMetadataChangedMessageToAll.ts b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultMetadataChangedMessageToAll.ts new file mode 100644 index 000000000..2473cc4c9 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultMetadataChangedMessageToAll.ts @@ -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 { + 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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultRootKeyChangedMessageToAll.ts b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultRootKeyChangedMessageToAll.ts new file mode 100644 index 000000000..8656afc89 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/SendSharedVaultRootKeyChangedMessageToAll.ts @@ -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 { + 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 { + 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 + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/ShareContactWithAllMembersOfSharedVault.ts b/packages/services/src/Domain/SharedVaults/UseCase/ShareContactWithAllMembersOfSharedVault.ts new file mode 100644 index 000000000..0e6b63a3b --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/ShareContactWithAllMembersOfSharedVault.ts @@ -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 { + 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, + }) + } + } +} diff --git a/packages/services/src/Domain/SharedVaults/UseCase/UpdateSharedVaultInvite.ts b/packages/services/src/Domain/SharedVaults/UseCase/UpdateSharedVaultInvite.ts new file mode 100644 index 000000000..847888d87 --- /dev/null +++ b/packages/services/src/Domain/SharedVaults/UseCase/UpdateSharedVaultInvite.ts @@ -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 { + 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 + } +} diff --git a/packages/services/src/Domain/Singleton/SingletonManagerInterface.ts b/packages/services/src/Domain/Singleton/SingletonManagerInterface.ts new file mode 100644 index 000000000..c3dafd0be --- /dev/null +++ b/packages/services/src/Domain/Singleton/SingletonManagerInterface.ts @@ -0,0 +1,26 @@ +import { DecryptedItemInterface, ItemContent, Predicate, PredicateInterface } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' + +export interface SingletonManagerInterface { + findSingleton( + contentType: ContentType, + predicate: PredicateInterface, + ): T | undefined + + findOrCreateContentTypeSingleton< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, + >( + contentType: ContentType, + createContent: ItemContent, + ): Promise + + findOrCreateSingleton< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, + >( + predicate: Predicate, + contentType: ContentType, + createContent: ItemContent, + ): Promise +} diff --git a/packages/services/src/Domain/Storage/StorageKeys.ts b/packages/services/src/Domain/Storage/StorageKeys.ts index 2af95426c..a064d1f03 100644 --- a/packages/services/src/Domain/Storage/StorageKeys.ts +++ b/packages/services/src/Domain/Storage/StorageKeys.ts @@ -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 { diff --git a/packages/services/src/Domain/Storage/StorageServiceInterface.ts b/packages/services/src/Domain/Storage/StorageServiceInterface.ts index 1663f62f0..e89e01068 100644 --- a/packages/services/src/Domain/Storage/StorageServiceInterface.ts +++ b/packages/services/src/Domain/Storage/StorageServiceInterface.ts @@ -8,14 +8,16 @@ import { StoragePersistencePolicies, StorageValueModes } from './StorageTypes' export interface StorageServiceInterface { getAllRawPayloads(): Promise + getAllKeys(mode?: StorageValueModes): string[] getValue(key: string, mode?: StorageValueModes, defaultValue?: T): T canDecryptWithKey(key: RootKeyInterface): Promise savePayload(payload: PayloadInterface): Promise savePayloads(decryptedPayloads: PayloadInterface[]): Promise - setValue(key: string, value: unknown, mode?: StorageValueModes): void + setValue(key: string, value: T, mode?: StorageValueModes): void removeValue(key: string, mode?: StorageValueModes): Promise setPersistencePolicy(persistencePolicy: StoragePersistencePolicies): Promise clearAllData(): Promise - forceDeletePayloads(payloads: FullyFormedPayloadInterface[]): Promise + deletePayloads(payloads: FullyFormedPayloadInterface[]): Promise + deletePayloadsWithUuids(uuids: string[]): Promise clearAllPayloads(): Promise } diff --git a/packages/services/src/Domain/Strings/InfoStrings.ts b/packages/services/src/Domain/Strings/InfoStrings.ts index cd220ca05..d474b4138 100644 --- a/packages/services/src/Domain/Strings/InfoStrings.ts +++ b/packages/services/src/Domain/Strings/InfoStrings.ts @@ -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.", } diff --git a/packages/services/src/Domain/Strings/Messages.ts b/packages/services/src/Domain/Strings/Messages.ts index ec794ca5e..0bf160cbb 100644 --- a/packages/services/src/Domain/Strings/Messages.ts +++ b/packages/services/src/Domain/Strings/Messages.ts @@ -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 = { diff --git a/packages/services/src/Domain/Sync/SyncOptions.ts b/packages/services/src/Domain/Sync/SyncOptions.ts index 96e0352e3..879ba5a60 100644 --- a/packages/services/src/Domain/Sync/SyncOptions.ts +++ b/packages/services/src/Domain/Sync/SyncOptions.ts @@ -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 } diff --git a/packages/services/src/Domain/Sync/SyncServiceInterface.ts b/packages/services/src/Domain/Sync/SyncServiceInterface.ts index 7d5047db5..9a8005eba 100644 --- a/packages/services/src/Domain/Sync/SyncServiceInterface.ts +++ b/packages/services/src/Domain/Sync/SyncServiceInterface.ts @@ -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 { sync(options?: Partial): Promise resetSyncState(): void markAllItemsAsNeedingSyncAndPersist(): Promise @@ -11,4 +13,5 @@ export interface SyncServiceInterface { persistPayloads(payloads: FullyFormedPayloadInterface[]): Promise lockSyncing(): void unlockSyncing(): void + syncSharedVaultsFromScratch(sharedVaultUuids: string[]): Promise } diff --git a/packages/services/src/Domain/User/UserClientInterface.ts b/packages/services/src/Domain/User/UserClientInterface.ts index 6c4404986..62b20df58 100644 --- a/packages/services/src/Domain/User/UserClientInterface.ts +++ b/packages/services/src/Domain/User/UserClientInterface.ts @@ -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 +} + +export interface UserClientInterface extends AbstractService { + isSignedIn(): boolean + register( + email: string, + password: string, + ephemeral: boolean, + mergeLocal: boolean, + ): Promise + signIn( + email: string, + password: string, + strict: boolean, + ephemeral: boolean, + mergeLocal: boolean, + awaitSync: boolean, + ): Promise> deleteAccount(): Promise<{ error: boolean message?: string @@ -10,4 +50,9 @@ export interface UserClientInterface { signOut(force?: boolean, source?: DeinitSource): Promise submitUserRequest(requestType: UserRequestType): Promise populateSessionFromDemoShareToken(token: Base64String): Promise + updateAccountWithFirstTimeKeyPair(): Promise<{ + success?: true + canceled?: true + error?: { message: string } + }> } diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 8f4ee14b8..c700b66eb 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -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 -} - export class UserService extends AbstractService 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 { 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 }) diff --git a/packages/services/src/Domain/UserEvent/UserEventService.ts b/packages/services/src/Domain/UserEvent/UserEventService.ts new file mode 100644 index 000000000..c9cf580af --- /dev/null +++ b/packages/services/src/Domain/UserEvent/UserEventService.ts @@ -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 + implements InternalEventHandlerInterface +{ + constructor(internalEventBus: InternalEventBusInterface) { + super(internalEventBus) + + internalEventBus.addEventHandler(this, SyncEvent.ReceivedUserEvents) + } + + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === SyncEvent.ReceivedUserEvents) { + return this.handleReceivedUserEvents(event.payload as SyncEventReceivedUserEventsData) + } + } + + private async handleReceivedUserEvents(userEvents: UserEventServerHash[]): Promise { + 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) + } + } +} diff --git a/packages/services/src/Domain/UserEvent/UserEventServiceEvent.ts b/packages/services/src/Domain/UserEvent/UserEventServiceEvent.ts new file mode 100644 index 000000000..0eb8b4afc --- /dev/null +++ b/packages/services/src/Domain/UserEvent/UserEventServiceEvent.ts @@ -0,0 +1,9 @@ +import { UserEventPayload } from '@standardnotes/responses' + +export enum UserEventServiceEvent { + UserEventReceived = 'UserEventReceived', +} + +export type UserEventServiceEventPayload = { + eventPayload: UserEventPayload +} diff --git a/packages/services/src/Domain/Vaults/ChangeVaultOptionsDTO.ts b/packages/services/src/Domain/Vaults/ChangeVaultOptionsDTO.ts new file mode 100644 index 000000000..2001dd8c3 --- /dev/null +++ b/packages/services/src/Domain/Vaults/ChangeVaultOptionsDTO.ts @@ -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 +} diff --git a/packages/services/src/Domain/Vaults/UseCase/ChangeVaultKeyOptions.ts b/packages/services/src/Domain/Vaults/UseCase/ChangeVaultKeyOptions.ts new file mode 100644 index 000000000..98600d7e8 --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/ChangeVaultKeyOptions.ts @@ -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 { + 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 { + 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(vault, (mutator) => { + mutator.rootKeyParams = newRootKey.keyParams + }) + + await this.encryption.reencryptKeySystemItemsKeysForVault(vault.systemIdentifier) + } + + private async changePasswordTypeToRandomized( + vault: VaultListingInterface, + storageMode: KeySystemRootKeyStorageMode, + ): Promise { + 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(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 { + 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(vault, (mutator) => { + mutator.keyStorageMode = newKeyStorageMode + }) + + await this.sync.sync() + } + + private async changeStorageModeToSynced(vault: VaultListingInterface): Promise { + 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(vault, (mutator) => { + mutator.keyStorageMode = KeySystemRootKeyStorageMode.Synced + }) + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/CreateVault.ts b/packages/services/src/Domain/Vaults/UseCase/CreateVault.ts new file mode 100644 index 000000000..e51cc3625 --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/CreateVault.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/DeleteVault.ts b/packages/services/src/Domain/Vaults/UseCase/DeleteVault.ts new file mode 100644 index 000000000..1a9c2b146 --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/DeleteVault.ts @@ -0,0 +1,32 @@ +import { ClientDisplayableError } from '@standardnotes/responses' +import { ItemManagerInterface } from '../../Item/ItemManagerInterface' +import { VaultListingInterface } from '@standardnotes/models' +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface' + +export class DeleteVaultUseCase { + constructor( + private items: ItemManagerInterface, + private mutator: MutatorClientInterface, + private encryption: EncryptionProviderInterface, + ) {} + + async execute(vault: VaultListingInterface): Promise { + if (!vault.systemIdentifier) { + throw new Error('Vault system identifier is missing') + } + + await this.encryption.keys.deleteNonPersistentSystemRootKeysForVault(vault.systemIdentifier) + + const rootKeys = this.encryption.keys.getSyncedKeySystemRootKeysForVault(vault.systemIdentifier) + await this.mutator.setItemsToBeDeleted(rootKeys) + + const itemsKeys = this.encryption.keys.getKeySystemItemsKeys(vault.systemIdentifier) + await this.mutator.setItemsToBeDeleted(itemsKeys) + + const vaultItems = this.items.itemsBelongingToKeySystem(vault.systemIdentifier) + await this.mutator.setItemsToBeDeleted(vaultItems) + + await this.mutator.setItemToBeDeleted(vault) + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/GetVault.ts b/packages/services/src/Domain/Vaults/UseCase/GetVault.ts new file mode 100644 index 000000000..de8298b0d --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/GetVault.ts @@ -0,0 +1,17 @@ +import { VaultListingInterface } from '@standardnotes/models' +import { ItemManagerInterface } from './../../Item/ItemManagerInterface' +import { ContentType } from '@standardnotes/common' + +export class GetVaultUseCase { + constructor(private items: ItemManagerInterface) {} + + execute(query: { keySystemIdentifier: string } | { sharedVaultUuid: string }): T | undefined { + const vaults = this.items.getItems(ContentType.VaultListing) + + if ('keySystemIdentifier' in query) { + return vaults.find((listing) => listing.systemIdentifier === query.keySystemIdentifier) as T + } else { + return vaults.find((listing) => listing.sharing?.sharedVaultUuid === query.sharedVaultUuid) as T + } + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/MoveItemsToVault.ts b/packages/services/src/Domain/Vaults/UseCase/MoveItemsToVault.ts new file mode 100644 index 000000000..433a14105 --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/MoveItemsToVault.ts @@ -0,0 +1,42 @@ +import { MutatorClientInterface, SyncServiceInterface } from '@standardnotes/services' +import { ClientDisplayableError } from '@standardnotes/responses' +import { DecryptedItemInterface, FileItem, VaultListingInterface } from '@standardnotes/models' +import { FilesClientInterface } from '@standardnotes/files' +import { ContentType } from '@standardnotes/common' + +export class MoveItemsToVaultUseCase { + constructor( + private mutator: MutatorClientInterface, + private sync: SyncServiceInterface, + private files: FilesClientInterface, + ) {} + + async execute(dto: { + items: DecryptedItemInterface[] + vault: VaultListingInterface + }): Promise { + for (const item of dto.items) { + await this.mutator.changeItem(item, (mutator) => { + mutator.key_system_identifier = dto.vault.systemIdentifier + mutator.shared_vault_uuid = dto.vault.isSharedVaultListing() ? dto.vault.sharing.sharedVaultUuid : undefined + }) + } + + await this.sync.sync() + + for (const item of dto.items) { + if (item.content_type !== ContentType.File) { + continue + } + + if (dto.vault.isSharedVaultListing()) { + await this.files.moveFileToSharedVault(item as FileItem, dto.vault) + } else { + const itemPreviouslyBelongedToSharedVault = item.shared_vault_uuid + if (itemPreviouslyBelongedToSharedVault) { + await this.files.moveFileOutOfSharedVault(item as FileItem) + } + } + } + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/RemoveItemFromVault.ts b/packages/services/src/Domain/Vaults/UseCase/RemoveItemFromVault.ts new file mode 100644 index 000000000..44ca73529 --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/RemoveItemFromVault.ts @@ -0,0 +1,26 @@ +import { MutatorClientInterface, SyncServiceInterface } from '@standardnotes/services' +import { ClientDisplayableError } from '@standardnotes/responses' +import { DecryptedItemInterface, FileItem } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { FilesClientInterface } from '@standardnotes/files' + +export class RemoveItemFromVault { + constructor( + private mutator: MutatorClientInterface, + private sync: SyncServiceInterface, + private files: FilesClientInterface, + ) {} + + async execute(dto: { item: DecryptedItemInterface }): Promise { + await this.mutator.changeItem(dto.item, (mutator) => { + mutator.key_system_identifier = undefined + mutator.shared_vault_uuid = undefined + }) + + await this.sync.sync() + + if (dto.item.content_type === ContentType.File) { + await this.files.moveFileOutOfSharedVault(dto.item as FileItem) + } + } +} diff --git a/packages/services/src/Domain/Vaults/UseCase/RotateVaultRootKey.ts b/packages/services/src/Domain/Vaults/UseCase/RotateVaultRootKey.ts new file mode 100644 index 000000000..6b10ff6de --- /dev/null +++ b/packages/services/src/Domain/Vaults/UseCase/RotateVaultRootKey.ts @@ -0,0 +1,90 @@ +import { UuidGenerator, assert } from '@standardnotes/utils' +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { ClientDisplayableError, isClientDisplayableError } from '@standardnotes/responses' +import { + KeySystemIdentifier, + KeySystemRootKeyInterface, + KeySystemRootKeyPasswordType, + KeySystemRootKeyStorageMode, + VaultListingInterface, + VaultListingMutator, +} from '@standardnotes/models' +import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface' + +export class RotateVaultRootKeyUseCase { + constructor(private mutator: MutatorClientInterface, private encryption: EncryptionProviderInterface) {} + + async execute(params: { + vault: VaultListingInterface + sharedVaultUuid: string | undefined + userInputtedPassword: string | undefined + }): Promise { + const currentRootKey = this.encryption.keys.getPrimaryKeySystemRootKey(params.vault.systemIdentifier) + if (!currentRootKey) { + throw new Error('Cannot rotate key system root key; key system root key not found') + } + + let newRootKey: KeySystemRootKeyInterface | undefined + + if (currentRootKey.keyParams.passwordType === KeySystemRootKeyPasswordType.UserInputted) { + if (!params.userInputtedPassword) { + throw new Error('Cannot rotate key system root key; user inputted password required') + } + + newRootKey = this.encryption.createUserInputtedKeySystemRootKey({ + systemIdentifier: params.vault.systemIdentifier, + userInputtedPassword: params.userInputtedPassword, + }) + } else if (currentRootKey.keyParams.passwordType === KeySystemRootKeyPasswordType.Randomized) { + newRootKey = this.encryption.createRandomizedKeySystemRootKey({ + systemIdentifier: params.vault.systemIdentifier, + }) + } + + if (!newRootKey) { + throw new Error('Cannot rotate key system root key; new root key not created') + } + + if (params.vault.keyStorageMode === KeySystemRootKeyStorageMode.Synced) { + await this.mutator.insertItem(newRootKey, true) + } else { + this.encryption.keys.intakeNonPersistentKeySystemRootKey(newRootKey, params.vault.keyStorageMode) + } + + await this.mutator.changeItem(params.vault, (mutator) => { + assert(newRootKey) + mutator.rootKeyParams = newRootKey.keyParams + }) + + const errors: ClientDisplayableError[] = [] + + const updateKeySystemItemsKeyResult = await this.createNewKeySystemItemsKey({ + keySystemIdentifier: params.vault.systemIdentifier, + sharedVaultUuid: params.sharedVaultUuid, + rootKeyToken: newRootKey.token, + }) + + if (isClientDisplayableError(updateKeySystemItemsKeyResult)) { + errors.push(updateKeySystemItemsKeyResult) + } + + await this.encryption.reencryptKeySystemItemsKeysForVault(params.vault.systemIdentifier) + + return errors + } + + private async createNewKeySystemItemsKey(params: { + keySystemIdentifier: KeySystemIdentifier + sharedVaultUuid: string | undefined + rootKeyToken: string + }): Promise { + const newItemsKeyUuid = UuidGenerator.GenerateUuid() + const newItemsKey = this.encryption.createKeySystemItemsKey( + newItemsKeyUuid, + params.keySystemIdentifier, + params.sharedVaultUuid, + params.rootKeyToken, + ) + await this.mutator.insertItem(newItemsKey) + } +} diff --git a/packages/services/src/Domain/Vaults/VaultService.ts b/packages/services/src/Domain/Vaults/VaultService.ts new file mode 100644 index 000000000..b12e1c8d5 --- /dev/null +++ b/packages/services/src/Domain/Vaults/VaultService.ts @@ -0,0 +1,322 @@ +import { isClientDisplayableError } from '@standardnotes/responses' +import { + DecryptedItemInterface, + FileItem, + KeySystemIdentifier, + KeySystemRootKeyPasswordType, + KeySystemRootKeyStorageMode, + VaultListingInterface, + VaultListingMutator, + isNote, +} from '@standardnotes/models' +import { VaultServiceInterface } from './VaultServiceInterface' +import { ChangeVaultOptionsDTO } from './ChangeVaultOptionsDTO' +import { VaultServiceEvent, VaultServiceEventPayload } from './VaultServiceEvent' +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { CreateVaultUseCase } from './UseCase/CreateVault' +import { AbstractService } from '../Service/AbstractService' +import { SyncServiceInterface } from '../Sync/SyncServiceInterface' +import { ItemManagerInterface } from '../Item/ItemManagerInterface' +import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' +import { RemoveItemFromVault } from './UseCase/RemoveItemFromVault' +import { DeleteVaultUseCase } from './UseCase/DeleteVault' +import { MoveItemsToVaultUseCase } from './UseCase/MoveItemsToVault' + +import { RotateVaultRootKeyUseCase } from './UseCase/RotateVaultRootKey' +import { FilesClientInterface } from '@standardnotes/files' +import { ContentType } from '@standardnotes/common' +import { GetVaultUseCase } from './UseCase/GetVault' +import { ChangeVaultKeyOptionsUseCase } from './UseCase/ChangeVaultKeyOptions' +import { MutatorClientInterface } from '../Mutator/MutatorClientInterface' +import { AlertService } from '../Alert/AlertService' + +export class VaultService + extends AbstractService + implements VaultServiceInterface +{ + private lockMap = new Map() + + constructor( + private sync: SyncServiceInterface, + private items: ItemManagerInterface, + private mutator: MutatorClientInterface, + private encryption: EncryptionProviderInterface, + private files: FilesClientInterface, + private alerts: AlertService, + eventBus: InternalEventBusInterface, + ) { + super(eventBus) + + items.addObserver([ContentType.KeySystemItemsKey, ContentType.KeySystemRootKey, ContentType.VaultListing], () => { + void this.recomputeAllVaultsLockingState() + }) + } + + getVaults(): VaultListingInterface[] { + return this.items.getItems(ContentType.VaultListing).sort((a, b) => { + return a.name.localeCompare(b.name) + }) + } + + getLockedvaults(): VaultListingInterface[] { + const vaults = this.getVaults() + return vaults.filter((vault) => this.isVaultLocked(vault)) + } + + public getVault(dto: { keySystemIdentifier: KeySystemIdentifier }): VaultListingInterface | undefined { + const usecase = new GetVaultUseCase(this.items) + return usecase.execute(dto) + } + + public getSureVault(dto: { keySystemIdentifier: KeySystemIdentifier }): VaultListingInterface { + const vault = this.getVault(dto) + if (!vault) { + throw new Error('Vault not found') + } + + return vault + } + + async createRandomizedVault(dto: { + name: string + description?: string + storagePreference: KeySystemRootKeyStorageMode + }): Promise { + return this.createVaultWithParameters({ + name: dto.name, + description: dto.description, + userInputtedPassword: undefined, + storagePreference: dto.storagePreference, + }) + } + + async createUserInputtedPasswordVault(dto: { + name: string + description?: string + userInputtedPassword: string + storagePreference: KeySystemRootKeyStorageMode + }): Promise { + return this.createVaultWithParameters(dto) + } + + private async createVaultWithParameters(dto: { + name: string + description?: string + userInputtedPassword: string | undefined + storagePreference: KeySystemRootKeyStorageMode + }): Promise { + const createVault = new CreateVaultUseCase(this.mutator, this.encryption, this.sync) + const result = await createVault.execute({ + vaultName: dto.name, + vaultDescription: dto.description, + userInputtedPassword: dto.userInputtedPassword, + storagePreference: dto.storagePreference, + }) + + return result + } + + async moveItemToVault( + vault: VaultListingInterface, + item: DecryptedItemInterface, + ): Promise { + if (this.isVaultLocked(vault)) { + throw new Error('Attempting to add item to locked vault') + } + + let linkedFiles: FileItem[] = [] + if (isNote(item)) { + linkedFiles = this.items.getNoteLinkedFiles(item) + + if (linkedFiles.length > 0) { + const confirmed = await this.alerts.confirmV2({ + title: 'Linked files will be moved to vault', + text: `This note has ${linkedFiles.length} linked files. They will also be moved to the vault. Do you want to continue?`, + }) + if (!confirmed) { + return undefined + } + } + } + + const useCase = new MoveItemsToVaultUseCase(this.mutator, this.sync, this.files) + await useCase.execute({ vault, items: [item, ...linkedFiles] }) + + return this.items.findSureItem(item.uuid) + } + + async removeItemFromVault(item: DecryptedItemInterface): Promise { + const vault = this.getItemVault(item) + if (!vault) { + throw new Error('Cannot find vault to remove item from') + } + + if (this.isVaultLocked(vault)) { + throw new Error('Attempting to remove item from locked vault') + } + + const useCase = new RemoveItemFromVault(this.mutator, this.sync, this.files) + await useCase.execute({ item }) + return this.items.findSureItem(item.uuid) + } + + async deleteVault(vault: VaultListingInterface): Promise { + if (vault.isSharedVaultListing()) { + throw new Error('Shared vault must be deleted through SharedVaultService') + } + + const useCase = new DeleteVaultUseCase(this.items, this.mutator, this.encryption) + const error = await useCase.execute(vault) + + if (isClientDisplayableError(error)) { + return false + } + + await this.sync.sync() + return true + } + + async changeVaultNameAndDescription( + vault: VaultListingInterface, + params: { name: string; description?: string }, + ): Promise { + const updatedVault = await this.mutator.changeItem(vault, (mutator) => { + mutator.name = params.name + mutator.description = params.description + }) + + await this.sync.sync() + + return updatedVault + } + + async rotateVaultRootKey(vault: VaultListingInterface): Promise { + if (this.computeVaultLockState(vault) === 'locked') { + throw new Error('Cannot rotate root key of locked vault') + } + + const useCase = new RotateVaultRootKeyUseCase(this.mutator, this.encryption) + await useCase.execute({ + vault, + sharedVaultUuid: vault.isSharedVaultListing() ? vault.sharing.sharedVaultUuid : undefined, + userInputtedPassword: undefined, + }) + + await this.notifyEventSync(VaultServiceEvent.VaultRootKeyRotated, { vault }) + + await this.sync.sync() + } + + isItemInVault(item: DecryptedItemInterface): boolean { + return item.key_system_identifier !== undefined + } + + getItemVault(item: DecryptedItemInterface): VaultListingInterface | undefined { + const latestItem = this.items.findItem(item.uuid) + if (!latestItem) { + throw new Error('Cannot find latest version of item to get vault for') + } + + if (!latestItem.key_system_identifier) { + return undefined + } + + return this.getVault({ keySystemIdentifier: latestItem.key_system_identifier }) + } + + async changeVaultOptions(dto: ChangeVaultOptionsDTO): Promise { + if (this.isVaultLocked(dto.vault)) { + throw new Error('Attempting to change vault options on a locked vault') + } + + const usecase = new ChangeVaultKeyOptionsUseCase(this.items, this.mutator, this.sync, this.encryption) + await usecase.execute(dto) + + if (dto.newPasswordType) { + await this.notifyEventSync(VaultServiceEvent.VaultRootKeyRotated, { vault: dto.vault }) + } + } + + public isVaultLocked(vault: VaultListingInterface): boolean { + return this.lockMap.get(vault.uuid) === true + } + + public async lockNonPersistentVault(vault: VaultListingInterface): Promise { + if (vault.keyStorageMode === KeySystemRootKeyStorageMode.Synced) { + throw new Error('Vault uses synced root key and cannot be locked') + } + + this.encryption.keys.clearMemoryOfKeysRelatedToVault(vault) + + this.lockMap.set(vault.uuid, true) + void this.notifyEventSync(VaultServiceEvent.VaultLocked, { vault }) + } + + public async unlockNonPersistentVault(vault: VaultListingInterface, password: string): Promise { + if (vault.keyPasswordType !== KeySystemRootKeyPasswordType.UserInputted) { + throw new Error('Vault uses randomized password and cannot be unlocked with user inputted password') + } + + if (vault.keyStorageMode === KeySystemRootKeyStorageMode.Synced) { + throw new Error('Vault uses synced root key and cannot be unlocked with user inputted password') + } + + const derivedRootKey = this.encryption.deriveUserInputtedKeySystemRootKey({ + keyParams: vault.rootKeyParams, + userInputtedPassword: password, + }) + + this.encryption.keys.intakeNonPersistentKeySystemRootKey(derivedRootKey, vault.keyStorageMode) + + await this.encryption.decryptErroredPayloads() + + if (this.computeVaultLockState(vault) === 'locked') { + this.encryption.keys.undoIntakeNonPersistentKeySystemRootKey(vault.systemIdentifier) + return false + } + + this.lockMap.set(vault.uuid, false) + void this.notifyEventSync(VaultServiceEvent.VaultUnlocked, { vault }) + + return true + } + + private recomputeAllVaultsLockingState = async (): Promise => { + const vaults = this.getVaults() + + for (const vault of vaults) { + const locked = this.computeVaultLockState(vault) === 'locked' + + if (this.lockMap.get(vault.uuid) !== locked) { + this.lockMap.set(vault.uuid, locked) + + if (locked) { + void this.notifyEvent(VaultServiceEvent.VaultLocked, { vault }) + } else { + void this.notifyEvent(VaultServiceEvent.VaultUnlocked, { vault }) + } + } + } + } + + private computeVaultLockState(vault: VaultListingInterface): 'locked' | 'unlocked' { + const rootKey = this.encryption.keys.getPrimaryKeySystemRootKey(vault.systemIdentifier) + if (!rootKey) { + return 'locked' + } + + const itemsKey = this.encryption.keys.getPrimaryKeySystemItemsKey(vault.systemIdentifier) + if (!itemsKey) { + return 'locked' + } + + return 'unlocked' + } + + override deinit(): void { + super.deinit() + ;(this.sync as unknown) = undefined + ;(this.encryption as unknown) = undefined + ;(this.items as unknown) = undefined + } +} diff --git a/packages/services/src/Domain/Vaults/VaultServiceEvent.ts b/packages/services/src/Domain/Vaults/VaultServiceEvent.ts new file mode 100644 index 000000000..4d18f146c --- /dev/null +++ b/packages/services/src/Domain/Vaults/VaultServiceEvent.ts @@ -0,0 +1,19 @@ +import { VaultListingInterface } from '@standardnotes/models' + +export enum VaultServiceEvent { + VaultRootKeyRotated = 'VaultRootKeyRotated', + VaultUnlocked = 'VaultUnlocked', + VaultLocked = 'VaultLocked', +} + +export type VaultServiceEventPayload = { + [VaultServiceEvent.VaultRootKeyRotated]: { + vault: VaultListingInterface + } + [VaultServiceEvent.VaultUnlocked]: { + vault: VaultListingInterface + } + [VaultServiceEvent.VaultLocked]: { + vault: VaultListingInterface + } +} diff --git a/packages/services/src/Domain/Vaults/VaultServiceInterface.ts b/packages/services/src/Domain/Vaults/VaultServiceInterface.ts new file mode 100644 index 000000000..e9eddc255 --- /dev/null +++ b/packages/services/src/Domain/Vaults/VaultServiceInterface.ts @@ -0,0 +1,47 @@ +import { + DecryptedItemInterface, + KeySystemIdentifier, + KeySystemRootKeyStorageMode, + VaultListingInterface, +} from '@standardnotes/models' +import { AbstractService } from '../Service/AbstractService' +import { VaultServiceEvent, VaultServiceEventPayload } from './VaultServiceEvent' +import { ChangeVaultOptionsDTO } from './ChangeVaultOptionsDTO' + +export interface VaultServiceInterface + extends AbstractService { + createRandomizedVault(dto: { + name: string + description?: string + storagePreference: KeySystemRootKeyStorageMode + }): Promise + createUserInputtedPasswordVault(dto: { + name: string + description?: string + userInputtedPassword: string + storagePreference: KeySystemRootKeyStorageMode + }): Promise + + getVaults(): VaultListingInterface[] + getVault(dto: { keySystemIdentifier: KeySystemIdentifier }): VaultListingInterface | undefined + getLockedvaults(): VaultListingInterface[] + deleteVault(vault: VaultListingInterface): Promise + + moveItemToVault( + vault: VaultListingInterface, + item: DecryptedItemInterface, + ): Promise + removeItemFromVault(item: DecryptedItemInterface): Promise + isItemInVault(item: DecryptedItemInterface): boolean + getItemVault(item: DecryptedItemInterface): VaultListingInterface | undefined + + changeVaultNameAndDescription( + vault: VaultListingInterface, + params: { name: string; description: string }, + ): Promise + rotateVaultRootKey(vault: VaultListingInterface): Promise + changeVaultOptions(dto: ChangeVaultOptionsDTO): Promise + + isVaultLocked(vault: VaultListingInterface): boolean + unlockNonPersistentVault(vault: VaultListingInterface, password: string): Promise +} diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index 544b5cd77..325fa4bd7 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -1,21 +1,46 @@ export * from './Alert/AlertService' export * from './Api/ApiServiceInterface' + export * from './Application/AppGroupManagedApplication' export * from './Application/ApplicationInterface' export * from './Application/ApplicationStage' export * from './Application/DeinitCallback' export * from './Application/DeinitMode' export * from './Application/DeinitSource' -export * from './Application/WebApplicationInterface' + +export * from './AsymmetricMessage/AsymmetricMessageService' + export * from './Auth/AuthClientInterface' export * from './Auth/AuthManager' + export * from './Authenticator/AuthenticatorClientInterface' export * from './Authenticator/AuthenticatorManager' + export * from './Backups/BackupService' + export * from './Challenge' + export * from './Component/ComponentManagerInterface' export * from './Component/ComponentViewerError' export * from './Component/ComponentViewerInterface' + +export * from './Contacts/ContactServiceInterface' +export * from './Contacts/ContactService' + +export * from './KeySystem/KeySystemKeyManager' + +export * from './SharedVaults/SharedVaultServiceInterface' +export * from './SharedVaults/SharedVaultService' +export * from './SharedVaults/SharedVaultServiceEvent' +export * from './SharedVaults/PendingSharedVaultInviteRecord' + +export * from './Singleton/SingletonManagerInterface' + +export * from './Vaults/VaultService' +export * from './Vaults/VaultServiceInterface' +export * from './Vaults/VaultServiceEvent' +export * from './Vaults/ChangeVaultOptionsDTO' + export * from './Device/DatabaseItemMetadata' export * from './Device/DatabaseLoadOptions' export * from './Device/DatabaseLoadSorter' @@ -26,72 +51,102 @@ export * from './Device/DeviceInterface' export * from './Device/MobileDeviceInterface' export * from './Device/TypeCheck' export * from './Device/WebOrDesktopDeviceInterface' + export * from './Diagnostics/ServiceDiagnostics' -export * from './Encryption/BackupFileDecryptor' + +export * from './Encryption/DecryptBackupFileUseCase' export * from './Encryption/EncryptionService' export * from './Encryption/EncryptionServiceEvent' export * from './Encryption/Functions' export * from './Encryption/ItemsEncryption' export * from './Encryption/RootKeyEncryption' + export * from './Event/ApplicationEvent' export * from './Event/ApplicationEventCallback' export * from './Event/EventObserver' export * from './Event/SyncEvent' export * from './Event/SyncEventReceiver' export * from './Event/WebAppEvent' + export * from './Feature/FeaturesClientInterface' export * from './Feature/FeaturesEvent' export * from './Feature/FeatureStatus' export * from './Feature/OfflineSubscriptionEntitlements' export * from './Feature/SetOfflineFeaturesFunctionResponse' + export * from './Files/FileService' + export * from './History/HistoryServiceInterface' + export * from './Integrity/IntegrityApiInterface' export * from './Integrity/IntegrityEvent' export * from './Integrity/IntegrityEventPayload' export * from './Integrity/IntegrityService' + export * from './Internal/InternalEventBus' export * from './Internal/InternalEventBusInterface' export * from './Internal/InternalEventHandlerInterface' export * from './Internal/InternalEventInterface' export * from './Internal/InternalEventPublishStrategy' export * from './Internal/InternalEventType' -export * from './Item/ItemCounter' -export * from './Item/ItemCounterInterface' + +export * from './InternalFeatures/InternalFeature' +export * from './InternalFeatures/InternalFeatureService' +export * from './InternalFeatures/InternalFeatureServiceInterface' + +export * from './Item/StaticItemCounter' export * from './Item/ItemManagerInterface' export * from './Item/ItemRelationshipDirection' -export * from './Item/ItemsClientInterface' export * from './Item/ItemsServerInterface' + export * from './Mutator/MutatorClientInterface' +export * from './Mutator/ImportDataUseCase' + export * from './Payloads/PayloadManagerInterface' + export * from './Preferences/PreferenceServiceInterface' + export * from './Protection/MobileUnlockTiming' export * from './Protection/ProtectionClientInterface' export * from './Protection/TimingDisplayOption' + export * from './Revision/RevisionClientInterface' export * from './Revision/RevisionManager' + export * from './Service/AbstractService' export * from './Service/ServiceInterface' + export * from './Session/SessionManagerResponse' export * from './Session/SessionsClientInterface' +export * from './Session/SessionEvent' +export * from './Session/UserKeyPairChangedEventData' + export * from './Status/StatusService' export * from './Status/StatusServiceInterface' + export * from './Storage/InMemoryStore' export * from './Storage/KeyValueStoreInterface' export * from './Storage/StorageKeys' export * from './Storage/StorageServiceInterface' export * from './Storage/StorageTypes' + export * from './Strings/InfoStrings' export * from './Strings/Messages' + export * from './Subscription/AppleIAPProductId' export * from './Subscription/AppleIAPReceipt' export * from './Subscription/SubscriptionClientInterface' export * from './Subscription/SubscriptionManager' + export * from './Sync/SyncMode' export * from './Sync/SyncOptions' export * from './Sync/SyncQueueStrategy' export * from './Sync/SyncServiceInterface' export * from './Sync/SyncSource' + export * from './User/UserClientInterface' export * from './User/UserClientInterface' export * from './User/UserService' + +export * from './UserEvent/UserEventService' +export * from './UserEvent/UserEventServiceEvent' diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json index 39b1efa35..bc3bb930b 100644 --- a/packages/services/tsconfig.json +++ b/packages/services/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "skipLibCheck": true, "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "noEmit": true }, "include": ["src/**/*"], "exclude": ["**/*.spec.ts", "node_modules"] diff --git a/packages/sncrypto-common/src/Common/PureCryptoInterface.ts b/packages/sncrypto-common/src/Common/PureCryptoInterface.ts index 595b1656e..e9d237501 100644 --- a/packages/sncrypto-common/src/Common/PureCryptoInterface.ts +++ b/packages/sncrypto-common/src/Common/PureCryptoInterface.ts @@ -139,7 +139,6 @@ export interface PureCryptoInterface { senderSecretKey: HexString, recipientPublicKey: HexString, ): Base64String - sodiumCryptoBoxEasyDecrypt( ciphertext: Base64String, nonce: HexString, @@ -147,7 +146,15 @@ export interface PureCryptoInterface { recipientSecretKey: HexString, ): Utf8String - sodiumCryptoBoxGenerateKeypair(): PkcKeyPair + sodiumCryptoBoxSeedKeypair(seed: HexString): PkcKeyPair + sodiumCryptoSignSeedKeypair(seed: HexString): PkcKeyPair + + sodiumCryptoSign(message: Utf8String, secretKey: HexString): Base64String + sodiumCryptoSignVerify(message: Utf8String, signature: Base64String, publicKey: HexString): boolean + + sodiumCryptoKdfDeriveFromKey(key: HexString, subkeyNumber: number, subkeyLength: number, context: string): HexString + + sodiumCryptoGenericHash(message: Utf8String, key?: HexString): HexString /** * Converts a plain string into base64 diff --git a/packages/sncrypto-common/src/Types/PkcKeyPair.ts b/packages/sncrypto-common/src/Types/PkcKeyPair.ts index 59ecd59bc..ddf5ac3b6 100644 --- a/packages/sncrypto-common/src/Types/PkcKeyPair.ts +++ b/packages/sncrypto-common/src/Types/PkcKeyPair.ts @@ -1,7 +1,6 @@ import { HexString } from './HexString' export type PkcKeyPair = { - keyType: 'curve25519' | 'ed25519' | 'x25519' privateKey: HexString publicKey: HexString } diff --git a/packages/sncrypto-common/src/Types/SodiumConstant.ts b/packages/sncrypto-common/src/Types/SodiumConstant.ts index e6648c5ea..94f6daa48 100644 --- a/packages/sncrypto-common/src/Types/SodiumConstant.ts +++ b/packages/sncrypto-common/src/Types/SodiumConstant.ts @@ -8,4 +8,7 @@ export enum SodiumConstant { CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY = 2, CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL = 3, CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX = 0x3fffffff80, + crypto_box_SEEDBYTES = 32, + crypto_sign_SEEDBYTES = 32, + crypto_generichash_KEYBYTES = 32, } diff --git a/packages/sncrypto-web/src/crypto.ts b/packages/sncrypto-web/src/crypto.ts index e58783863..58751aae2 100644 --- a/packages/sncrypto-web/src/crypto.ts +++ b/packages/sncrypto-web/src/crypto.ts @@ -380,13 +380,61 @@ export class SNWebCrypto implements PureCryptoInterface { return result } - public sodiumCryptoBoxGenerateKeypair(): PkcKeyPair { - const result = sodium.crypto_box_keypair() + sodiumCryptoBoxSeedKeypair(seed: HexString): PkcKeyPair { + const result = sodium.crypto_box_seed_keypair(Utils.hexStringToArrayBuffer(seed)) const publicKey = Utils.arrayBufferToHexString(result.publicKey) const privateKey = Utils.arrayBufferToHexString(result.privateKey) - return { publicKey, privateKey, keyType: result.keyType } + return { publicKey, privateKey } + } + + sodiumCryptoSignSeedKeypair(seed: HexString): PkcKeyPair { + const result = sodium.crypto_sign_seed_keypair(Utils.hexStringToArrayBuffer(seed)) + + const publicKey = Utils.arrayBufferToHexString(result.publicKey) + const privateKey = Utils.arrayBufferToHexString(result.privateKey) + + return { publicKey, privateKey } + } + + sodiumCryptoSign(message: Utf8String, secretKey: HexString): Base64String { + const result = sodium.crypto_sign_detached(message, Utils.hexStringToArrayBuffer(secretKey)) + + return Utils.arrayBufferToBase64(result) + } + + sodiumCryptoSignVerify(message: Utf8String, signature: Base64String, publicKey: HexString): boolean { + return sodium.crypto_sign_verify_detached( + Utils.base64ToArrayBuffer(signature), + message, + Utils.hexStringToArrayBuffer(publicKey), + ) + } + + sodiumCryptoKdfDeriveFromKey(key: HexString, subkeyNumber: number, subkeyLength: number, context: string): HexString { + if (context.length !== 8) { + throw new Error('Context must be 8 bytes') + } + + const result = sodium.crypto_kdf_derive_from_key( + subkeyLength, + subkeyNumber, + context, + Utils.hexStringToArrayBuffer(key), + ) + + return Utils.arrayBufferToHexString(result) + } + + sodiumCryptoGenericHash(message: string, key?: HexString): HexString { + const result = sodium.crypto_generichash( + sodium.crypto_generichash_BYTES, + message, + key ? Utils.hexStringToArrayBuffer(key) : null, + ) + + return Utils.arrayBufferToHexString(result) } /** diff --git a/packages/sncrypto-web/src/libsodium.ts b/packages/sncrypto-web/src/libsodium.ts index d39d4ff90..46e84a237 100644 --- a/packages/sncrypto-web/src/libsodium.ts +++ b/packages/sncrypto-web/src/libsodium.ts @@ -6,12 +6,19 @@ export { crypto_box_easy, crypto_box_keypair, crypto_box_open_easy, + crypto_box_seed_keypair, + crypto_generichash, + crypto_kdf_derive_from_key, crypto_pwhash_ALG_DEFAULT, crypto_pwhash, crypto_secretstream_xchacha20poly1305_init_pull, crypto_secretstream_xchacha20poly1305_init_push, crypto_secretstream_xchacha20poly1305_pull, crypto_secretstream_xchacha20poly1305_push, + crypto_sign_detached, + crypto_sign_keypair, + crypto_sign_seed_keypair, + crypto_sign_verify_detached, from_base64, from_hex, from_string, @@ -19,6 +26,7 @@ export { to_base64, to_hex, to_string, + crypto_generichash_BYTES, } from 'libsodium-wrappers' export type { StateAddress } from 'libsodium-wrappers' diff --git a/packages/sncrypto-web/test/crypto.test.js b/packages/sncrypto-web/test/crypto.test.js index 25dde0ca8..d1a8263d5 100644 --- a/packages/sncrypto-web/test/crypto.test.js +++ b/packages/sncrypto-web/test/crypto.test.js @@ -259,15 +259,17 @@ describe('crypto operations', async function () { }) it('pkc crypto_box_easy keypair generation', async function () { - const keypair = await webCrypto.sodiumCryptoBoxGenerateKeypair() + const seed = await webCrypto.generateRandomKey(32) + const keypair = await webCrypto.sodiumCryptoBoxSeedKeypair(seed) expect(keypair.keyType).to.equal('x25519') expect(keypair.publicKey.length).to.equal(64) expect(keypair.privateKey.length).to.equal(64) }) it('pkc crypto_box_easy encrypt/decrypt', async function () { - const senderKeypair = await webCrypto.sodiumCryptoBoxGenerateKeypair() - const recipientKeypair = await webCrypto.sodiumCryptoBoxGenerateKeypair() + const seed = await webCrypto.generateRandomKey(32) + const senderKeyPair = await webCrypto.sodiumCryptoBoxSeedKeypair(seed) + const recipientKeyPair = await webCrypto.sodiumCryptoBoxSeedKeypair(seed) const nonce = await webCrypto.generateRandomKey(192) const plaintext = 'hello world 🌍' @@ -275,8 +277,8 @@ describe('crypto operations', async function () { const ciphertext = await webCrypto.sodiumCryptoBoxEasyEncrypt( plaintext, nonce, - senderKeypair.privateKey, - recipientKeypair.publicKey, + senderKeyPair.privateKey, + recipientKeyPair.publicKey, ) expect(ciphertext.length).to.equal(44) @@ -284,8 +286,8 @@ describe('crypto operations', async function () { const decrypted = await webCrypto.sodiumCryptoBoxEasyDecrypt( ciphertext, nonce, - senderKeypair.publicKey, - recipientKeypair.privateKey, + senderKeyPair.publicKey, + recipientKeyPair.privateKey, ) expect(decrypted).to.equal(plaintext) diff --git a/packages/snjs/README.md b/packages/snjs/README.md index f1748fb63..c93288f29 100644 --- a/packages/snjs/README.md +++ b/packages/snjs/README.md @@ -42,27 +42,29 @@ Object.assign(window, SNLibrary); #### Prerequisites -To run a stable server environment for E2E tests that is up to date with production, clone the [self-hosted repository](https://github.com/standardnotes/self-hosted). Make sure you have everything set up configuration wise as in self-hosting docs. In particular, make sure the env files are created and proper values for keys are set up. +To run a stable server environment for E2E tests that is up to date with production, [setup a local self-hosted server](https://standardnotes.com/help/self-hosting/docker). + +Make sure you have the following value in the env vars mentioned below. It's important to have low token TTLs for the purpose of the suite. -Make sure you have the following value in the env vars mentioned below. It's important to have low token TTLs for the purpose of the suite. For the most up to date values it's best to check `self-hosted` github workflows. At the moment of writting the recommended values are: ``` -# docker/auth.env -... -ACCESS_TOKEN_AGE=4 -REFRESH_TOKEN_AGE=10 -EPHEMERAL_SESSION_AGE=300 - # .env ... -REVISIONS_FREQUENCY=5 +AUTH_SERVER_ACCESS_TOKEN_AGE=4 +AUTH_SERVER_REFRESH_TOKEN_AGE=10 +AUTH_SERVER_EPHEMERAL_SESSION_AGE=300 +SYNCING_SERVER_REVISIONS_FREQUENCY=5 ``` -#### Start Server For Tests (SELF-HOSTED) +Edit `docker-compose.yml` ports and change keypath services.server.ports[0] from port 3000 to 3123. + +If running server without docker and as individual node processes, and you need a valid subscription for a test (such as uploading files), you'll need to clone the [mock-event-publisher](https://github.com/standardnotes/mock-event-publisher) and run it locally on port 3124. In the Container.ts file, comment out any SNS_ENDPOINT related lines for running locally. + +#### Start Server For Tests In the `self-hosted` folder run: ``` -EXPOSED_PORT=3123 ./server.sh start && ./server.sh wait-for-startup +docker compose pull && docker compose up ``` Wait for the services to be up. diff --git a/packages/snjs/jest-global.ts b/packages/snjs/jest-global.ts index b07f9ca0a..75c8cbc39 100644 --- a/packages/snjs/jest-global.ts +++ b/packages/snjs/jest-global.ts @@ -1,2 +1,3 @@ //@ts-ignore global['__VERSION__'] = global['SnjsVersion'] = require('./package.json').version +global['__IS_DEV__'] = global['isDev'] = process.env.NODE_ENV !== 'production' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 2a945b3d3..c8f4dcda0 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -149,6 +149,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private declare subscriptionManager: SubscriptionClientInterface private declare webSocketApiService: WebSocketApiServiceInterface private declare webSocketServer: WebSocketServerInterface + private sessionManager!: InternalServices.SNSessionManager private syncService!: InternalServices.SNSyncService private challengeService!: InternalServices.ChallengeService @@ -171,6 +172,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private integrityService!: ExternalServices.IntegrityService private statusService!: ExternalServices.StatusService private filesBackupService?: FilesBackupService + private vaultService!: ExternalServices.VaultServiceInterface + private contactService!: ExternalServices.ContactServiceInterface + private sharedVaultService!: ExternalServices.SharedVaultServiceInterface + private userEventService!: ExternalServices.UserEventService + private asymmetricMessageService!: ExternalServices.AsymmetricMessageService + private keySystemKeyManager!: ExternalServices.KeySystemKeyManager + private declare sessionStorageMapper: MapperInterface> private declare legacySessionStorageMapper: MapperInterface> private declare authenticatorManager: AuthenticatorClientInterface @@ -313,7 +321,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.featuresService } - public get items(): ExternalServices.ItemsClientInterface { + public get items(): ExternalServices.ItemManagerInterface { return this.itemManager } @@ -373,6 +381,18 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.challengeService } + public get vaults(): ExternalServices.VaultServiceInterface { + return this.vaultService + } + + public get contacts(): ExternalServices.ContactServiceInterface { + return this.contactService + } + + public get sharedVaults(): ExternalServices.SharedVaultServiceInterface { + return this.sharedVaultService + } + public computePrivateUsername(username: string): Promise { return ComputePrivateUsername(this.options.crypto, username) } @@ -534,6 +554,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli for (const service of this.services) { await service.handleApplicationStage(stage) } + + this.internalEventBus.publish({ + type: ApplicationEvent.ApplicationStageChanged, + payload: { stage } as ExternalServices.ApplicationStageChangedEventPayload, + }) } /** @@ -587,11 +612,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } else if (event === ApplicationEvent.Launched) { this.onLaunch() } + for (const observer of this.eventHandlers.slice()) { if ((observer.singleEvent && observer.singleEvent === event) || !observer.singleEvent) { await observer.callback(event, data || {}) } } + void this.migrationService.handleApplicationEvent(event) } @@ -637,6 +664,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli public async getAvailableSubscriptions(): Promise< Responses.AvailableSubscriptions | Responses.ClientDisplayableError > { + if (this.isThirdPartyHostUsed()) { + return ClientDisplayableError.FromString('Third party hosts do not support subscriptions.') + } return this.sessionManager.getAvailableSubscriptions() } @@ -827,8 +857,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.diskStorageService.setValue(key, value, mode) } - public getValue(key: string, mode?: ExternalServices.StorageValueModes): unknown { - return this.diskStorageService.getValue(key, mode) + public getValue(key: string, mode?: ExternalServices.StorageValueModes): T { + return this.diskStorageService.getValue(key, mode) } public async removeValue(key: string, mode?: ExternalServices.StorageValueModes): Promise { @@ -863,7 +893,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } } - public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void { + public addChallengeObserver(challenge: Challenge, observer: ExternalServices.ChallengeObserver): () => void { return this.challengeService.addChallengeObserver(challenge, observer) } @@ -980,6 +1010,53 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli }) } + public async changeAndSaveItem( + itemToLookupUuidFor: DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps = true, + emitSource?: Models.PayloadEmitSource, + syncOptions?: ExternalServices.SyncOptions, + ): Promise { + await this.mutator.changeItems( + [itemToLookupUuidFor], + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + emitSource, + ) + await this.syncService.sync(syncOptions) + return this.itemManager.findItem(itemToLookupUuidFor.uuid) + } + + public async changeAndSaveItems( + itemsToLookupUuidsFor: DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps = true, + emitSource?: Models.PayloadEmitSource, + syncOptions?: ExternalServices.SyncOptions, + ): Promise { + await this.mutator.changeItems( + itemsToLookupUuidsFor, + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + emitSource, + ) + await this.syncService.sync(syncOptions) + } + + public async importData(data: BackupFile, awaitSync = false): Promise { + const usecase = new ExternalServices.ImportDataUseCase( + this.itemManager, + this.syncService, + this.protectionService, + this.protocolService, + this.payloadManager, + this.challengeService, + this.historyManager, + ) + + return usecase.execute(data, awaitSync) + } + private async handleRevokedSession(): Promise { /** * Because multiple API requests can come back at the same time @@ -1148,9 +1225,16 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createMappers() this.createPayloadManager() this.createItemManager() + this.createMutatorService() + this.createDiskStorageManager() + this.createUserEventService() + this.createInMemoryStorageManager() + + this.createKeySystemKeyManager() this.createProtocolService() + this.diskStorageService.provideEncryptionProvider(this.protocolService) this.createChallengeService() this.createLegacyHttpManager() @@ -1185,7 +1269,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createFileService() this.createIntegrityService() - this.createMutatorService() + this.createListedService() this.createActionsManager() this.createAuthenticatorManager() @@ -1193,6 +1277,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createRevisionManager() this.createUseCases() + this.createContactService() + this.createVaultService() + this.createSharedVaultService() + this.createAsymmetricMessageService() } private clearServices() { @@ -1249,6 +1337,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this._listRevisions as unknown) = undefined ;(this._getRevision as unknown) = undefined ;(this._deleteRevision as unknown) = undefined + ;(this.vaultService as unknown) = undefined + ;(this.contactService as unknown) = undefined + ;(this.sharedVaultService as unknown) = undefined + ;(this.userEventService as unknown) = undefined + ;(this.asymmetricMessageService as unknown) = undefined + ;(this.keySystemKeyManager as unknown) = undefined this.services = [] } @@ -1270,6 +1364,71 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this.internalEventBus as unknown) = undefined } + private createUserEventService(): void { + this.userEventService = new ExternalServices.UserEventService(this.internalEventBus) + this.services.push(this.userEventService) + } + + private createAsymmetricMessageService() { + this.asymmetricMessageService = new ExternalServices.AsymmetricMessageService( + this.httpService, + this.protocolService, + this.contacts, + this.itemManager, + this.mutator, + this.syncService, + this.internalEventBus, + ) + this.services.push(this.asymmetricMessageService) + } + + private createContactService(): void { + this.contactService = new ExternalServices.ContactService( + this.syncService, + this.itemManager, + this.mutator, + this.sessionManager, + this.options.crypto, + this.user, + this.protocolService, + this.singletonManager, + this.internalEventBus, + ) + + this.services.push(this.contactService) + } + + private createSharedVaultService(): void { + this.sharedVaultService = new ExternalServices.SharedVaultService( + this.httpService, + this.syncService, + this.itemManager, + this.mutator, + this.protocolService, + this.sessions, + this.contactService, + this.files, + this.vaults, + this.storage, + this.internalEventBus, + ) + this.services.push(this.sharedVaultService) + } + + private createVaultService(): void { + this.vaultService = new ExternalServices.VaultService( + this.syncService, + this.itemManager, + this.mutator, + this.protocolService, + this.files, + this.alertService, + this.internalEventBus, + ) + + this.services.push(this.vaultService) + } + private createListedService(): void { this.listedService = new InternalServices.ListedService( this.apiService, @@ -1278,6 +1437,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.deprecatedHttpService, this.protectionService, this.mutator, + this.sync, this.internalEventBus, ) this.services.push(this.listedService) @@ -1286,10 +1446,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createFileService() { this.fileService = new FileService( this.apiService, - this.itemManager, + this.mutator, this.syncService, this.protocolService, this.challengeService, + this.httpService, this.alertService, this.options.crypto, this.internalEventBus, @@ -1315,6 +1476,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.diskStorageService, this.apiService, this.itemManager, + this.mutator, this.webSocketsService, this.settingsService, this.userService, @@ -1366,6 +1528,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli sessionManager: this.sessionManager, challengeService: this.challengeService, itemManager: this.itemManager, + mutator: this.mutator, singletonManager: this.singletonManager, featuresService: this.featuresService, environment: this.environment, @@ -1453,6 +1616,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createComponentManager() { this.componentManagerService = new InternalServices.SNComponentManager( this.itemManager, + this.mutator, this.syncService, this.featuresService, this.preferencesService, @@ -1508,6 +1672,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createSingletonManager() { this.singletonManager = new InternalServices.SNSingletonManager( this.itemManager, + this.mutator, this.payloadManager, this.syncService, this.internalEventBus, @@ -1531,9 +1696,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createProtocolService() { this.protocolService = new EncryptionService( this.itemManager, + this.mutator, this.payloadManager, this.deviceInterface, this.diskStorageService, + this.keySystemKeyManager, this.identifier, this.options.crypto, this.internalEventBus, @@ -1548,6 +1715,17 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.services.push(this.protocolService) } + private createKeySystemKeyManager() { + this.keySystemKeyManager = new ExternalServices.KeySystemKeyManager( + this.itemManager, + this.mutator, + this.storage, + this.internalEventBus, + ) + + this.services.push(this.keySystemKeyManager) + } + private createKeyRecoveryService() { this.keyRecoveryService = new InternalServices.SNKeyRecoveryService( this.itemManager, @@ -1582,7 +1760,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.serviceObservers.push( this.sessionManager.addEventObserver(async (event) => { switch (event) { - case InternalServices.SessionEvent.Restored: { + case ExternalServices.SessionEvent.Restored: { void (async () => { await this.sync.sync({ sourceDescription: 'Session restored pre key creation' }) if (this.protocolService.needsNewRootKeyBasedItemsKey()) { @@ -1593,10 +1771,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli })() break } - case InternalServices.SessionEvent.Revoked: { + case ExternalServices.SessionEvent.Revoked: { await this.handleRevokedSession() break } + case ExternalServices.SessionEvent.UserKeyPairChanged: + break default: { Utils.assertUnreachable(event) } @@ -1655,6 +1835,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createProtectionService() { this.protectionService = new InternalServices.SNProtectionService( this.protocolService, + this.mutator, this.challengeService, this.diskStorageService, this.internalEventBus, @@ -1701,6 +1882,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.preferencesService = new InternalServices.SNPreferencesService( this.singletonManager, this.itemManager, + this.mutator, this.syncService, this.internalEventBus, ) @@ -1734,13 +1916,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createMutatorService() { this.mutatorService = new InternalServices.MutatorService( this.itemManager, - this.syncService, - this.protectionService, - this.protocolService, this.payloadManager, - this.challengeService, - this.componentManagerService, - this.historyManager, + this.alertService, this.internalEventBus, ) this.services.push(this.mutatorService) diff --git a/packages/snjs/lib/Application/Event.ts b/packages/snjs/lib/Application/Event.ts index 91ba03516..819ba8160 100644 --- a/packages/snjs/lib/Application/Event.ts +++ b/packages/snjs/lib/Application/Event.ts @@ -5,7 +5,7 @@ export function applicationEventForSyncEvent(syncEvent: SyncEvent) { return ( { [SyncEvent.SyncCompletedWithAllItemsUploaded]: ApplicationEvent.CompletedFullSync, - [SyncEvent.SingleRoundTripSyncCompleted]: ApplicationEvent.CompletedIncrementalSync, + [SyncEvent.PaginatedSyncRequestCompleted]: ApplicationEvent.CompletedIncrementalSync, [SyncEvent.SyncError]: ApplicationEvent.FailedSync, [SyncEvent.SyncTakingTooLong]: ApplicationEvent.HighLatencySync, [SyncEvent.EnterOutOfSync]: ApplicationEvent.EnteredOutOfSync, @@ -14,7 +14,7 @@ export function applicationEventForSyncEvent(syncEvent: SyncEvent) { [SyncEvent.MajorDataChange]: ApplicationEvent.MajorDataChange, [SyncEvent.LocalDataIncrementalLoad]: ApplicationEvent.LocalDataIncrementalLoad, [SyncEvent.StatusChanged]: ApplicationEvent.SyncStatusChanged, - [SyncEvent.SyncWillBegin]: ApplicationEvent.WillSync, + [SyncEvent.SyncDidBeginProcessing]: ApplicationEvent.WillSync, [SyncEvent.InvalidSession]: ApplicationEvent.InvalidSyncSession, [SyncEvent.DatabaseReadError]: ApplicationEvent.LocalDatabaseReadError, [SyncEvent.DatabaseWriteError]: ApplicationEvent.LocalDatabaseWriteError, diff --git a/packages/snjs/lib/Application/LiveItem.ts b/packages/snjs/lib/Application/LiveItem.ts index 0b84be86b..e159d685f 100644 --- a/packages/snjs/lib/Application/LiveItem.ts +++ b/packages/snjs/lib/Application/LiveItem.ts @@ -1,12 +1,12 @@ import { DecryptedItemInterface } from '@standardnotes/models' -import { SNApplication } from './Application' +import { ApplicationInterface } from '@standardnotes/services' /** Keeps an item reference up to date with changes */ export class LiveItem { public item: T private removeObserver: () => void - constructor(uuid: string, application: SNApplication, onChange?: (item: T) => void) { + constructor(uuid: string, application: ApplicationInterface, onChange?: (item: T) => void) { this.item = application.items.findSureItem(uuid) onChange && onChange(this.item) diff --git a/packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts b/packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts index a3fb37248..839dbe711 100644 --- a/packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts +++ b/packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts @@ -1,3 +1,4 @@ +import { ServerItemResponse } from '@standardnotes/responses' import { RevisionClientInterface } from '@standardnotes/services' import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' import { @@ -50,6 +51,8 @@ export class GetRevision implements UseCaseInterface { content_type: revision.content_type as ContentType, updated_at: new Date(revision.updated_at), created_at: new Date(revision.created_at), + key_system_identifier: revision.key_system_identifier ?? undefined, + shared_vault_uuid: revision.shared_vault_uuid ?? undefined, waitingForKey: false, errorDecrypting: false, }) @@ -67,7 +70,7 @@ export class GetRevision implements UseCaseInterface { uuid: sourceItemUuid || revision.item_uuid, }) - if (!isRemotePayloadAllowed(payload)) { + if (!isRemotePayloadAllowed(payload as ServerItemResponse)) { return Result.fail(`Remote payload is disallowed: ${JSON.stringify(payload)}`) } diff --git a/packages/snjs/lib/IsDev.ts b/packages/snjs/lib/IsDev.ts new file mode 100644 index 000000000..0971e851d --- /dev/null +++ b/packages/snjs/lib/IsDev.ts @@ -0,0 +1,3 @@ +/** Declared in webpack config */ +declare const __IS_DEV__: boolean +export const isDev = __IS_DEV__ diff --git a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts index bdeeca746..f0c37e878 100644 --- a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts +++ b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts @@ -1,31 +1,37 @@ import { ItemManager } from '@Lib/Services' import { TagsToFoldersMigrationApplicator } from './TagsToFolders' +import { MutatorClientInterface } from '@standardnotes/services' + +describe('folders component to hierarchy', () => { + let itemManager: ItemManager + let mutator: MutatorClientInterface + let changeItemMock: jest.Mock + let findOrCreateTagParentChainMock: jest.Mock -const itemManagerMock = (tagTitles: string[]) => { const mockTag = (title: string) => ({ title, uuid: title, parentId: undefined, }) + beforeEach(() => { + itemManager = {} as unknown as jest.Mocked - const mock = { - getItems: jest.fn().mockReturnValue(tagTitles.map(mockTag)), - findOrCreateTagParentChain: jest.fn(), - changeItem: jest.fn(), - } + mutator = {} as unknown as jest.Mocked - return mock -} + changeItemMock = mutator.changeItem = jest.fn() + findOrCreateTagParentChainMock = mutator.findOrCreateTagParentChain = jest.fn() + }) -describe('folders component to hierarchy', () => { it('should produce a valid hierarchy in the simple case', async () => { const titles = ['a', 'a.b', 'a.b.c'] + itemManager.getItems - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) + + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(2) expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a']) @@ -39,11 +45,11 @@ describe('folders component to hierarchy', () => { it('should not touch flat hierarchies', async () => { const titles = ['a', 'x', 'y', 'z'] - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(0) @@ -53,11 +59,11 @@ describe('folders component to hierarchy', () => { it('should work despite cloned tags', async () => { const titles = ['a.b', 'c', 'a.b'] - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(2) expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a']) @@ -71,11 +77,11 @@ describe('folders component to hierarchy', () => { it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async () => { const titles = ['y.2', 'w.3', 'y'] - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(2) expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['w']) @@ -89,11 +95,11 @@ describe('folders component to hierarchy', () => { it('skip prefixed names', async () => { const titles = ['.something', '.something...something'] - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(0) expect(changeItemCalls.length).toEqual(0) @@ -109,11 +115,11 @@ describe('folders component to hierarchy', () => { 'something..another.thing..anyway', ] - const itemManager = itemManagerMock(titles) - await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + itemManager.getItems = jest.fn().mockReturnValue(titles.map(mockTag)) + await TagsToFoldersMigrationApplicator.run(itemManager, mutator) - const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls - const changeItemCalls = itemManager.changeItem.mock.calls + const findOrCreateTagParentChainCalls = findOrCreateTagParentChainMock.mock.calls + const changeItemCalls = changeItemMock.mock.calls expect(findOrCreateTagParentChainCalls.length).toEqual(1) expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a', 'b']) diff --git a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts index 3e0ae719c..690d9b15b 100644 --- a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts +++ b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts @@ -1,3 +1,4 @@ +import { MutatorClientInterface } from '@standardnotes/services' import { SNTag, TagMutator, TagFolderDelimitter } from '@standardnotes/models' import { ItemManager } from '@Lib/Services' import { lastElement, sortByKey, withoutLastElement } from '@standardnotes/utils' @@ -15,7 +16,7 @@ export class TagsToFoldersMigrationApplicator { return false } - public static async run(itemManager: ItemManager): Promise { + public static async run(itemManager: ItemManager, mutator: MutatorClientInterface): Promise { const tags = itemManager.getItems(ContentType.Tag) as SNTag[] const sortedTags = sortByKey(tags, 'title') @@ -36,9 +37,9 @@ export class TagsToFoldersMigrationApplicator { return } - const parent = await itemManager.findOrCreateTagParentChain(parents) + const parent = await mutator.findOrCreateTagParentChain(parents) - await itemManager.changeItem(tag, (mutator: TagMutator) => { + await mutator.changeItem(tag, (mutator: TagMutator) => { mutator.title = newTitle if (parent) { diff --git a/packages/snjs/lib/Migrations/Base.ts b/packages/snjs/lib/Migrations/Base.ts index 210fc2694..fb3507aef 100644 --- a/packages/snjs/lib/Migrations/Base.ts +++ b/packages/snjs/lib/Migrations/Base.ts @@ -1,5 +1,10 @@ import { AnyKeyParamsContent, KeyParamsContent004 } from '@standardnotes/common' -import { EncryptedPayload, EncryptedTransferPayload, isErrorDecryptingPayload } from '@standardnotes/models' +import { + EncryptedPayload, + EncryptedTransferPayload, + isErrorDecryptingPayload, + ContentTypeUsesRootKeyEncryption, +} from '@standardnotes/models' import { PreviousSnjsVersion1_0_0, PreviousSnjsVersion2_0_0, SnjsVersion } from '../Version' import { Migration } from '@Lib/Migrations/Migration' import { @@ -16,7 +21,6 @@ import { import { assert } from '@standardnotes/utils' import { CreateReader } from './StorageReaders/Functions' import { StorageReader } from './StorageReaders/Reader' -import { ContentTypeUsesRootKeyEncryption } from '@standardnotes/encryption' /** A key that was briefly present in Snjs version 2.0.0 but removed in 2.0.1 */ const LastMigrationTimeStampKey2_0_0 = 'last_migration_timestamp' diff --git a/packages/snjs/lib/Migrations/MigrationServices.ts b/packages/snjs/lib/Migrations/MigrationServices.ts index d99fe9a91..352ed2867 100644 --- a/packages/snjs/lib/Migrations/MigrationServices.ts +++ b/packages/snjs/lib/Migrations/MigrationServices.ts @@ -1,6 +1,11 @@ import { BackupServiceInterface } from '@standardnotes/files' import { Environment, Platform } from '@standardnotes/models' -import { DeviceInterface, InternalEventBusInterface, EncryptionService } from '@standardnotes/services' +import { + DeviceInterface, + InternalEventBusInterface, + EncryptionService, + MutatorClientInterface, +} from '@standardnotes/services' import { SNSessionManager } from '../Services/Session/SessionManager' import { ApplicationIdentifier } from '@standardnotes/common' import { ItemManager } from '@Lib/Services/Items/ItemManager' @@ -15,6 +20,7 @@ export type MigrationServices = { sessionManager: SNSessionManager backups?: BackupServiceInterface itemManager: ItemManager + mutator: MutatorClientInterface singletonManager: SNSingletonManager featuresService: SNFeaturesService environment: Environment diff --git a/packages/snjs/lib/Migrations/Versions/2_20_0.ts b/packages/snjs/lib/Migrations/Versions/2_20_0.ts index 70beffec8..020cb5516 100644 --- a/packages/snjs/lib/Migrations/Versions/2_20_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_20_0.ts @@ -20,7 +20,7 @@ export class Migration2_20_0 extends Migration { for (const item of items) { this.services.itemManager.removeItemLocally(item) - await this.services.storageService.deletePayloadWithId(item.uuid) + await this.services.storageService.deletePayloadWithUuid(item.uuid) } } } diff --git a/packages/snjs/lib/Migrations/Versions/2_36_0.ts b/packages/snjs/lib/Migrations/Versions/2_36_0.ts index 62743513c..70efaff09 100644 --- a/packages/snjs/lib/Migrations/Versions/2_36_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_36_0.ts @@ -20,7 +20,7 @@ export class Migration2_36_0 extends Migration { for (const item of items) { this.services.itemManager.removeItemLocally(item) - await this.services.storageService.deletePayloadWithId(item.uuid) + await this.services.storageService.deletePayloadWithUuid(item.uuid) } } } diff --git a/packages/snjs/lib/Migrations/Versions/2_42_0.ts b/packages/snjs/lib/Migrations/Versions/2_42_0.ts index 0bf3ba07c..d69bc084f 100644 --- a/packages/snjs/lib/Migrations/Versions/2_42_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_42_0.ts @@ -24,7 +24,7 @@ export class Migration2_42_0 extends Migration { }) for (const theme of themes) { - await this.services.itemManager.setItemToBeDeleted(theme) + await this.services.mutator.setItemToBeDeleted(theme) } } } diff --git a/packages/snjs/lib/Migrations/Versions/2_7_0.ts b/packages/snjs/lib/Migrations/Versions/2_7_0.ts index 48059f090..8d20f8dba 100644 --- a/packages/snjs/lib/Migrations/Versions/2_7_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_7_0.ts @@ -26,7 +26,7 @@ export class Migration2_7_0 extends Migration { const batchMgrSingleton = this.services.singletonManager.findSingleton(ContentType.Component, batchMgrPred) if (batchMgrSingleton) { - await this.services.itemManager.setItemToBeDeleted(batchMgrSingleton) + await this.services.mutator.setItemToBeDeleted(batchMgrSingleton) } } } diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index dadc13765..126849018 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -8,7 +8,6 @@ import { ItemsServerInterface, StorageKey, ApiServiceEvent, - DiagnosticInfo, KeyValueStoreInterface, API_MESSAGE_GENERIC_SYNC_FAIL, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL, @@ -30,8 +29,8 @@ import { API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS, ApiServiceEventData, } from '@standardnotes/services' -import { FilesApiInterface } from '@standardnotes/files' -import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models' +import { DownloadFileParams, FileOwnershipType, FilesApiInterface } from '@standardnotes/files' +import { ServerSyncPushContextualPayload, SNFeatureRepo } from '@standardnotes/models' import { User, HttpStatusCode, @@ -72,6 +71,8 @@ import { HttpErrorResponse, HttpSuccessResponse, isErrorResponse, + ValetTokenOperation, + MoveFileResponse, } from '@standardnotes/responses' import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' import { HttpServiceInterface } from '@standardnotes/api' @@ -103,7 +104,6 @@ export class SNApiService { private session: Session | LegacySession | null public user?: User - private registering = false private authenticating = false private changing = false private refreshingSession = false @@ -210,7 +210,7 @@ export class SNApiService } private errorResponseWithFallbackMessage(response: HttpErrorResponse, message: string): HttpErrorResponse { - if (!response.data.error.message) { + if (response.data.error && !response.data.error.message) { response.data.error.message = message } @@ -369,9 +369,10 @@ export class SNApiService async sync( payloads: ServerSyncPushContextualPayload[], - lastSyncToken: string, - paginationToken: string, + lastSyncToken: string | undefined, + paginationToken: string | undefined, limit: number, + sharedVaultUuids?: string[], ): Promise> { const preprocessingError = this.preprocessingError() if (preprocessingError) { @@ -383,6 +384,7 @@ export class SNApiService [ApiEndpointParam.LastSyncToken]: lastSyncToken, [ApiEndpointParam.PaginationToken]: paginationToken, [ApiEndpointParam.SyncDlLimit]: limit, + [ApiEndpointParam.SharedVaultUuids]: sharedVaultUuids, }) const response = await this.httpService.post(path, params, this.getSessionAccessToken()) @@ -686,12 +688,12 @@ export class SNApiService }) } - public async createFileValetToken( + public async createUserFileValetToken( remoteIdentifier: string, - operation: 'write' | 'read' | 'delete', + operation: ValetTokenOperation, unencryptedFileSize?: number, ): Promise { - const url = joinPaths(this.host, Paths.v1.createFileValetToken) + const url = joinPaths(this.host, Paths.v1.createUserFileValetToken) const params: CreateValetTokenPayload = { operation, @@ -717,40 +719,60 @@ export class SNApiService return response.data?.valetToken } - public async startUploadSession(apiToken: string): Promise> { - const url = joinPaths(this.getFilesHost(), Paths.v1.startUploadSession) + public async startUploadSession( + valetToken: string, + ownershipType: FileOwnershipType, + ): Promise> { + const url = joinPaths( + this.getFilesHost(), + ownershipType === 'user' ? Paths.v1.startUploadSession : Paths.v1.startSharedVaultUploadSession, + ) return this.tokenRefreshableRequest({ verb: HttpVerb.Post, url, - customHeaders: [{ key: 'x-valet-token', value: apiToken }], + customHeaders: [{ key: 'x-valet-token', value: valetToken }], fallbackErrorMessage: Strings.Network.Files.FailedStartUploadSession, }) } - public async deleteFile(apiToken: string): Promise> { - const url = joinPaths(this.getFilesHost(), Paths.v1.deleteFile) + public async deleteFile( + valetToken: string, + ownershipType: FileOwnershipType, + ): Promise> { + const url = joinPaths( + this.getFilesHost(), + ownershipType === 'user' ? Paths.v1.deleteFile : Paths.v1.deleteSharedVaultFile, + ) return this.tokenRefreshableRequest({ verb: HttpVerb.Delete, url, - customHeaders: [{ key: 'x-valet-token', value: apiToken }], + customHeaders: [{ key: 'x-valet-token', value: valetToken }], fallbackErrorMessage: Strings.Network.Files.FailedDeleteFile, }) } - public async uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise { + public async uploadFileBytes( + valetToken: string, + ownershipType: FileOwnershipType, + chunkId: number, + encryptedBytes: Uint8Array, + ): Promise { if (chunkId === 0) { throw Error('chunkId must start with 1') } - const url = joinPaths(this.getFilesHost(), Paths.v1.uploadFileChunk) + const url = joinPaths( + this.getFilesHost(), + ownershipType === 'user' ? Paths.v1.uploadFileChunk : Paths.v1.uploadSharedVaultFileChunk, + ) const response = await this.tokenRefreshableRequest({ verb: HttpVerb.Post, url, rawBytes: encryptedBytes, customHeaders: [ - { key: 'x-valet-token', value: apiToken }, + { key: 'x-valet-token', value: valetToken }, { key: 'x-chunk-id', value: chunkId.toString() }, { key: 'Content-Type', value: 'application/octet-stream' }, ], @@ -764,13 +786,16 @@ export class SNApiService return response.data.success } - public async closeUploadSession(apiToken: string): Promise { - const url = joinPaths(this.getFilesHost(), Paths.v1.closeUploadSession) + public async closeUploadSession(valetToken: string, ownershipType: FileOwnershipType): Promise { + const url = joinPaths( + this.getFilesHost(), + ownershipType === 'user' ? Paths.v1.closeUploadSession : Paths.v1.closeSharedVaultUploadSession, + ) const response = await this.tokenRefreshableRequest({ verb: HttpVerb.Post, url, - customHeaders: [{ key: 'x-valet-token', value: apiToken }], + customHeaders: [{ key: 'x-valet-token', value: valetToken }], fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession, }) @@ -781,33 +806,61 @@ export class SNApiService return response.data.success } - public getFilesDownloadUrl(): string { - return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk) + public async moveFile(valetToken: string): Promise { + const url = joinPaths(this.getFilesHost(), Paths.v1.moveFile) + + const response = await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url, + customHeaders: [{ key: 'x-valet-token', value: valetToken }], + fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession, + }) + + if (isErrorResponse(response)) { + return false + } + + return response.data.success } - public async downloadFile( - file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] }, - chunkIndex = 0, - apiToken: string, - contentRangeStart: number, - onBytesReceived: (bytes: Uint8Array) => Promise, - ): Promise { - const url = this.getFilesDownloadUrl() + public getFilesDownloadUrl(ownershipType: FileOwnershipType): string { + if (ownershipType === 'user') { + return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk) + } else if (ownershipType === 'shared-vault') { + return joinPaths(this.getFilesHost(), Paths.v1.downloadSharedVaultFileChunk) + } else { + throw Error('Invalid download type') + } + } + + public async downloadFile({ + file, + chunkIndex, + valetToken, + ownershipType, + contentRangeStart, + onBytesReceived, + }: DownloadFileParams): Promise { + const url = this.getFilesDownloadUrl(ownershipType) const pullChunkSize = file.encryptedChunkSizes[chunkIndex] - const response = await this.tokenRefreshableRequest({ + const request: HttpRequest = { verb: HttpVerb.Get, url, customHeaders: [ - { key: 'x-valet-token', value: apiToken }, + { key: 'x-valet-token', value: valetToken }, { key: 'x-chunk-size', value: pullChunkSize.toString(), }, { key: 'range', value: `bytes=${contentRangeStart}-` }, ], - fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk, responseType: 'arraybuffer', + } + + const response = await this.tokenRefreshableRequest({ + ...request, + fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk, }) if (isErrorResponse(response)) { @@ -833,7 +886,14 @@ export class SNApiService await onBytesReceived(bytesReceived) if (rangeEnd < totalSize - 1) { - return this.downloadFile(file, ++chunkIndex, apiToken, rangeStart + pullChunkSize, onBytesReceived) + return this.downloadFile({ + file, + chunkIndex: ++chunkIndex, + valetToken, + ownershipType, + contentRangeStart: rangeStart + pullChunkSize, + onBytesReceived, + }) } return undefined @@ -889,19 +949,4 @@ export class SNApiService return this.session.accessToken } - - override getDiagnostics(): Promise { - return Promise.resolve({ - api: { - hasSession: this.session != undefined, - user: this.user, - registering: this.registering, - authenticating: this.authenticating, - changing: this.changing, - refreshingSession: this.refreshingSession, - filesHost: this.filesHost, - host: this.host, - }, - }) - } } diff --git a/packages/snjs/lib/Services/Api/Paths.ts b/packages/snjs/lib/Services/Api/Paths.ts index 331987108..815b555fa 100644 --- a/packages/snjs/lib/Services/Api/Paths.ts +++ b/packages/snjs/lib/Services/Api/Paths.ts @@ -1,12 +1,22 @@ const FilesPaths = { closeUploadSession: '/v1/files/upload/close-session', - createFileValetToken: '/v1/files/valet-tokens', + createUserFileValetToken: '/v1/files/valet-tokens', deleteFile: '/v1/files', downloadFileChunk: '/v1/files', + downloadVaultFileChunk: '/v1/vaults/files', startUploadSession: '/v1/files/upload/create-session', uploadFileChunk: '/v1/files/upload/chunk', } +const SharedVaultFilesPaths = { + closeSharedVaultUploadSession: '/v1/shared-vault/files/upload/close-session', + deleteSharedVaultFile: '/v1/shared-vault/files', + downloadSharedVaultFileChunk: '/v1/shared-vault/files', + startSharedVaultUploadSession: '/v1/shared-vault/files/upload/create-session', + uploadSharedVaultFileChunk: '/v1/shared-vault/files/upload/chunk', + moveFile: '/v1/shared-vault/files/move', +} + const UserPaths = { changeCredentials: (userUuid: string) => `/v1/users/${userUuid}/attributes/credentials`, deleteAccount: (userUuid: string) => `/v1/users/${userUuid}`, @@ -58,6 +68,7 @@ const ListedPaths = { export const Paths = { v1: { ...FilesPaths, + ...SharedVaultFilesPaths, ...ItemsPaths, ...ListedPaths, ...SettingsPaths, diff --git a/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts b/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts index d9f06164b..c7ac66b6e 100644 --- a/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts +++ b/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts @@ -1,7 +1,6 @@ -import { Challenge, ChallengeValue, ChallengeArtifacts } from '@standardnotes/services' +import { Challenge, ChallengeValue, ChallengeArtifacts, ChallengeValueCallback } from '@standardnotes/services' import { ChallengeResponse } from './ChallengeResponse' import { removeFromArray } from '@standardnotes/utils' -import { ValueCallback } from './ChallengeService' /** * A challenge operation stores user-submitted values and callbacks. @@ -15,8 +14,8 @@ export class ChallengeOperation { constructor( public challenge: Challenge, - public onValidValue: ValueCallback, - public onInvalidValue: ValueCallback, + public onValidValue: ChallengeValueCallback, + public onInvalidValue: ChallengeValueCallback, public onNonvalidatedSubmit: (response: ChallengeResponse) => void, public onComplete: (response: ChallengeResponse) => void, public onCancel: () => void, diff --git a/packages/snjs/lib/Services/Challenge/ChallengeService.ts b/packages/snjs/lib/Services/Challenge/ChallengeService.ts index d22103d8c..45f841f4d 100644 --- a/packages/snjs/lib/Services/Challenge/ChallengeService.ts +++ b/packages/snjs/lib/Services/Challenge/ChallengeService.ts @@ -16,6 +16,7 @@ import { ChallengePrompt, EncryptionService, ChallengeStrings, + ChallengeObserver, } from '@standardnotes/services' import { ChallengeResponse } from './ChallengeResponse' import { ChallengeOperation } from './ChallengeOperation' @@ -25,16 +26,6 @@ type ChallengeValidationResponse = { artifacts?: ChallengeArtifacts } -export type ValueCallback = (value: ChallengeValue) => void - -export type ChallengeObserver = { - onValidValue?: ValueCallback - onInvalidValue?: ValueCallback - onNonvalidatedSubmit?: (response: ChallengeResponse) => void - onComplete?: (response: ChallengeResponse) => void - onCancel?: () => void -} - const clearChallengeObserver = (observer: ChallengeObserver) => { observer.onCancel = undefined observer.onComplete = undefined @@ -112,7 +103,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic return value.value as string } - async promptForAccountPassword(): Promise { + async promptForAccountPassword(): Promise { if (!this.protocolService.hasAccount()) { throw Error('Requiring account password for challenge with no account') } @@ -126,11 +117,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic ), ) - if (response) { - return true - } else { - return false - } + return response?.getValueForType(ChallengeValidation.AccountPassword)?.value as string } /** @@ -175,7 +162,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic return this.protocolService.isPasscodeLocked() } - public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver) { + public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver): () => void { const observers = this.challengeObservers[challenge.id] || [] observers.push(observer) @@ -303,11 +290,11 @@ export class ChallengeService extends AbstractService implements ChallengeServic } public setValidationStatusForChallenge( - challenge: Challenge, + challenge: ChallengeInterface, value: ChallengeValue, valid: boolean, artifacts?: ChallengeArtifacts, - ) { + ): void { const operation = this.getChallengeOperation(challenge) operation.setValueStatus(value, valid, artifacts) diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts index f5454bb16..5fa9a6096 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts @@ -19,6 +19,7 @@ import { InternalEventBusInterface, AlertService, DeviceInterface, + MutatorClientInterface, } from '@standardnotes/services' import { ItemManager } from '@Lib/Services/Items/ItemManager' import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' @@ -27,6 +28,7 @@ import { SNSyncService } from '../Sync/SyncService' describe('featuresService', () => { let itemManager: ItemManager + let mutator: MutatorClientInterface let featureService: SNFeaturesService let alertService: AlertService let syncService: SNSyncService @@ -52,6 +54,7 @@ describe('featuresService', () => { const manager = new SNComponentManager( itemManager, + mutator, syncService, featureService, prefsService, @@ -71,12 +74,14 @@ describe('featuresService', () => { itemManager = {} as jest.Mocked itemManager.getItems = jest.fn().mockReturnValue([]) - itemManager.createItem = jest.fn() - itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) - itemManager.setItemsToBeDeleted = jest.fn() itemManager.addObserver = jest.fn() - itemManager.changeItem = jest.fn() - itemManager.changeFeatureRepo = jest.fn() + + mutator = {} as jest.Mocked + mutator.createItem = jest.fn() + mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + mutator.setItemsToBeDeleted = jest.fn() + mutator.changeItem = jest.fn() + mutator.changeFeatureRepo = jest.fn() featureService = {} as jest.Mocked diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts index ae38c6976..57fb5ea76 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts @@ -39,6 +39,7 @@ import { AlertService, DeviceInterface, isMobileDevice, + MutatorClientInterface, } from '@standardnotes/services' const DESKTOP_URL_PREFIX = 'sn://' @@ -78,6 +79,7 @@ export class SNComponentManager constructor( private itemManager: ItemManager, + private mutator: MutatorClientInterface, private syncService: SNSyncService, private featuresService: SNFeaturesService, private preferencesSerivce: SNPreferencesService, @@ -162,6 +164,7 @@ export class SNComponentManager const viewer = new ComponentViewer( component, this.itemManager, + this.mutator, this.syncService, this.alertService, this.preferencesSerivce, @@ -482,7 +485,7 @@ export class SNComponentManager } } - await this.itemManager.changeItem(component, (m) => { + await this.mutator.changeItem(component, (m) => { const mutator = m as ComponentMutator mutator.permissions = componentPermissions }) @@ -546,14 +549,14 @@ export class SNComponentManager const theme = this.findComponent(uuid) as SNTheme if (theme.active) { - await this.itemManager.changeComponent(theme, (mutator) => { + await this.mutator.changeComponent(theme, (mutator) => { mutator.active = false }) } else { const activeThemes = this.getActiveThemes() /* Activate current before deactivating others, so as not to flicker */ - await this.itemManager.changeComponent(theme, (mutator) => { + await this.mutator.changeComponent(theme, (mutator) => { mutator.active = true }) @@ -562,13 +565,15 @@ export class SNComponentManager await sleep(10) for (const candidate of activeThemes) { if (candidate && !candidate.isLayerable()) { - await this.itemManager.changeComponent(candidate, (mutator) => { + await this.mutator.changeComponent(candidate, (mutator) => { mutator.active = false }) } } } } + + void this.syncService.sync() } async toggleComponent(uuid: UuidString): Promise { @@ -580,9 +585,11 @@ export class SNComponentManager return } - await this.itemManager.changeComponent(component, (mutator) => { + await this.mutator.changeComponent(component, (mutator) => { mutator.active = !(mutator.getItem() as SNComponent).active }) + + void this.syncService.sync() } isComponentActive(component: SNComponent): boolean { diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts index e900c6d72..e3182f1f7 100644 --- a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts @@ -5,6 +5,7 @@ import { FeatureStatus, FeaturesEvent, AlertService, + MutatorClientInterface, } from '@standardnotes/services' import { SNFeaturesService } from '@Lib/Services' import { @@ -109,6 +110,7 @@ export class ComponentViewer implements ComponentViewerInterface { constructor( public readonly component: SNComponent, private itemManager: ItemManager, + private mutator: MutatorClientInterface, private syncService: SNSyncService, private alertService: AlertService, private preferencesSerivce: SNPreferencesService, @@ -719,7 +721,7 @@ export class ComponentViewer implements ComponentViewerInterface { ...contextualPayload, }) const template = CreateDecryptedItemFromPayload(payload) - await this.itemManager.insertItem(template) + await this.mutator.insertItem(template) } else { if (contextualPayload.content_type !== item.content_type) { throw Error('Extension is trying to modify content type of item.') @@ -727,7 +729,7 @@ export class ComponentViewer implements ComponentViewerInterface { } } - await this.itemManager.changeItems( + await this.mutator.changeItems( items.filter(isNotUndefined), (mutator) => { const contextualPayload = sureSearchArray(contextualPayloads, { @@ -798,9 +800,9 @@ export class ComponentViewer implements ComponentViewerInterface { }) const template = CreateDecryptedItemFromPayload(payload) - const item = await this.itemManager.insertItem(template) + const item = await this.mutator.insertItem(template) - await this.itemManager.changeItem( + await this.mutator.changeItem( item, (mutator) => { if (responseItem.clientData) { @@ -857,7 +859,7 @@ export class ComponentViewer implements ComponentViewerInterface { void this.alertService.alert('The item you are trying to delete cannot be found.') continue } - await this.itemManager.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved) + await this.mutator.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved) } void this.syncService.sync() @@ -875,7 +877,7 @@ export class ComponentViewer implements ComponentViewerInterface { handleSetComponentDataMessage(message: ComponentMessage): void { const noPermissionsRequired: ComponentPermission[] = [] this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => { - await this.itemManager.changeComponent(this.component, (mutator) => { + await this.mutator.changeComponent(this.component, (mutator) => { mutator.componentData = message.data.componentData || {} }) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts index a9adfa606..7074f89f8 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -14,6 +14,7 @@ import { FeaturesEvent, FeatureStatus, InternalEventBusInterface, + MutatorClientInterface, StorageKey, UserService, } from '@standardnotes/services' @@ -25,6 +26,7 @@ describe('featuresService', () => { let storageService: DiskStorageService let apiService: SNApiService let itemManager: ItemManager + let mutator: MutatorClientInterface let webSocketsService: SNWebSocketsService let settingsService: SNSettingsService let userService: UserService @@ -46,6 +48,7 @@ describe('featuresService', () => { storageService, apiService, itemManager, + mutator, webSocketsService, settingsService, userService, @@ -95,13 +98,15 @@ describe('featuresService', () => { itemManager = {} as jest.Mocked itemManager.getItems = jest.fn().mockReturnValue(items) - itemManager.createItem = jest.fn() itemManager.createTemplateItem = jest.fn().mockReturnValue({}) - itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) - itemManager.setItemsToBeDeleted = jest.fn() itemManager.addObserver = jest.fn() - itemManager.changeItem = jest.fn() - itemManager.changeFeatureRepo = jest.fn() + + mutator = {} as jest.Mocked + mutator.createItem = jest.fn() + mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + mutator.setItemsToBeDeleted = jest.fn() + mutator.changeItem = jest.fn() + mutator.changeFeatureRepo = jest.fn() webSocketsService = {} as jest.Mocked webSocketsService.addEventObserver = jest.fn() @@ -173,7 +178,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).not.toHaveBeenCalled() + expect(mutator.createItem).not.toHaveBeenCalled() }) it('does create a component for enabled experimental feature', async () => { @@ -196,7 +201,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).toHaveBeenCalled() + expect(mutator.createItem).toHaveBeenCalled() }) }) @@ -300,8 +305,8 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).toHaveBeenCalledTimes(2) - expect(itemManager.createItem).toHaveBeenCalledWith( + expect(mutator.createItem).toHaveBeenCalledTimes(2) + expect(mutator.createItem).toHaveBeenCalledWith( ContentType.Theme, expect.objectContaining({ package_info: expect.objectContaining({ @@ -312,7 +317,7 @@ describe('featuresService', () => { }), true, ) - expect(itemManager.createItem).toHaveBeenCalledWith( + expect(mutator.createItem).toHaveBeenCalledWith( ContentType.Component, expect.objectContaining({ package_info: expect.objectContaining({ @@ -346,7 +351,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function)) + expect(mutator.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function)) }) it('creates items for expired components if they do not exist', async () => { @@ -373,7 +378,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).toHaveBeenCalledWith( + expect(mutator.createItem).toHaveBeenCalledWith( ContentType.Component, expect.objectContaining({ package_info: expect.objectContaining({ @@ -403,7 +408,7 @@ describe('featuresService', () => { const now = new Date() const yesterday = now.setDate(now.getDate() - 1) - itemManager.changeComponent = jest.fn().mockReturnValue(existingItem) + mutator.changeComponent = jest.fn().mockReturnValue(existingItem) storageService.getValue = jest.fn().mockReturnValue(roles) itemManager.getItems = jest.fn().mockReturnValue([existingItem]) apiService.getUserFeatures = jest.fn().mockReturnValue({ @@ -422,7 +427,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem]) + expect(mutator.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem]) }) it('does not create an item for a feature without content type', async () => { @@ -447,7 +452,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).not.toHaveBeenCalled() + expect(mutator.createItem).not.toHaveBeenCalled() }) it('does not create an item for deprecated features', async () => { @@ -472,7 +477,7 @@ describe('featuresService', () => { const { didChangeRoles } = await featuresService.updateOnlineRoles(newRoles) await featuresService.fetchFeatures('123', didChangeRoles) - expect(itemManager.createItem).not.toHaveBeenCalled() + expect(mutator.createItem).not.toHaveBeenCalled() }) it('does nothing after initial update if roles have not changed', async () => { diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index bcbb997bf..aee6833bd 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -48,6 +48,7 @@ import { SetOfflineFeaturesFunctionResponse, StorageKey, UserService, + MutatorClientInterface, } from '@standardnotes/services' import { FeatureIdentifier } from '@standardnotes/features' @@ -72,7 +73,8 @@ export class SNFeaturesService private storageService: DiskStorageService, private apiService: SNApiService, private itemManager: ItemManager, - private webSocketsService: SNWebSocketsService, + private mutator: MutatorClientInterface, + webSocketsService: SNWebSocketsService, private settingsService: SNSettingsService, private userService: UserService, private syncService: SNSyncService, @@ -188,7 +190,7 @@ export class SNFeaturesService if (existingItem) { const hasChange = JSON.stringify(feature) !== JSON.stringify(existingItem.package_info) if (hasChange) { - await this.itemManager.changeComponent(existingItem, (mutator) => { + await this.mutator.changeComponent(existingItem, (mutator) => { mutator.package_info = feature }) } @@ -196,7 +198,7 @@ export class SNFeaturesService continue } - await this.itemManager.createItem( + await this.mutator.createItem( feature.content_type, this.componentContentForNativeFeatureDescription(feature), true, @@ -230,7 +232,7 @@ export class SNFeaturesService return } - void this.itemManager.setItemToBeDeleted(component).then(() => { + void this.mutator.setItemToBeDeleted(component).then(() => { void this.syncService.sync() }) void this.notifyEvent(FeaturesEvent.FeaturesUpdated) @@ -270,7 +272,7 @@ export class SNFeaturesService return result } - const offlineRepo = (await this.itemManager.createItem( + const offlineRepo = (await this.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ offlineFeaturesUrl: result.featuresUrl, @@ -298,7 +300,7 @@ export class SNFeaturesService public async deleteOfflineFeatureRepo(): Promise { const repo = this.getOfflineRepo() if (repo) { - await this.itemManager.setItemToBeDeleted(repo) + await this.mutator.setItemToBeDeleted(repo) void this.syncService.sync() } await this.storageService.removeValue(StorageKey.UserFeatures) @@ -346,7 +348,7 @@ export class SNFeaturesService userKey, true, ) - await this.itemManager.changeFeatureRepo(item, (m) => { + await this.mutator.changeFeatureRepo(item, (m) => { m.migratedToUserSetting = true }) } @@ -371,7 +373,7 @@ export class SNFeaturesService const userKeyMatch = repoUrl.match(/\w{32,64}/) if (userKeyMatch && userKeyMatch.length > 0) { const userKey = userKeyMatch[0] - const updatedRepo = await this.itemManager.changeFeatureRepo(item, (m) => { + const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => { m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL m.offlineKey = userKey m.migratedToOfflineEntitlements = true @@ -647,7 +649,7 @@ export class SNFeaturesService } } - await this.itemManager.setItemsToBeDeleted(itemsToDelete) + await this.mutator.setItemsToBeDeleted(itemsToDelete) if (hasChanges) { void this.syncService.sync() @@ -704,7 +706,7 @@ export class SNFeaturesService const hasChange = hasChangeInPackageInfo || hasChangeInExpiration if (hasChange) { - resultingItem = await this.itemManager.changeComponent(existingItem, (mutator) => { + resultingItem = await this.mutator.changeComponent(existingItem, (mutator) => { mutator.package_info = feature mutator.valid_until = featureExpiresAt }) @@ -714,7 +716,7 @@ export class SNFeaturesService resultingItem = existingItem } } else if (!expired || feature.content_type === ContentType.Component) { - resultingItem = (await this.itemManager.createItem( + resultingItem = (await this.mutator.createItem( feature.content_type, this.componentContentForNativeFeatureDescription(feature), true, @@ -835,7 +837,7 @@ export class SNFeaturesService ;(this.storageService as unknown) = undefined ;(this.apiService as unknown) = undefined ;(this.itemManager as unknown) = undefined - ;(this.webSocketsService as unknown) = undefined + ;(this.mutator as unknown) = undefined ;(this.settingsService as unknown) = undefined ;(this.userService as unknown) = undefined ;(this.syncService as unknown) = undefined diff --git a/packages/snjs/lib/Services/Items/ItemManager.spec.ts b/packages/snjs/lib/Services/Items/ItemManager.spec.ts index b0214aa38..bdc07e3ed 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.spec.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.spec.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' -import { InternalEventBusInterface, ItemRelationshipDirection } from '@standardnotes/services' +import { AlertService, InternalEventBusInterface, ItemRelationshipDirection } from '@standardnotes/services' import { ItemManager } from './ItemManager' import { PayloadManager } from '../Payloads/PayloadManager' -import { UuidGenerator } from '@standardnotes/utils' +import { UuidGenerator, assert } from '@standardnotes/utils' import * as Models from '@standardnotes/models' import { DecryptedPayload, @@ -15,6 +15,7 @@ import { SystemViewId, } from '@standardnotes/models' import { createNoteWithTitle } from '../../Spec/SpecUtils' +import { MutatorService } from '../Mutator' const setupRandomUuid = () => { UuidGenerator.SetGenerator(() => String(Math.random())) @@ -43,15 +44,11 @@ const LongTextPredicate = Models.predicateFromJson({ }) describe('itemManager', () => { + let mutator: MutatorService let payloadManager: PayloadManager let itemManager: ItemManager - let items: Models.DecryptedItemInterface[] let internalEventBus: InternalEventBusInterface - const createService = () => { - return new ItemManager(payloadManager, internalEventBus) - } - beforeEach(() => { setupRandomUuid() @@ -59,16 +56,9 @@ describe('itemManager', () => { internalEventBus.publish = jest.fn() payloadManager = new PayloadManager(internalEventBus) + itemManager = new ItemManager(payloadManager, internalEventBus) - items = [] as jest.Mocked - itemManager = {} as jest.Mocked - itemManager.getItems = jest.fn().mockReturnValue(items) - itemManager.createItem = jest.fn() - itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) - itemManager.setItemsToBeDeleted = jest.fn() - itemManager.addObserver = jest.fn() - itemManager.changeItem = jest.fn() - itemManager.changeFeatureRepo = jest.fn() + mutator = new MutatorService(itemManager, payloadManager, {} as jest.Mocked, internalEventBus) }) const createTag = (title: string) => { @@ -99,8 +89,6 @@ describe('itemManager', () => { describe('item emit', () => { it('deleted payloads should map to removed items', async () => { - itemManager = createService() - const payload = new DeletedPayload({ uuid: String(Math.random()), content_type: ContentType.Note, @@ -120,8 +108,6 @@ describe('itemManager', () => { }) it('decrypted items who become encrypted should be removed from ui', async () => { - itemManager = createService() - const decrypted = new DecryptedPayload({ uuid: String(Math.random()), content_type: ContentType.Note, @@ -154,11 +140,10 @@ describe('itemManager', () => { describe('note display criteria', () => { it('viewing notes with tag', async () => { - itemManager = createService() const tag = createTag('parent') const note = createNoteWithTitle('note') - await itemManager.insertItems([tag, note]) - await itemManager.addTagToNote(note, tag, false) + await mutator.insertItems([tag, note]) + await mutator.addTagToNote(note, tag, false) itemManager.setPrimaryItemDisplayOptions({ tags: [tag], @@ -171,21 +156,19 @@ describe('itemManager', () => { }) it('viewing trashed notes smart view should include archived notes', async () => { - itemManager = createService() - const archivedNote = createNoteWithTitle('archived') const trashedNote = createNoteWithTitle('trashed') const archivedAndTrashedNote = createNoteWithTitle('archived&trashed') - await itemManager.insertItems([archivedNote, trashedNote, archivedAndTrashedNote]) + await mutator.insertItems([archivedNote, trashedNote, archivedAndTrashedNote]) - await itemManager.changeItem(archivedNote, (m) => { + await mutator.changeItem(archivedNote, (m) => { m.archived = true }) - await itemManager.changeItem(trashedNote, (m) => { + await mutator.changeItem(trashedNote, (m) => { m.trashed = true }) - await itemManager.changeItem(archivedAndTrashedNote, (m) => { + await mutator.changeItem(archivedAndTrashedNote, (m) => { m.trashed = true m.archived = true }) @@ -206,58 +189,53 @@ describe('itemManager', () => { describe('tag relationships', () => { it('updates parentId of child tag', async () => { - itemManager = createService() const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([parent, child]) - await itemManager.setTagParent(parent, child) + await mutator.insertItems([parent, child]) + await mutator.setTagParent(parent, child) const changedChild = itemManager.findItem(child.uuid) as Models.SNTag expect(changedChild.parentId).toBe(parent.uuid) }) it('forbids a tag to be its own parent', async () => { - itemManager = createService() const tag = createTag('tag') - await itemManager.insertItems([tag]) + await mutator.insertItems([tag]) - expect(() => itemManager.setTagParent(tag, tag)).toThrow() + await expect(mutator.setTagParent(tag, tag)).rejects.toThrow() expect(itemManager.getTagParent(tag)).toBeUndefined() }) it('forbids a tag to be its own ancestor', async () => { - itemManager = createService() const grandParent = createTag('grandParent') const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([child, parent, grandParent]) - await itemManager.setTagParent(parent, child) - await itemManager.setTagParent(grandParent, parent) + await mutator.insertItems([child, parent, grandParent]) + await mutator.setTagParent(parent, child) + await mutator.setTagParent(grandParent, parent) - expect(() => itemManager.setTagParent(child, grandParent)).toThrow() + await expect(mutator.setTagParent(child, grandParent)).rejects.toThrow() expect(itemManager.getTagParent(grandParent)).toBeUndefined() }) it('getTagParent', async () => { - itemManager = createService() const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([parent, child]) - await itemManager.setTagParent(parent, child) + await mutator.insertItems([parent, child]) + await mutator.setTagParent(parent, child) expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid) }) it('findTagByTitleAndParent', async () => { - itemManager = createService() const parent = createTag('name1') const child = createTag('childName') const duplicateNameChild = createTag('name1') - await itemManager.insertItems([parent, child, duplicateNameChild]) - await itemManager.setTagParent(parent, child) - await itemManager.setTagParent(parent, duplicateNameChild) + await mutator.insertItems([parent, child, duplicateNameChild]) + await mutator.setTagParent(parent, child) + await mutator.setTagParent(parent, duplicateNameChild) const a = itemManager.findTagByTitleAndParent('name1', undefined) const b = itemManager.findTagByTitleAndParent('name1', parent) @@ -270,16 +248,16 @@ describe('itemManager', () => { it('findOrCreateTagByTitle', async () => { setupRandomUuid() - itemManager = createService() + const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([parent, child]) - await itemManager.setTagParent(parent, child) + await mutator.insertItems([parent, child]) + await mutator.setTagParent(parent, child) - const childA = await itemManager.findOrCreateTagByTitle('child') - const childB = await itemManager.findOrCreateTagByTitle('child', parent) - const childC = await itemManager.findOrCreateTagByTitle('child-bis', parent) - const childD = await itemManager.findOrCreateTagByTitle('child-bis', parent) + const childA = await mutator.findOrCreateTagByTitle({ title: 'child' }) + const childB = await mutator.findOrCreateTagByTitle({ title: 'child', parentItemToLookupUuidFor: parent }) + const childC = await mutator.findOrCreateTagByTitle({ title: 'child-bis', parentItemToLookupUuidFor: parent }) + const childD = await mutator.findOrCreateTagByTitle({ title: 'child-bis', parentItemToLookupUuidFor: parent }) expect(childA.uuid).not.toEqual(child.uuid) expect(childB.uuid).toEqual(child.uuid) @@ -292,17 +270,16 @@ describe('itemManager', () => { }) it('findOrCreateTagParentChain', async () => { - itemManager = createService() const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([parent, child]) - await itemManager.setTagParent(parent, child) + await mutator.insertItems([parent, child]) + await mutator.setTagParent(parent, child) - const a = await itemManager.findOrCreateTagParentChain(['parent']) - const b = await itemManager.findOrCreateTagParentChain(['parent', 'child']) - const c = await itemManager.findOrCreateTagParentChain(['parent', 'child2']) - const d = await itemManager.findOrCreateTagParentChain(['parent2', 'child1']) + const a = await mutator.findOrCreateTagParentChain(['parent']) + const b = await mutator.findOrCreateTagParentChain(['parent', 'child']) + const c = await mutator.findOrCreateTagParentChain(['parent', 'child2']) + const d = await mutator.findOrCreateTagParentChain(['parent2', 'child1']) expect(a?.uuid).toEqual(parent.uuid) expect(b?.uuid).toEqual(child.uuid) @@ -317,15 +294,14 @@ describe('itemManager', () => { }) it('isAncestor', async () => { - itemManager = createService() const grandParent = createTag('grandParent') const parent = createTag('parent') const child = createTag('child') const another = createTag('another') - await itemManager.insertItems([child, parent, grandParent, another]) - await itemManager.setTagParent(parent, child) - await itemManager.setTagParent(grandParent, parent) + await mutator.insertItems([child, parent, grandParent, another]) + await mutator.setTagParent(parent, child) + await mutator.setTagParent(grandParent, parent) expect(itemManager.isTagAncestor(grandParent, parent)).toEqual(true) expect(itemManager.isTagAncestor(grandParent, child)).toEqual(true) @@ -341,28 +317,26 @@ describe('itemManager', () => { }) it('unsetTagRelationship', async () => { - itemManager = createService() const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([parent, child]) - await itemManager.setTagParent(parent, child) + await mutator.insertItems([parent, child]) + await mutator.setTagParent(parent, child) expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid) - await itemManager.unsetTagParent(child) + await mutator.unsetTagParent(child) expect(itemManager.getTagParent(child)).toBeUndefined() }) it('getTagParentChain', async () => { - itemManager = createService() const greatGrandParent = createTag('greatGrandParent') const grandParent = createTag('grandParent') const parent = createTag('parent') const child = createTag('child') - await itemManager.insertItems([greatGrandParent, grandParent, parent, child]) - await itemManager.setTagParent(parent, child) - await itemManager.setTagParent(grandParent, parent) - await itemManager.setTagParent(greatGrandParent, grandParent) + await mutator.insertItems([greatGrandParent, grandParent, parent, child]) + await mutator.setTagParent(parent, child) + await mutator.setTagParent(grandParent, parent) + await mutator.setTagParent(greatGrandParent, grandParent) const uuidChain = itemManager.getTagParentChain(child).map((tag) => tag.uuid) @@ -371,18 +345,17 @@ describe('itemManager', () => { }) it('viewing notes for parent tag should not display notes of children', async () => { - itemManager = createService() const parentTag = createTag('parent') const childTag = createTag('child') - await itemManager.insertItems([parentTag, childTag]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag]) + await mutator.setTagParent(parentTag, childTag) const parentNote = createNoteWithTitle('parentNote') const childNote = createNoteWithTitle('childNote') - await itemManager.insertItems([parentNote, childNote]) + await mutator.insertItems([parentNote, childNote]) - await itemManager.addTagToNote(parentNote, parentTag, false) - await itemManager.addTagToNote(childNote, childTag, false) + await mutator.addTagToNote(parentNote, parentTag, false) + await mutator.addTagToNote(childNote, childTag, false) itemManager.setPrimaryItemDisplayOptions({ tags: [parentTag], @@ -397,7 +370,6 @@ describe('itemManager', () => { describe('template items', () => { it('create template item', async () => { - itemManager = createService() setupRandomUuid() const item = await itemManager.createTemplateItem(ContentType.Note, { @@ -412,7 +384,6 @@ describe('itemManager', () => { }) it('isTemplateItem return the correct value', async () => { - itemManager = createService() setupRandomUuid() const item = await itemManager.createTemplateItem(ContentType.Note, { @@ -422,13 +393,12 @@ describe('itemManager', () => { expect(itemManager.isTemplateItem(item)).toEqual(true) - await itemManager.insertItem(item) + await mutator.insertItem(item) expect(itemManager.isTemplateItem(item)).toEqual(false) }) it('isTemplateItem return the correct value for system smart views', () => { - itemManager = createService() setupRandomUuid() const [systemTag1, ...restOfSystemViews] = itemManager @@ -445,29 +415,27 @@ describe('itemManager', () => { describe('tags', () => { it('lets me create a regular tag with a clear API', async () => { - itemManager = createService() setupRandomUuid() - const tag = await itemManager.createTag('this is my new tag') + const tag = await mutator.createTag({ title: 'this is my new tag' }) expect(tag).toBeTruthy() expect(itemManager.isTemplateItem(tag)).toEqual(false) }) it('should search tags correctly', async () => { - itemManager = createService() setupRandomUuid() - const foo = await itemManager.createTag('foo[') - const foobar = await itemManager.createTag('foo[bar]') - const bar = await itemManager.createTag('bar[') - const barfoo = await itemManager.createTag('bar[foo]') - const fooDelimiter = await itemManager.createTag('bar.foo') - const barFooDelimiter = await itemManager.createTag('baz.bar.foo') - const fooAttached = await itemManager.createTag('Foo') + const foo = await mutator.createTag({ title: 'foo[' }) + const foobar = await mutator.createTag({ title: 'foo[bar]' }) + const bar = await mutator.createTag({ title: 'bar[' }) + const barfoo = await mutator.createTag({ title: 'bar[foo]' }) + const fooDelimiter = await mutator.createTag({ title: 'bar.foo' }) + const barFooDelimiter = await mutator.createTag({ title: 'baz.bar.foo' }) + const fooAttached = await mutator.createTag({ title: 'Foo' }) const note = createNoteWithTitle('note') - await itemManager.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note]) - await itemManager.addTagToNote(note, fooAttached, false) + await mutator.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note]) + await mutator.addTagToNote(note, fooAttached, false) const fooResults = itemManager.searchTags('foo') expect(fooResults).toContainEqual(foo) @@ -482,19 +450,17 @@ describe('itemManager', () => { describe('tags notes index', () => { it('counts countable notes', async () => { - itemManager = createService() - const parentTag = createTag('parent') const childTag = createTag('child') - await itemManager.insertItems([parentTag, childTag]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag]) + await mutator.setTagParent(parentTag, childTag) const parentNote = createNoteWithTitle('parentNote') const childNote = createNoteWithTitle('childNote') - await itemManager.insertItems([parentNote, childNote]) + await mutator.insertItems([parentNote, childNote]) - await itemManager.addTagToNote(parentNote, parentTag, false) - await itemManager.addTagToNote(childNote, childTag, false) + await mutator.addTagToNote(parentNote, parentTag, false) + await mutator.addTagToNote(childNote, childTag, false) expect(itemManager.countableNotesForTag(parentTag)).toBe(1) expect(itemManager.countableNotesForTag(childTag)).toBe(1) @@ -502,29 +468,27 @@ describe('itemManager', () => { }) it('archiving a note should update count index', async () => { - itemManager = createService() - const tag1 = createTag('tag 1') - await itemManager.insertItems([tag1]) + await mutator.insertItems([tag1]) const note1 = createNoteWithTitle('note 1') const note2 = createNoteWithTitle('note 2') - await itemManager.insertItems([note1, note2]) + await mutator.insertItems([note1, note2]) - await itemManager.addTagToNote(note1, tag1, false) - await itemManager.addTagToNote(note2, tag1, false) + await mutator.addTagToNote(note1, tag1, false) + await mutator.addTagToNote(note2, tag1, false) expect(itemManager.countableNotesForTag(tag1)).toBe(2) expect(itemManager.allCountableNotesCount()).toBe(2) - await itemManager.changeItem(note1, (m) => { + await mutator.changeItem(note1, (m) => { m.archived = true }) expect(itemManager.allCountableNotesCount()).toBe(1) expect(itemManager.countableNotesForTag(tag1)).toBe(1) - await itemManager.changeItem(note1, (m) => { + await mutator.changeItem(note1, (m) => { m.archived = false }) @@ -535,13 +499,12 @@ describe('itemManager', () => { describe('smart views', () => { it('lets me create a smart view', async () => { - itemManager = createService() setupRandomUuid() const [view1, view2, view3] = await Promise.all([ - itemManager.createSmartView('Not Pinned', NotPinnedPredicate), - itemManager.createSmartView('Last Day', LastDayPredicate), - itemManager.createSmartView('Long', LongTextPredicate), + mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate }), + mutator.createSmartView({ title: 'Last Day', predicate: LastDayPredicate }), + mutator.createSmartView({ title: 'Long', predicate: LongTextPredicate }), ]) expect(view1).toBeTruthy() @@ -554,10 +517,9 @@ describe('itemManager', () => { }) it('lets me use a smart view', async () => { - itemManager = createService() setupRandomUuid() - const view = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + const view = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate }) const notes = itemManager.notesMatchingSmartView(view) @@ -565,7 +527,6 @@ describe('itemManager', () => { }) it('lets me test if a title is a smart view', () => { - itemManager = createService() setupRandomUuid() expect(itemManager.isSmartViewTitle(VIEW_NOT_PINNED)).toEqual(true) @@ -577,13 +538,12 @@ describe('itemManager', () => { }) it('lets me create a smart view from the DSL', async () => { - itemManager = createService() setupRandomUuid() const [tag1, tag2, tag3] = await Promise.all([ - itemManager.createSmartViewFromDSL(VIEW_NOT_PINNED), - itemManager.createSmartViewFromDSL(VIEW_LAST_DAY), - itemManager.createSmartViewFromDSL(VIEW_LONG), + mutator.createSmartViewFromDSL(VIEW_NOT_PINNED), + mutator.createSmartViewFromDSL(VIEW_LAST_DAY), + mutator.createSmartViewFromDSL(VIEW_LONG), ]) expect(tag1).toBeTruthy() @@ -596,11 +556,10 @@ describe('itemManager', () => { }) it('will create smart view or tags from the generic method', async () => { - itemManager = createService() setupRandomUuid() - const someTag = await itemManager.createTagOrSmartView('some-tag') - const someView = await itemManager.createTagOrSmartView(VIEW_LONG) + const someTag = await mutator.createTagOrSmartView('some-tag') + const someView = await mutator.createTagOrSmartView(VIEW_LONG) expect(someTag.content_type).toEqual(ContentType.Tag) expect(someView.content_type).toEqual(ContentType.SmartView) @@ -608,12 +567,11 @@ describe('itemManager', () => { }) it('lets me rename a smart view', async () => { - itemManager = createService() setupRandomUuid() - const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + const tag = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate }) - await itemManager.changeItem(tag, (m) => { + await mutator.changeItem(tag, (m) => { m.title = 'New Title' }) @@ -625,10 +583,9 @@ describe('itemManager', () => { }) it('lets me find a smart view', async () => { - itemManager = createService() setupRandomUuid() - const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + const tag = await mutator.createSmartView({ title: 'Not Pinned', predicate: NotPinnedPredicate }) const view = itemManager.findItem(tag.uuid) as Models.SmartView @@ -636,7 +593,6 @@ describe('itemManager', () => { }) it('untagged notes smart view', async () => { - itemManager = createService() setupRandomUuid() const view = itemManager.untaggedNotesSmartView @@ -644,11 +600,11 @@ describe('itemManager', () => { const tag = createTag('tag') const untaggedNote = createNoteWithTitle('note') const taggedNote = createNoteWithTitle('taggedNote') - await itemManager.insertItems([tag, untaggedNote, taggedNote]) + await mutator.insertItems([tag, untaggedNote, taggedNote]) expect(itemManager.notesMatchingSmartView(view)).toHaveLength(2) - await itemManager.addTagToNote(taggedNote, tag, false) + await mutator.addTagToNote(taggedNote, tag, false) expect(itemManager.notesMatchingSmartView(view)).toHaveLength(1) @@ -657,31 +613,28 @@ describe('itemManager', () => { describe('files', () => { it('should correctly rename file to filename that has extension', async () => { - itemManager = createService() const file = createFile('initialName.ext') - await itemManager.insertItems([file]) + await mutator.insertItems([file]) - const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt') + const renamedFile = await mutator.renameFile(file, 'anotherName.anotherExt') expect(renamedFile.name).toBe('anotherName.anotherExt') }) it('should correctly rename extensionless file to filename that has extension', async () => { - itemManager = createService() const file = createFile('initialName') - await itemManager.insertItems([file]) + await mutator.insertItems([file]) - const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt') + const renamedFile = await mutator.renameFile(file, 'anotherName.anotherExt') expect(renamedFile.name).toBe('anotherName.anotherExt') }) it('should correctly rename file to filename that does not have extension', async () => { - itemManager = createService() const file = createFile('initialName.ext') - await itemManager.insertItems([file]) + await mutator.insertItems([file]) - const renamedFile = await itemManager.renameFile(file, 'anotherName') + const renamedFile = await mutator.renameFile(file, 'anotherName') expect(renamedFile.name).toBe('anotherName') }) @@ -689,15 +642,14 @@ describe('itemManager', () => { describe('linking', () => { it('adding a note to a tag hierarchy should add the note to its parent too', async () => { - itemManager = createService() const parentTag = createTag('parent') const childTag = createTag('child') const note = createNoteWithTitle('note') - await itemManager.insertItems([parentTag, childTag, note]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag, note]) + await mutator.setTagParent(parentTag, childTag) - await itemManager.addTagToNote(note, childTag, true) + await mutator.addTagToNote(note, childTag, true) const tags = itemManager.getSortedTagsForItem(note) @@ -707,15 +659,14 @@ describe('itemManager', () => { }) it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => { - itemManager = createService() const parentTag = createTag('parent') const childTag = createTag('child') const note = createNoteWithTitle('note') - await itemManager.insertItems([parentTag, childTag, note]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag, note]) + await mutator.setTagParent(parentTag, childTag) - await itemManager.addTagToNote(note, childTag, false) + await mutator.addTagToNote(note, childTag, false) const tags = itemManager.getSortedTagsForItem(note) @@ -724,15 +675,14 @@ describe('itemManager', () => { }) it('adding a file to a tag hierarchy should add the file to its parent too', async () => { - itemManager = createService() const parentTag = createTag('parent') const childTag = createTag('child') const file = createFile('file') - await itemManager.insertItems([parentTag, childTag, file]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag, file]) + await mutator.setTagParent(parentTag, childTag) - await itemManager.addTagToFile(file, childTag, true) + await mutator.addTagToFile(file, childTag, true) const tags = itemManager.getSortedTagsForItem(file) @@ -742,15 +692,14 @@ describe('itemManager', () => { }) it('adding a file to a tag hierarchy should not add the file to its parent if hierarchy option is disabled', async () => { - itemManager = createService() const parentTag = createTag('parent') const childTag = createTag('child') const file = createFile('file') - await itemManager.insertItems([parentTag, childTag, file]) - await itemManager.setTagParent(parentTag, childTag) + await mutator.insertItems([parentTag, childTag, file]) + await mutator.setTagParent(parentTag, childTag) - await itemManager.addTagToFile(file, childTag, false) + await mutator.addTagToFile(file, childTag, false) const tags = itemManager.getSortedTagsForItem(file) @@ -759,12 +708,12 @@ describe('itemManager', () => { }) it('should link file with note', async () => { - itemManager = createService() const note = createNoteWithTitle('invoices') const file = createFile('invoice_1.pdf') - await itemManager.insertItems([note, file]) + await mutator.insertItems([note, file]) - const resultingFile = await itemManager.associateFileWithNote(file, note) + const resultingFile = await mutator.associateFileWithNote(file, note) + assert(resultingFile) const references = resultingFile.references expect(references).toHaveLength(1) @@ -772,25 +721,24 @@ describe('itemManager', () => { }) it('should unlink file from note', async () => { - itemManager = createService() const note = createNoteWithTitle('invoices') const file = createFile('invoice_1.pdf') - await itemManager.insertItems([note, file]) + await mutator.insertItems([note, file]) - const associatedFile = await itemManager.associateFileWithNote(file, note) - const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note) + const associatedFile = await mutator.associateFileWithNote(file, note) + assert(associatedFile) + const disassociatedFile = await mutator.disassociateFileWithNote(associatedFile, note) const references = disassociatedFile.references expect(references).toHaveLength(0) }) it('should link note to note', async () => { - itemManager = createService() const note = createNoteWithTitle('research') const note2 = createNoteWithTitle('citation') - await itemManager.insertItems([note, note2]) + await mutator.insertItems([note, note2]) - const resultingNote = await itemManager.linkNoteToNote(note, note2) + const resultingNote = await mutator.linkNoteToNote(note, note2) const references = resultingNote.references expect(references).toHaveLength(1) @@ -798,12 +746,11 @@ describe('itemManager', () => { }) it('should link file to file', async () => { - itemManager = createService() const file = createFile('research') const file2 = createFile('citation') - await itemManager.insertItems([file, file2]) + await mutator.insertItems([file, file2]) - const resultingfile = await itemManager.linkFileToFile(file, file2) + const resultingfile = await mutator.linkFileToFile(file, file2) const references = resultingfile.references expect(references).toHaveLength(1) @@ -811,13 +758,12 @@ describe('itemManager', () => { }) it('should get the relationship type for two items', async () => { - itemManager = createService() const firstNote = createNoteWithTitle('First note') const secondNote = createNoteWithTitle('Second note') const unlinkedNote = createNoteWithTitle('Unlinked note') - await itemManager.insertItems([firstNote, secondNote, unlinkedNote]) + await mutator.insertItems([firstNote, secondNote, unlinkedNote]) - const firstNoteLinkedToSecond = await itemManager.linkNoteToNote(firstNote, secondNote) + const firstNoteLinkedToSecond = await mutator.linkNoteToNote(firstNote, secondNote) const relationshipOfFirstNoteToSecond = itemManager.relationshipDirectionBetweenItems( firstNoteLinkedToSecond, @@ -838,13 +784,12 @@ describe('itemManager', () => { }) it('should unlink itemOne from itemTwo if relation is direct', async () => { - itemManager = createService() const note = createNoteWithTitle('Note 1') const note2 = createNoteWithTitle('Note 2') - await itemManager.insertItems([note, note2]) + await mutator.insertItems([note, note2]) - const linkedItem = await itemManager.linkNoteToNote(note, note2) - const unlinkedItem = await itemManager.unlinkItems(linkedItem, note2) + const linkedItem = await mutator.linkNoteToNote(note, note2) + const unlinkedItem = await mutator.unlinkItems(linkedItem, note2) const references = unlinkedItem.references expect(unlinkedItem.uuid).toBe(note.uuid) @@ -852,13 +797,12 @@ describe('itemManager', () => { }) it('should unlink itemTwo from itemOne if relation is indirect', async () => { - itemManager = createService() const note = createNoteWithTitle('Note 1') const note2 = createNoteWithTitle('Note 2') - await itemManager.insertItems([note, note2]) + await mutator.insertItems([note, note2]) - const linkedItem = await itemManager.linkNoteToNote(note, note2) - const changedItem = await itemManager.unlinkItems(linkedItem, note2) + const linkedItem = await mutator.linkNoteToNote(note, note2) + const changedItem = await mutator.unlinkItems(linkedItem, note2) expect(changedItem.uuid).toBe(note.uuid) expect(changedItem.references).toHaveLength(0) diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index ae0188778..ad541c063 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -1,14 +1,13 @@ import { ContentType } from '@standardnotes/common' import { assert, naturalSort, removeFromArray, UuidGenerator, Uuids } from '@standardnotes/utils' -import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption' +import { SNItemsKey } from '@standardnotes/encryption' import { PayloadManager } from '../Payloads/PayloadManager' import { TagsToFoldersMigrationApplicator } from '../../Migrations/Applicators/TagsToFolders' import { UuidString } from '../../Types/UuidString' import * as Models from '@standardnotes/models' import * as Services from '@standardnotes/services' import { PayloadManagerChangeData } from '../Payloads' -import { DiagnosticInfo, ItemsClientInterface, ItemRelationshipDirection } from '@standardnotes/services' -import { CollectionSort, DecryptedItemInterface, ItemContent, SmartViewDefaultIconName } from '@standardnotes/models' +import { ItemRelationshipDirection } from '@standardnotes/services' type ItemsChangeObserver = { contentType: ContentType[] @@ -23,18 +22,18 @@ type ItemsChangeObserver void private observers: ItemsChangeObserver[] = [] private collection!: Models.ItemCollection private systemSmartViews: Models.SmartView[] - private tagItemsIndex!: Models.TagItemsIndex + private itemCounter!: Models.ItemCounter - private navigationDisplayController!: Models.ItemDisplayController - private tagDisplayController!: Models.ItemDisplayController + private navigationDisplayController!: Models.ItemDisplayController< + Models.SNNote | Models.FileItem, + Models.NotesAndFilesDisplayOptions + > + private tagDisplayController!: Models.ItemDisplayController private itemsKeyDisplayController!: Models.ItemDisplayController private componentDisplayController!: Models.ItemDisplayController private themeDisplayController!: Models.ItemDisplayController @@ -52,11 +51,15 @@ export class ItemManager this.unsubChangeObserver = this.payloadManager.addObserver(ContentType.Any, this.setPayloads.bind(this)) } - private rebuildSystemSmartViews(criteria: Models.FilterDisplayOptions): Models.SmartView[] { + private rebuildSystemSmartViews(criteria: Models.NotesAndFilesDisplayOptions): Models.SmartView[] { this.systemSmartViews = Models.BuildSmartViews(criteria) return this.systemSmartViews } + public getCollection(): Models.ItemCollection { + return this.collection + } + private createCollection() { this.collection = new Models.ItemCollection() @@ -94,7 +97,7 @@ export class ItemManager sortDirection: 'asc', }) - this.tagItemsIndex = new Models.TagItemsIndex(this.collection, this.tagItemsIndex?.observers) + this.itemCounter = new Models.ItemCounter(this.collection, this.itemCounter?.observers) } private get allDisplayControllers(): Models.ItemDisplayController[] { @@ -113,6 +116,10 @@ export class ItemManager return this.collection.invalidElements() } + public get invalidNonVaultedItems(): Models.EncryptedItemInterface[] { + return this.invalidItems.filter((item) => !item.key_system_identifier) + } + public createItemFromPayload(payload: Models.DecryptedPayloadInterface): Models.DecryptedItemInterface { return Models.CreateDecryptedItemFromPayload(payload) } @@ -121,8 +128,8 @@ export class ItemManager return new Models.DecryptedPayload(object) } - public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void { - const override: Models.FilterDisplayOptions = {} + public setPrimaryItemDisplayOptions(options: Models.NotesAndFilesDisplayControllerOptions): void { + const override: Models.NotesAndFilesDisplayOptions = {} const additionalFilters: Models.ItemFilter[] = [] if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) { @@ -164,7 +171,7 @@ export class ItemManager }) .filter((view) => view != undefined) - const updatedOptions: Models.DisplayOptions = { + const updatedOptions: Models.DisplayControllerDisplayOptions & Models.NotesAndFilesDisplayOptions = { ...options, ...override, ...{ @@ -173,7 +180,7 @@ export class ItemManager }, } - if (updatedOptions.sortBy === CollectionSort.Title) { + if (updatedOptions.sortBy === Models.CollectionSort.Title) { updatedOptions.sortDirection = updatedOptions.sortDirection === 'asc' ? 'dsc' : 'asc' } @@ -181,6 +188,17 @@ export class ItemManager customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection, additionalFilters), ...updatedOptions, }) + + this.itemCounter.setDisplayOptions(updatedOptions) + } + + public setVaultDisplayOptions(options: Models.VaultDisplayOptions): void { + this.navigationDisplayController.setVaultDisplayOptions(options) + this.tagDisplayController.setVaultDisplayOptions(options) + this.smartViewDisplayController.setVaultDisplayOptions(options) + this.fileDisplayController.setVaultDisplayOptions(options) + + this.itemCounter.setVaultDisplayOptions(options) } public getDisplayableNotes(): Models.SNNote[] { @@ -214,7 +232,7 @@ export class ItemManager ;(this.unsubChangeObserver as unknown) = undefined ;(this.payloadManager as unknown) = undefined ;(this.collection as unknown) = undefined - ;(this.tagItemsIndex as unknown) = undefined + ;(this.itemCounter as unknown) = undefined ;(this.tagDisplayController as unknown) = undefined ;(this.navigationDisplayController as unknown) = undefined ;(this.itemsKeyDisplayController as unknown) = undefined @@ -252,9 +270,6 @@ export class ItemManager return this.findItem(uuid) as T } - /** - * Returns all items matching given ids - */ findItems(uuids: UuidString[]): T[] { return this.collection.findAllDecrypted(uuids) as T[] } @@ -271,6 +286,7 @@ export class ItemManager return this.collection.nondeletedElements().filter(Models.isDecryptedItem) } + /** Unlock .items, this function includes error decrypting items */ allTrackedItems(): Models.ItemInterface[] { return this.collection.all() } @@ -280,26 +296,26 @@ export class ItemManager } public addNoteCountChangeObserver(observer: Models.TagItemCountChangeObserver): () => void { - return this.tagItemsIndex.addCountChangeObserver(observer) + return this.itemCounter.addCountChangeObserver(observer) } public allCountableNotesCount(): number { - return this.tagItemsIndex.allCountableNotesCount() + return this.itemCounter.allCountableNotesCount() } public allCountableFilesCount(): number { - return this.tagItemsIndex.allCountableFilesCount() + return this.itemCounter.allCountableFilesCount() } public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number { if (tag instanceof Models.SmartView) { if (tag.uuid === Models.SystemViewId.AllNotes) { - return this.tagItemsIndex.allCountableNotesCount() + return this.itemCounter.allCountableNotesCount() } throw Error('countableItemsForTag is not meant to be used for smart views.') } - return this.tagItemsIndex.countableItemsForTag(tag) + return this.itemCounter.countableItemsForTag(tag) } public getNoteCount(): number { @@ -330,12 +346,12 @@ export class ItemManager /** * Returns the items that reference the given item, or an empty array if no results. */ - public itemsReferencingItem( - itemToLookupUuidFor: Models.DecryptedItemInterface, + public itemsReferencingItem( + itemToLookupUuidFor: { uuid: UuidString }, contentType?: ContentType, - ): Models.DecryptedItemInterface[] { + ): I[] { const uuids = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid) - let referencing = this.findItems(uuids) + let referencing = this.findItems(uuids) if (contentType) { referencing = referencing.filter((ref) => { return ref?.content_type === contentType @@ -405,7 +421,7 @@ export class ItemManager } this.collection.onChange(delta) - this.tagItemsIndex.onChange(delta) + this.itemCounter.onChange(delta) const affectedContentTypesArray = Array.from(affectedContentTypes.values()) for (const controller of this.allDisplayControllers) { @@ -509,250 +525,6 @@ export class ItemManager } } - /** - * Consumers wanting to modify an item should run it through this block, - * so that data is properly mapped through our function, and latest state - * is properly reconciled. - */ - public async changeItem< - M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator, - I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, - >( - itemToLookupUuidFor: I, - mutate?: (mutator: M) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const results = await this.changeItems( - [itemToLookupUuidFor], - mutate, - mutationType, - emitSource, - payloadSourceKey, - ) - return results[0] - } - - /** - * @param mutate If not supplied, the intention would simply be to mark the item as dirty. - */ - public async changeItems< - M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator, - I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, - >( - itemsToLookupUuidsFor: I[], - mutate?: (mutator: M) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const items = this.findItemsIncludingBlanks(Uuids(itemsToLookupUuidsFor)) - const payloads: Models.DecryptedPayloadInterface[] = [] - - for (const item of items) { - if (!item) { - throw Error('Attempting to change non-existant item') - } - const mutator = Models.CreateDecryptedMutatorForItem(item, mutationType) - if (mutate) { - mutate(mutator as M) - } - const payload = mutator.getResult() - payloads.push(payload) - } - - await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) - - const results = this.findItems(payloads.map((p) => p.uuid)) as I[] - - return results - } - - /** - * Run unique mutations per each item in the array, then only propagate all changes - * once all mutations have been run. This differs from `changeItems` in that changeItems - * runs the same mutation on all items. - */ - public async runTransactionalMutations( - transactions: Models.TransactionalMutation[], - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise<(Models.DecryptedItemInterface | undefined)[]> { - const payloads: Models.DecryptedPayloadInterface[] = [] - - for (const transaction of transactions) { - const item = this.findItem(transaction.itemUuid) - - if (!item) { - continue - } - - const mutator = Models.CreateDecryptedMutatorForItem( - item, - transaction.mutationType || Models.MutationType.UpdateUserTimestamps, - ) - - transaction.mutate(mutator) - const payload = mutator.getResult() - payloads.push(payload) - } - - await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) - const results = this.findItems(payloads.map((p) => p.uuid)) - return results - } - - public async runTransactionalMutation( - transaction: Models.TransactionalMutation, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const item = this.findSureItem(transaction.itemUuid) - const mutator = Models.CreateDecryptedMutatorForItem( - item, - transaction.mutationType || Models.MutationType.UpdateUserTimestamps, - ) - transaction.mutate(mutator) - const payload = mutator.getResult() - - await this.payloadManager.emitPayloads([payload], emitSource, payloadSourceKey) - const result = this.findItem(payload.uuid) - return result - } - - async changeNote( - itemToLookupUuidFor: Models.SNNote, - mutate: (mutator: Models.NoteMutator) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const note = this.findItem(itemToLookupUuidFor.uuid) - if (!note) { - throw Error('Attempting to change non-existant note') - } - const mutator = new Models.NoteMutator(note, mutationType) - - return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - } - - async changeTag( - itemToLookupUuidFor: Models.SNTag, - mutate: (mutator: Models.TagMutator) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const tag = this.findItem(itemToLookupUuidFor.uuid) - if (!tag) { - throw Error('Attempting to change non-existant tag') - } - const mutator = new Models.TagMutator(tag, mutationType) - await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - return this.findSureItem(itemToLookupUuidFor.uuid) - } - - async changeComponent( - itemToLookupUuidFor: Models.SNComponent, - mutate: (mutator: Models.ComponentMutator) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const component = this.findItem(itemToLookupUuidFor.uuid) - if (!component) { - throw Error('Attempting to change non-existant component') - } - const mutator = new Models.ComponentMutator(component, mutationType) - await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - return this.findSureItem(itemToLookupUuidFor.uuid) - } - - async changeFeatureRepo( - itemToLookupUuidFor: Models.SNFeatureRepo, - mutate: (mutator: Models.FeatureRepoMutator) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const repo = this.findItem(itemToLookupUuidFor.uuid) - if (!repo) { - throw Error('Attempting to change non-existant repo') - } - const mutator = new Models.FeatureRepoMutator(repo, mutationType) - await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - return this.findSureItem(itemToLookupUuidFor.uuid) - } - - async changeActionsExtension( - itemToLookupUuidFor: Models.SNActionsExtension, - mutate: (mutator: Models.ActionsExtensionMutator) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const extension = this.findItem(itemToLookupUuidFor.uuid) - if (!extension) { - throw Error('Attempting to change non-existant extension') - } - const mutator = new Models.ActionsExtensionMutator(extension, mutationType) - await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - return this.findSureItem(itemToLookupUuidFor.uuid) - } - - async changeItemsKey( - itemToLookupUuidFor: Models.ItemsKeyInterface, - mutate: (mutator: Models.ItemsKeyMutatorInterface) => void, - mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - const itemsKey = this.findItem(itemToLookupUuidFor.uuid) - - if (!itemsKey) { - throw Error('Attempting to change non-existant itemsKey') - } - - const mutator = new ItemsKeyMutator(itemsKey, mutationType) - - await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) - - return this.findSureItem(itemToLookupUuidFor.uuid) - } - - private async applyTransform( - mutator: T, - mutate: (mutator: T) => void, - emitSource = Models.PayloadEmitSource.LocalChanged, - payloadSourceKey?: string, - ): Promise { - mutate(mutator) - const payload = mutator.getResult() - return this.payloadManager.emitPayload(payload, emitSource, payloadSourceKey) - } - - /** - * Sets the item as needing sync. The item is then run through the mapping function, - * and propagated to mapping observers. - * @param isUserModified - Whether to update the item's "user modified date" - */ - public async setItemDirty(itemToLookupUuidFor: Models.DecryptedItemInterface, isUserModified = false) { - const result = await this.setItemsDirty([itemToLookupUuidFor], isUserModified) - return result[0] - } - - public async setItemsDirty( - itemsToLookupUuidsFor: Models.DecryptedItemInterface[], - isUserModified = false, - ): Promise { - return this.changeItems( - itemsToLookupUuidsFor, - undefined, - isUserModified ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, - ) - } - /** * Returns an array of items that need to be synced. */ @@ -760,47 +532,6 @@ export class ItemManager return this.collection.dirtyElements().filter(Models.isDecryptedOrDeletedItem) } - /** - * Duplicates an item and maps it, thus propagating the item to observers. - * @param isConflict - Whether to mark the duplicate as a conflict of the original. - */ - public async duplicateItem( - itemToLookupUuidFor: T, - isConflict = false, - additionalContent?: Partial, - ) { - const item = this.findSureItem(itemToLookupUuidFor.uuid) - const payload = item.payload.copy() - const resultingPayloads = Models.PayloadsByDuplicating({ - payload, - baseCollection: this.payloadManager.getMasterCollection(), - isConflict, - additionalContent, - }) - - await this.payloadManager.emitPayloads(resultingPayloads, Models.PayloadEmitSource.LocalChanged) - const duplicate = this.findSureItem(resultingPayloads[0].uuid) - return duplicate - } - - public async createItem( - contentType: ContentType, - content: C, - needsSync = false, - ): Promise { - const payload = new Models.DecryptedPayload({ - uuid: UuidGenerator.GenerateUuid(), - content_type: contentType, - content: Models.FillItemContent(content), - dirty: needsSync, - ...Models.PayloadTimestampDefaults(), - }) - - await this.payloadManager.emitPayload(payload, Models.PayloadEmitSource.LocalInserted) - - return this.findSureItem(payload.uuid) - } - public createTemplateItem< C extends Models.ItemContent = Models.ItemContent, I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, @@ -824,75 +555,6 @@ export class ItemManager return !this.findItem(item.uuid) } - public async insertItem(item: Models.DecryptedItemInterface): Promise { - return this.emitItemFromPayload(item.payload, Models.PayloadEmitSource.LocalChanged) - } - - public async insertItems( - items: Models.DecryptedItemInterface[], - emitSource: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalInserted, - ): Promise { - return this.emitItemsFromPayloads( - items.map((item) => item.payload), - emitSource, - ) - } - - public async emitItemFromPayload( - payload: Models.DecryptedPayloadInterface, - emitSource: Models.PayloadEmitSource, - ): Promise { - await this.payloadManager.emitPayload(payload, emitSource) - - return this.findSureItem(payload.uuid) - } - - public async emitItemsFromPayloads( - payloads: Models.DecryptedPayloadInterface[], - emitSource: Models.PayloadEmitSource, - ): Promise { - await this.payloadManager.emitPayloads(payloads, emitSource) - - const uuids = Uuids(payloads) - - return this.findItems(uuids) - } - - public async setItemToBeDeleted( - itemToLookupUuidFor: Models.DecryptedItemInterface | Models.EncryptedItemInterface, - source: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalChanged, - ): Promise { - const referencingIdsCapturedBeforeChanges = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid) - - const item = this.findAnyItem(itemToLookupUuidFor.uuid) - - if (!item) { - return - } - - const mutator = new Models.DeleteItemMutator(item, Models.MutationType.UpdateUserTimestamps) - - const deletedPayload = mutator.getDeletedResult() - - await this.payloadManager.emitPayload(deletedPayload, source) - - for (const referencingId of referencingIdsCapturedBeforeChanges) { - const referencingItem = this.findItem(referencingId) - - if (referencingItem) { - await this.changeItem(referencingItem, (mutator) => { - mutator.removeItemAsRelationship(item) - }) - } - } - } - - public async setItemsToBeDeleted( - itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[], - ): Promise { - await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item))) - } - public getItems(contentType: ContentType | ContentType[]): T[] { return this.collection.allDecrypted(contentType) } @@ -1018,20 +680,6 @@ export class ItemManager return chain } - public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise { - let current: Models.SNTag | undefined = undefined - - for (const title of titlesHierarchy) { - current = await this.findOrCreateTagByTitle(title, current) - } - - if (!current) { - throw new Error('Invalid tag hierarchy') - } - - return current - } - public getTagChildren(itemToLookupUuidFor: Models.SNTag): Models.SNTag[] { const tag = this.findItem(itemToLookupUuidFor.uuid) if (!tag) { @@ -1079,117 +727,12 @@ export class ItemManager return true } - /** - * @returns The changed child tag - */ - public setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise { - if (parentTag.uuid === childTag.uuid) { - throw new Error('Can not set a tag parent of itself') - } - - if (this.isTagAncestor(childTag, parentTag)) { - throw new Error('Can not set a tag ancestor of itself') - } - - return this.changeTag(childTag, (m) => { - m.makeChildOf(parentTag) - }) - } - - /** - * @returns The changed child tag - */ - public unsetTagParent(childTag: Models.SNTag): Promise { - const parentTag = this.getTagParent(childTag) - - if (!parentTag) { - return Promise.resolve(childTag) - } - - return this.changeTag(childTag, (m) => { - m.unsetParent() - }) - } - - public async associateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise { - return this.changeItem(file, (mutator) => { - mutator.addNote(note) - }) - } - - public async disassociateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise { - return this.changeItem(file, (mutator) => { - mutator.removeNote(note) - }) - } - - public async addTagToNote(note: Models.SNNote, tag: Models.SNTag, addHierarchy: boolean): Promise { - let tagsToAdd = [tag] - - if (addHierarchy) { - const parentChainTags = this.getTagParentChain(tag) - tagsToAdd = [...parentChainTags, tag] - } - - return Promise.all( - tagsToAdd.map((tagToAdd) => { - return this.changeTag(tagToAdd, (mutator) => { - mutator.addNote(note) - }) as Promise - }), - ) - } - - public async addTagToFile(file: Models.FileItem, tag: Models.SNTag, addHierarchy: boolean): Promise { - let tagsToAdd = [tag] - - if (addHierarchy) { - const parentChainTags = this.getTagParentChain(tag) - tagsToAdd = [...parentChainTags, tag] - } - - return Promise.all( - tagsToAdd.map((tagToAdd) => { - return this.changeTag(tagToAdd, (mutator) => { - mutator.addFile(file) - }) as Promise - }), - ) - } - - public async linkNoteToNote(note: Models.SNNote, otherNote: Models.SNNote): Promise { - return this.changeItem(note, (mutator) => { - mutator.addNote(otherNote) - }) - } - - public async linkFileToFile(file: Models.FileItem, otherFile: Models.FileItem): Promise { - return this.changeItem(file, (mutator) => { - mutator.addFile(otherFile) - }) - } - - public async unlinkItems(itemA: DecryptedItemInterface, itemB: DecryptedItemInterface) { - const relationshipDirection = this.relationshipDirectionBetweenItems(itemA, itemB) - - if (relationshipDirection === ItemRelationshipDirection.NoRelationship) { - throw new Error('Trying to unlink already unlinked items') - } - - const itemToChange = relationshipDirection === ItemRelationshipDirection.AReferencesB ? itemA : itemB - const itemToRemove = itemToChange === itemA ? itemB : itemA - - return this.changeItem(itemToChange, (mutator) => { - mutator.removeItemAsRelationship(itemToRemove) - }) - } - /** * 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 */ - public getSortedTagsForItem(item: DecryptedItemInterface): Models.SNTag[] { + public getSortedTagsForItem(item: Models.DecryptedItemInterface): Models.SNTag[] { return naturalSort( this.itemsReferencingItem(item).filter((ref) => { return ref?.content_type === ContentType.Tag @@ -1198,81 +741,16 @@ export class ItemManager ) } - public async createTag(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise { - const newTag = await this.createItem( - ContentType.Tag, - Models.FillItemContent({ title }), - true, - ) - - if (parentItemToLookupUuidFor) { - const parentTag = this.findItem(parentItemToLookupUuidFor.uuid) - if (!parentTag) { - throw new Error('Invalid parent tag') - } - return this.changeTag(newTag, (m) => { - m.makeChildOf(parentTag) - }) - } - - return newTag - } - - public async createSmartView( - title: string, - predicate: Models.PredicateInterface, - iconString?: string, - ): Promise { - return this.createItem( - ContentType.SmartView, - Models.FillItemContent({ - title, - predicate: predicate.toJson(), - iconString: iconString || SmartViewDefaultIconName, - } as Models.SmartViewContent), - true, - ) as Promise - } - - public async createSmartViewFromDSL(dsl: string): Promise { - let components = null - try { - components = JSON.parse(dsl.substring(1, dsl.length)) - } catch (e) { - throw Error('Invalid smart view syntax') - } - - const title = components[0] - const predicate = Models.predicateFromDSLString(dsl) - return this.createSmartView(title, predicate) - } - - public async createTagOrSmartView(title: string): Promise { - if (this.isSmartViewTitle(title)) { - return this.createSmartViewFromDSL(title) - } else { - return this.createTag(title) - } - } - public isSmartViewTitle(title: string): boolean { return title.startsWith(Models.SMART_TAG_DSL_PREFIX) } - /** - * Finds or creates a tag with a given title - */ - public async findOrCreateTagByTitle(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise { - const tag = this.findTagByTitleAndParent(title, parentItemToLookupUuidFor) - return tag || this.createTag(title, parentItemToLookupUuidFor) - } - public notesMatchingSmartView(view: Models.SmartView): Models.SNNote[] { - const criteria: Models.FilterDisplayOptions = { + const criteria: Models.NotesAndFilesDisplayOptions = { views: [view], } - return Models.itemsMatchingOptions( + return Models.notesAndFilesMatchingOptions( criteria, this.collection.allDecrypted(ContentType.Note), this.collection, @@ -1299,14 +777,6 @@ export class ItemManager return this.notesMatchingSmartView(this.trashSmartView) } - /** - * Permanently deletes any items currently in the trash. Consumer must manually call sync. - */ - public async emptyTrash(): Promise { - const notes = this.trashedItems - await this.setItemsToBeDeleted(notes) - } - /** * Returns all smart views, sorted by title. */ @@ -1346,53 +816,29 @@ export class ItemManager this.payloadManager.resetState() } - public removeItemLocally(item: Models.DecryptedItemInterface | Models.DeletedItemInterface): void { - this.collection.discard([item]) - this.payloadManager.removePayloadLocally(item.payload) + /** + * Important: Caller must coordinate with storage service separately to delete item from persistent database. + */ + public removeItemLocally(item: Models.AnyItemInterface): void { + this.removeItemsLocally([item]) + } - const delta = Models.CreateItemDelta({ discarded: [item] as Models.DeletedItemInterface[] }) + /** + * Important: Caller must coordinate with storage service separately to delete item from persistent database. + */ + public removeItemsLocally(items: Models.AnyItemInterface[]): void { + this.collection.discard(items) + this.payloadManager.removePayloadLocally(items.map((item) => item.payload)) + + const delta = Models.CreateItemDelta({ discarded: items as Models.DeletedItemInterface[] }) + const affectedContentTypes = items.map((item) => item.content_type) for (const controller of this.allDisplayControllers) { - if (controller.contentTypes.some((ct) => ct === item.content_type)) { + if (controller.contentTypes.some((ct) => affectedContentTypes.includes(ct))) { controller.onCollectionChange(delta) } } } - public renameFile(file: Models.FileItem, name: string): Promise { - return this.changeItem(file, (mutator) => { - mutator.name = name - }) - } - - public async setLastSyncBeganForItems( - itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.DeletedItemInterface)[], - date: Date, - globalDirtyIndex: number, - ): Promise<(Models.DecryptedItemInterface | Models.DeletedItemInterface)[]> { - const uuids = Uuids(itemsToLookupUuidsFor) - - const items = this.collection.findAll(uuids).filter(Models.isDecryptedOrDeletedItem) - - const payloads: (Models.DecryptedPayloadInterface | Models.DeletedPayloadInterface)[] = [] - - for (const item of items) { - const mutator = new Models.ItemMutator( - item, - Models.MutationType.NonDirtying, - ) - - mutator.setBeginSync(date, globalDirtyIndex) - - const payload = mutator.getResult() - - payloads.push(payload) - } - - await this.payloadManager.emitPayloads(payloads, Models.PayloadEmitSource.PreSyncSave) - - return this.findAnyItems(uuids) as (Models.DecryptedItemInterface | Models.DeletedItemInterface)[] - } - public relationshipDirectionBetweenItems( itemA: Models.DecryptedItemInterface, itemB: Models.DecryptedItemInterface, @@ -1407,12 +853,8 @@ export class ItemManager : ItemRelationshipDirection.NoRelationship } - override getDiagnostics(): Promise { - return Promise.resolve({ - items: { - allIds: Uuids(this.collection.all()), - }, - }) + itemsBelongingToKeySystem(systemIdentifier: Models.KeySystemIdentifier): Models.DecryptedItemInterface[] { + return this.items.filter((item) => item.key_system_identifier === systemIdentifier) } public conflictsOf(uuid: string) { @@ -1422,4 +864,8 @@ export class ItemManager public numberOfNotesWithConflicts(): number { return this.findItems(this.collection.uuidsOfItemsWithConflicts()).filter(Models.isNote).length } + + getNoteLinkedFiles(note: Models.SNNote): Models.FileItem[] { + return this.itemsReferencingItem(note).filter(Models.isFile) + } } diff --git a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts index 670471ebe..2cb3c5f9d 100644 --- a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts +++ b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts @@ -312,7 +312,7 @@ export class SNKeyRecoveryService extends AbstractService(note, (mutator) => { + await this.mutator.changeItem(note, (mutator) => { mutator.authorizedForListed = true }) + void this.sync.sync() + return true } diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts b/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts index 693ad20a8..f0804c8da 100644 --- a/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts +++ b/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts @@ -1,16 +1,16 @@ -import { SNHistoryManager } from './../History/HistoryManager' -import { NoteContent, SNNote, FillItemContent, DecryptedPayload, PayloadTimestampDefaults } from '@standardnotes/models' -import { ContentType } from '@standardnotes/common' -import { EncryptionService, InternalEventBusInterface } from '@standardnotes/services' import { - ChallengeService, - MutatorService, - PayloadManager, - SNComponentManager, - SNProtectionService, - ItemManager, - SNSyncService, -} from '../' + NoteContent, + SNNote, + FillItemContent, + DecryptedPayload, + PayloadTimestampDefaults, + MutationType, + FileItem, + SNTag, +} from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { AlertService, InternalEventBusInterface } from '@standardnotes/services' +import { MutatorService, PayloadManager, ItemManager } from '../' import { UuidGenerator } from '@standardnotes/utils' const setupRandomUuid = () => { @@ -21,12 +21,6 @@ describe('mutator service', () => { let mutatorService: MutatorService let payloadManager: PayloadManager let itemManager: ItemManager - let syncService: SNSyncService - let protectionService: SNProtectionService - let protocolService: EncryptionService - let challengeService: ChallengeService - let componentManager: SNComponentManager - let historyService: SNHistoryManager let internalEventBus: InternalEventBusInterface @@ -38,17 +32,10 @@ describe('mutator service', () => { payloadManager = new PayloadManager(internalEventBus) itemManager = new ItemManager(payloadManager, internalEventBus) - mutatorService = new MutatorService( - itemManager, - syncService, - protectionService, - protocolService, - payloadManager, - challengeService, - componentManager, - historyService, - internalEventBus, - ) + const alerts = {} as jest.Mocked + alerts.alert = jest.fn() + + mutatorService = new MutatorService(itemManager, payloadManager, alerts, internalEventBus) }) const insertNote = (title: string) => { @@ -73,10 +60,76 @@ describe('mutator service', () => { (mutator) => { mutator.pinned = true }, - false, + MutationType.NoUpdateUserTimestamps, ) expect(note.userModifiedDate).toEqual(pinnedNote?.userModifiedDate) }) }) + + describe('linking', () => { + it('attempting to link file and note should not be allowed if items belong to different vaults', async () => { + const note = { + uuid: 'note', + key_system_identifier: '123', + } as jest.Mocked + + const file = { + uuid: 'file', + key_system_identifier: '456', + } as jest.Mocked + + const result = await mutatorService.associateFileWithNote(file, note) + + expect(result).toBeUndefined() + }) + + it('attempting to link vaulted tag with non vaulted note should not be permissable', async () => { + const note = { + uuid: 'note', + key_system_identifier: undefined, + } as jest.Mocked + + const tag = { + uuid: 'tag', + key_system_identifier: '456', + } as jest.Mocked + + const result = await mutatorService.addTagToNote(note, tag, true) + + expect(result).toBeUndefined() + }) + + it('attempting to link vaulted tag with non vaulted file should not be permissable', async () => { + const tag = { + uuid: 'tag', + key_system_identifier: '456', + } as jest.Mocked + + const file = { + uuid: 'file', + key_system_identifier: undefined, + } as jest.Mocked + + const result = await mutatorService.addTagToFile(file, tag, true) + + expect(result).toBeUndefined() + }) + + it('attempting to link vaulted tag with note belonging to different vault should not be perpermissable', async () => { + const note = { + uuid: 'note', + key_system_identifier: '123', + } as jest.Mocked + + const tag = { + uuid: 'tag', + key_system_identifier: '456', + } as jest.Mocked + + const result = await mutatorService.addTagToNote(note, tag, true) + + expect(result).toBeUndefined() + }) + }) }) diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.ts b/packages/snjs/lib/Services/Mutator/MutatorService.ts index 1407c5777..1a74f1530 100644 --- a/packages/snjs/lib/Services/Mutator/MutatorService.ts +++ b/packages/snjs/lib/Services/Mutator/MutatorService.ts @@ -1,62 +1,60 @@ -import { SNHistoryManager } from './../History/HistoryManager' import { AbstractService, InternalEventBusInterface, - SyncOptions, - ChallengeValidation, - ChallengePrompt, - ChallengeReason, MutatorClientInterface, - Challenge, - InfoStrings, + ItemRelationshipDirection, + AlertService, } from '@standardnotes/services' -import { EncryptionProviderInterface } from '@standardnotes/encryption' -import { ClientDisplayableError } from '@standardnotes/responses' -import { ContentType, ProtocolVersion, compareVersions } from '@standardnotes/common' +import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption' +import { ContentType } from '@standardnotes/common' import { ItemManager } from '../Items' import { PayloadManager } from '../Payloads/PayloadManager' -import { SNComponentManager } from '../ComponentManager/ComponentManager' -import { SNProtectionService } from '../Protection/ProtectionService' -import { SNSyncService } from '../Sync' -import { Strings } from '../../Strings' import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders' -import { ChallengeService } from '../Challenge' import { - BackupFile, - BackupFileDecryptedContextualPayload, - ComponentContent, - CopyPayloadWithContentOverride, - CreateDecryptedBackupFileContextPayload, + ActionsExtensionMutator, + ComponentMutator, CreateDecryptedMutatorForItem, - CreateEncryptedBackupFileContextPayload, DecryptedItemInterface, DecryptedItemMutator, DecryptedPayload, DecryptedPayloadInterface, + DeleteItemMutator, EncryptedItemInterface, + FeatureRepoMutator, FileItem, - isDecryptedPayload, - isEncryptedTransferPayload, + FileMutator, + FillItemContent, ItemContent, + ItemsKeyInterface, + ItemsKeyMutatorInterface, MutationType, + NoteMutator, PayloadEmitSource, + PayloadsByDuplicating, + PayloadTimestampDefaults, + PayloadVaultOverrides, + predicateFromDSLString, + PredicateInterface, SmartView, + SmartViewContent, + SmartViewDefaultIconName, + SNActionsExtension, SNComponent, + SNFeatureRepo, SNNote, SNTag, + TagContent, + TagMutator, TransactionalMutation, + VaultListingInterface, } from '@standardnotes/models' +import { UuidGenerator, Uuids } from '@standardnotes/utils' export class MutatorService extends AbstractService implements MutatorClientInterface { constructor( private itemManager: ItemManager, - private syncService: SNSyncService, - private protectionService: SNProtectionService, - private encryption: EncryptionProviderInterface, private payloadManager: PayloadManager, - private challengeService: ChallengeService, - private componentManager: SNComponentManager, - private historyService: SNHistoryManager, + private alerts: AlertService, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -65,86 +63,98 @@ export class MutatorService extends AbstractService implements MutatorClientInte override deinit() { super.deinit() ;(this.itemManager as unknown) = undefined - ;(this.syncService as unknown) = undefined - ;(this.protectionService as unknown) = undefined - ;(this.encryption as unknown) = undefined ;(this.payloadManager as unknown) = undefined - ;(this.challengeService as unknown) = undefined - ;(this.componentManager as unknown) = undefined - ;(this.historyService as unknown) = undefined } - public async insertItem(item: DecryptedItemInterface): Promise { - const mutator = CreateDecryptedMutatorForItem(item, MutationType.UpdateUserTimestamps) - const dirtiedPayload = mutator.getResult() - const insertedItem = await this.itemManager.emitItemFromPayload(dirtiedPayload, PayloadEmitSource.LocalInserted) - return insertedItem - } - - public async changeAndSaveItem( - itemToLookupUuidFor: DecryptedItemInterface, - mutate: (mutator: M) => void, - updateTimestamps = true, - emitSource?: PayloadEmitSource, - syncOptions?: SyncOptions, - ): Promise { - await this.itemManager.changeItems( + /** + * Consumers wanting to modify an item should run it through this block, + * so that data is properly mapped through our function, and latest state + * is properly reconciled. + */ + public async changeItem< + M extends DecryptedItemMutator = DecryptedItemMutator, + I extends DecryptedItemInterface = DecryptedItemInterface, + >( + itemToLookupUuidFor: I, + mutate?: (mutator: M) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const results = await this.changeItems( [itemToLookupUuidFor], mutate, - updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, + mutationType, emitSource, + payloadSourceKey, ) - await this.syncService.sync(syncOptions) - return this.itemManager.findItem(itemToLookupUuidFor.uuid) + return results[0] } - public async changeAndSaveItems( - itemsToLookupUuidsFor: DecryptedItemInterface[], - mutate: (mutator: M) => void, - updateTimestamps = true, - emitSource?: PayloadEmitSource, - syncOptions?: SyncOptions, - ): Promise { - await this.itemManager.changeItems( - itemsToLookupUuidsFor, - mutate, - updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, - emitSource, - ) - await this.syncService.sync(syncOptions) - } - - public async changeItem( - itemToLookupUuidFor: DecryptedItemInterface, - mutate: (mutator: M) => void, - updateTimestamps = true, - ): Promise { - await this.itemManager.changeItems( - [itemToLookupUuidFor], - mutate, - updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, - ) - return this.itemManager.findItem(itemToLookupUuidFor.uuid) - } - - public async changeItems( - itemsToLookupUuidsFor: DecryptedItemInterface[], - mutate: (mutator: M) => void, - updateTimestamps = true, - ): Promise<(DecryptedItemInterface | undefined)[]> { - return this.itemManager.changeItems( - itemsToLookupUuidsFor, - mutate, - updateTimestamps ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, - ) + /** + * @param mutate If not supplied, the intention would simply be to mark the item as dirty. + */ + public async changeItems< + M extends DecryptedItemMutator = DecryptedItemMutator, + I extends DecryptedItemInterface = DecryptedItemInterface, + >( + itemsToLookupUuidsFor: I[], + mutate?: (mutator: M) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const items = this.itemManager.findItemsIncludingBlanks(Uuids(itemsToLookupUuidsFor)) + const payloads: DecryptedPayloadInterface[] = [] + + for (const item of items) { + if (!item) { + throw Error('Attempting to change non-existant item') + } + const mutator = CreateDecryptedMutatorForItem(item, mutationType) + if (mutate) { + mutate(mutator as M) + } + const payload = mutator.getResult() + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) + + const results = this.itemManager.findItems(payloads.map((p) => p.uuid)) as I[] + + return results } + /** + * Run unique mutations per each item in the array, then only propagate all changes + * once all mutations have been run. This differs from `changeItems` in that changeItems + * runs the same mutation on all items. + */ public async runTransactionalMutations( transactions: TransactionalMutation[], emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise<(DecryptedItemInterface | undefined)[]> { - return this.itemManager.runTransactionalMutations(transactions, emitSource, payloadSourceKey) + const payloads: DecryptedPayloadInterface[] = [] + + for (const transaction of transactions) { + const item = this.itemManager.findItem(transaction.itemUuid) + + if (!item) { + continue + } + + const mutator = CreateDecryptedMutatorForItem(item, transaction.mutationType || MutationType.UpdateUserTimestamps) + + transaction.mutate(mutator) + const payload = mutator.getResult() + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) + const results = this.itemManager.findItems(payloads.map((p) => p.uuid)) + return results } public async runTransactionalMutation( @@ -152,97 +162,387 @@ export class MutatorService extends AbstractService implements MutatorClientInte emitSource = PayloadEmitSource.LocalChanged, payloadSourceKey?: string, ): Promise { - return this.itemManager.runTransactionalMutation(transaction, emitSource, payloadSourceKey) + const item = this.itemManager.findSureItem(transaction.itemUuid) + const mutator = CreateDecryptedMutatorForItem(item, transaction.mutationType || MutationType.UpdateUserTimestamps) + transaction.mutate(mutator) + const payload = mutator.getResult() + + await this.payloadManager.emitPayloads([payload], emitSource, payloadSourceKey) + const result = this.itemManager.findItem(payload.uuid) + return result } - async protectItems(items: I[]): Promise { - const protectedItems = await this.itemManager.changeItems( - items, - (mutator) => { - mutator.protected = true - }, - MutationType.NoUpdateUserTimestamps, - ) + async changeNote( + itemToLookupUuidFor: SNNote, + mutate: (mutator: NoteMutator) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const note = this.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!note) { + throw Error('Attempting to change non-existant note') + } + const mutator = new NoteMutator(note, mutationType) - void this.syncService.sync() - return protectedItems + return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) } - async unprotectItems( - items: I[], - reason: ChallengeReason, - ): Promise { - if ( - !(await this.protectionService.authorizeAction(reason, { - fallBackToAccountPassword: true, - requireAccountPassword: false, - forcePrompt: false, - })) - ) { - return undefined + async changeTag( + itemToLookupUuidFor: SNTag, + mutate: (mutator: TagMutator) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const tag = this.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!tag) { + throw Error('Attempting to change non-existant tag') + } + const mutator = new TagMutator(tag, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeComponent( + itemToLookupUuidFor: SNComponent, + mutate: (mutator: ComponentMutator) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const component = this.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!component) { + throw Error('Attempting to change non-existant component') + } + const mutator = new ComponentMutator(component, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeFeatureRepo( + itemToLookupUuidFor: SNFeatureRepo, + mutate: (mutator: FeatureRepoMutator) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const repo = this.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!repo) { + throw Error('Attempting to change non-existant repo') + } + const mutator = new FeatureRepoMutator(repo, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeActionsExtension( + itemToLookupUuidFor: SNActionsExtension, + mutate: (mutator: ActionsExtensionMutator) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const extension = this.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!extension) { + throw Error('Attempting to change non-existant extension') + } + const mutator = new ActionsExtensionMutator(extension, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeItemsKey( + itemToLookupUuidFor: ItemsKeyInterface, + mutate: (mutator: ItemsKeyMutatorInterface) => void, + mutationType: MutationType = MutationType.UpdateUserTimestamps, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const itemsKey = this.itemManager.findItem(itemToLookupUuidFor.uuid) + + if (!itemsKey) { + throw Error('Attempting to change non-existant itemsKey') } - const unprotectedItems = await this.itemManager.changeItems( - items, - (mutator) => { - mutator.protected = false - }, - MutationType.NoUpdateUserTimestamps, + const mutator = new ItemsKeyMutator(itemsKey, mutationType) + + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + + return this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + } + + private async applyTransform( + mutator: T, + mutate: (mutator: T) => void, + emitSource = PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + mutate(mutator) + const payload = mutator.getResult() + return this.payloadManager.emitPayload(payload, emitSource, payloadSourceKey) + } + + /** + * Sets the item as needing sync. The item is then run through the mapping function, + * and propagated to mapping observers. + * @param isUserModified - Whether to update the item's "user modified date" + */ + public async setItemDirty(itemToLookupUuidFor: DecryptedItemInterface, isUserModified = false) { + const result = await this.setItemsDirty([itemToLookupUuidFor], isUserModified) + return result[0] + } + + public async setItemsDirty( + itemsToLookupUuidsFor: DecryptedItemInterface[], + isUserModified = false, + ): Promise { + return this.changeItems( + itemsToLookupUuidsFor, + undefined, + isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, + ) + } + + /** + * Duplicates an item and maps it, thus propagating the item to observers. + * @param isConflict - Whether to mark the duplicate as a conflict of the original. + */ + public async duplicateItem( + itemToLookupUuidFor: T, + isConflict = false, + additionalContent?: Partial, + ) { + const item = this.itemManager.findSureItem(itemToLookupUuidFor.uuid) + const payload = item.payload.copy() + const resultingPayloads = PayloadsByDuplicating({ + payload, + baseCollection: this.payloadManager.getMasterCollection(), + isConflict, + additionalContent, + }) + + await this.payloadManager.emitPayloads(resultingPayloads, PayloadEmitSource.LocalChanged) + const duplicate = this.itemManager.findSureItem(resultingPayloads[0].uuid) + + return duplicate + } + + public async createItem( + contentType: ContentType, + content: C, + needsSync = false, + vault?: VaultListingInterface, + ): Promise { + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: FillItemContent(content), + dirty: needsSync, + ...PayloadVaultOverrides(vault), + ...PayloadTimestampDefaults(), + }) + + await this.payloadManager.emitPayload(payload, PayloadEmitSource.LocalInserted) + + return this.itemManager.findSureItem(payload.uuid) + } + + public async insertItem(item: DecryptedItemInterface, setDirty = true): Promise { + if (setDirty) { + const mutator = CreateDecryptedMutatorForItem(item, MutationType.UpdateUserTimestamps) + const dirtiedPayload = mutator.getResult() + const insertedItem = await this.emitItemFromPayload(dirtiedPayload, PayloadEmitSource.LocalInserted) + return insertedItem + } else { + return this.emitItemFromPayload(item.payload, PayloadEmitSource.LocalChanged) + } + } + + public async insertItems( + items: DecryptedItemInterface[], + emitSource: PayloadEmitSource = PayloadEmitSource.LocalInserted, + ): Promise { + return this.emitItemsFromPayloads( + items.map((item) => item.payload), + emitSource, + ) + } + + public async emitItemFromPayload( + payload: DecryptedPayloadInterface, + emitSource: PayloadEmitSource, + ): Promise { + await this.payloadManager.emitPayload(payload, emitSource) + + const result = this.itemManager.findSureItem(payload.uuid) + + if (!result) { + throw Error("Emitted item can't be found") + } + + return result + } + + public async emitItemsFromPayloads( + payloads: DecryptedPayloadInterface[], + emitSource: PayloadEmitSource, + ): Promise { + await this.payloadManager.emitPayloads(payloads, emitSource) + + const uuids = Uuids(payloads) + + return this.itemManager.findItems(uuids) + } + + public async setItemToBeDeleted( + itemToLookupUuidFor: DecryptedItemInterface | EncryptedItemInterface, + source: PayloadEmitSource = PayloadEmitSource.LocalChanged, + ): Promise { + const referencingIdsCapturedBeforeChanges = this.itemManager + .getCollection() + .uuidsThatReferenceUuid(itemToLookupUuidFor.uuid) + + const item = this.itemManager.findAnyItem(itemToLookupUuidFor.uuid) + if (!item) { + return + } + + const mutator = new DeleteItemMutator(item, MutationType.UpdateUserTimestamps) + + const deletedPayload = mutator.getDeletedResult() + + await this.payloadManager.emitPayload(deletedPayload, source) + + for (const referencingId of referencingIdsCapturedBeforeChanges) { + const referencingItem = this.itemManager.findItem(referencingId) + + if (referencingItem) { + await this.changeItem(referencingItem, (mutator) => { + mutator.removeItemAsRelationship(item) + }) + } + } + } + + public async setItemsToBeDeleted( + itemsToLookupUuidsFor: (DecryptedItemInterface | EncryptedItemInterface)[], + ): Promise { + await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item))) + } + + public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise { + let current: SNTag | undefined = undefined + + for (const title of titlesHierarchy) { + current = await this.findOrCreateTagByTitle({ title, parentItemToLookupUuidFor: current }) + } + + if (!current) { + throw new Error('Invalid tag hierarchy') + } + + return current + } + + public async createTag(dto: { + title: string + parentItemToLookupUuidFor?: SNTag + createInVault?: VaultListingInterface + }): Promise { + const newTag = await this.createItem( + ContentType.Tag, + FillItemContent({ title: dto.title }), + true, + dto.createInVault, ) - void this.syncService.sync() - return unprotectedItems + if (dto.parentItemToLookupUuidFor) { + const parentTag = this.itemManager.findItem(dto.parentItemToLookupUuidFor.uuid) + if (!parentTag) { + throw new Error('Invalid parent tag') + } + return this.changeTag(newTag, (m) => { + m.makeChildOf(parentTag) + }) + } + + return newTag } - public async protectNote(note: SNNote): Promise { - const result = await this.protectItems([note]) - return result[0] + public async createSmartView(dto: { + title: string + predicate: PredicateInterface + iconString?: string + vault?: VaultListingInterface + }): Promise { + return this.createItem( + ContentType.SmartView, + FillItemContent({ + title: dto.title, + predicate: dto.predicate.toJson(), + iconString: dto.iconString || SmartViewDefaultIconName, + } as SmartViewContent), + true, + dto.vault, + ) as Promise } - public async unprotectNote(note: SNNote): Promise { - const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote) - return result ? result[0] : undefined + public async createSmartViewFromDSL( + dsl: string, + vault?: VaultListingInterface, + ): Promise { + let components = null + try { + components = JSON.parse(dsl.substring(1, dsl.length)) + } catch (e) { + throw Error('Invalid smart view syntax') + } + + const title = components[0] + const predicate = predicateFromDSLString(dsl) + return this.createSmartView({ title, predicate, vault }) } - public async protectNotes(notes: SNNote[]): Promise { - return this.protectItems(notes) + public async createTagOrSmartView( + title: string, + vault?: VaultListingInterface, + ): Promise { + if (this.itemManager.isSmartViewTitle(title)) { + return this.createSmartViewFromDSL(title, vault) as Promise + } else { + return this.createTag({ title, createInVault: vault }) as Promise + } } - public async unprotectNotes(notes: SNNote[]): Promise { - const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote) - return results || [] + public async findOrCreateTagByTitle(dto: { + title: string + parentItemToLookupUuidFor?: SNTag + createInVault?: VaultListingInterface + }): Promise { + const tag = this.itemManager.findTagByTitleAndParent(dto.title, dto.parentItemToLookupUuidFor) + return tag || this.createTag(dto) } - async protectFile(file: FileItem): Promise { - const result = await this.protectItems([file]) - return result[0] - } - - async unprotectFile(file: FileItem): Promise { - const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile) - return result ? result[0] : undefined + public renameFile(file: FileItem, name: string): Promise { + return this.changeItem(file, (mutator) => { + mutator.name = name + }) } public async mergeItem(item: DecryptedItemInterface, source: PayloadEmitSource): Promise { - return this.itemManager.emitItemFromPayload(item.payloadRepresentation(), source) - } - - public createTemplateItem< - C extends ItemContent = ItemContent, - I extends DecryptedItemInterface = DecryptedItemInterface, - >(contentType: ContentType, content?: C, override?: Partial>): I { - return this.itemManager.createTemplateItem(contentType, content, override) + return this.emitItemFromPayload(item.payloadRepresentation(), source) } public async setItemNeedsSync( item: DecryptedItemInterface, updateTimestamps = false, ): Promise { - return this.itemManager.setItemDirty(item, updateTimestamps) + return this.setItemDirty(item, updateTimestamps) } public async setItemsNeedsSync(items: DecryptedItemInterface[]): Promise<(DecryptedItemInterface | undefined)[]> { - return this.itemManager.setItemsDirty(items) + return this.setItemsDirty(items) } public async deleteItem(item: DecryptedItemInterface | EncryptedItemInterface): Promise { @@ -250,153 +550,150 @@ export class MutatorService extends AbstractService implements MutatorClientInte } public async deleteItems(items: (DecryptedItemInterface | EncryptedItemInterface)[]): Promise { - await this.itemManager.setItemsToBeDeleted(items) - await this.syncService.sync() + await this.setItemsToBeDeleted(items) } + /** + * Permanently deletes any items currently in the trash. Consumer must manually call sync. + */ public async emptyTrash(): Promise { - await this.itemManager.emptyTrash() - await this.syncService.sync() + const notes = this.itemManager.trashedItems + await this.setItemsToBeDeleted(notes) } - public duplicateItem( - item: T, - additionalContent?: Partial, - ): Promise { - const duplicate = this.itemManager.duplicateItem(item, false, additionalContent) - void this.syncService.sync() - return duplicate + public async migrateTagsToFolders(): Promise { + await TagsToFoldersMigrationApplicator.run(this.itemManager, this) } - public async migrateTagsToFolders(): Promise { - await TagsToFoldersMigrationApplicator.run(this.itemManager) - return this.syncService.sync() + public async findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise { + return this.findOrCreateTagByTitle({ title, createInVault }) } - public async setTagParent(parentTag: SNTag, childTag: SNTag): Promise { - await this.itemManager.setTagParent(parentTag, childTag) - } - - public async unsetTagParent(childTag: SNTag): Promise { - await this.itemManager.unsetTagParent(childTag) - } - - public async findOrCreateTag(title: string): Promise { - return this.itemManager.findOrCreateTagByTitle(title) - } - - /** Creates and returns the tag but does not run sync. Callers must perform sync. */ - public async createTagOrSmartView(title: string): Promise { - return this.itemManager.createTagOrSmartView(title) - } - - public async toggleComponent(component: SNComponent): Promise { - await this.componentManager.toggleComponent(component.uuid) - await this.syncService.sync() - } - - public async toggleTheme(theme: SNComponent): Promise { - await this.componentManager.toggleTheme(theme.uuid) - await this.syncService.sync() - } - - public async importData( - data: BackupFile, - awaitSync = false, - ): Promise< - | { - affectedItems: DecryptedItemInterface[] - errorCount: number - } - | { - error: ClientDisplayableError - } - > { - 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(InfoStrings.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(InfoStrings.BackupFileMoreRecentThanAccount) } - } + /** + * @returns The changed child tag + */ + public async setTagParent(parentTag: SNTag, childTag: SNTag): Promise { + if (parentTag.uuid === childTag.uuid) { + throw new Error('Can not set a tag parent of itself') } - let password: string | undefined - - if (data.auth_params || data.keyParams) { - /** Get import file password. */ - const challenge = new Challenge( - [new ChallengePrompt(ChallengeValidation.None, Strings.Input.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 (this.itemManager.isTagAncestor(childTag, parentTag)) { + throw new Error('Can not set a tag ancestor of itself') } - 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) - } + return this.changeTag(childTag, (m) => { + m.makeChildOf(parentTag) }) + } - const decryptedPayloadsOrError = await this.encryption.decryptBackupFile(data, password) + /** + * @returns The changed child tag + */ + public unsetTagParent(childTag: SNTag): Promise { + const parentTag = this.itemManager.getTagParent(childTag) - if (decryptedPayloadsOrError instanceof ClientDisplayableError) { - return { error: decryptedPayloadsOrError } + if (!parentTag) { + return Promise.resolve(childTag) } - 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 - return CopyPayloadWithContentOverride(typedContent, { - active: false, - }) - } else { - return payload - } + return this.changeTag(childTag, (m) => { + m.unsetParent() }) + } - const affectedUuids = await this.payloadManager.importPayloads( - validPayloads, - this.historyService.getHistoryMapCopy(), + public async associateFileWithNote(file: FileItem, note: SNNote): Promise { + const isVaultConflict = + file.key_system_identifier && + note.key_system_identifier && + file.key_system_identifier !== note.key_system_identifier + + if (isVaultConflict) { + void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked') + return undefined + } + + return this.changeItem(file, (mutator) => { + mutator.addNote(note) + }) + } + + public async disassociateFileWithNote(file: FileItem, note: SNNote): Promise { + return this.changeItem(file, (mutator) => { + mutator.removeNote(note) + }) + } + + public async addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise { + if (tag.key_system_identifier !== note.key_system_identifier) { + void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked') + return undefined + } + + let tagsToAdd = [tag] + + if (addHierarchy) { + const parentChainTags = this.itemManager.getTagParentChain(tag) + tagsToAdd = [...parentChainTags, tag] + } + + return Promise.all( + tagsToAdd.map((tagToAdd) => { + return this.changeTag(tagToAdd, (mutator) => { + mutator.addNote(note) + }) as Promise + }), ) + } - const promise = this.syncService.sync() - - if (awaitSync) { - await promise + public async addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise { + if (tag.key_system_identifier !== file.key_system_identifier) { + void this.alerts.alert('The items you are trying to link belong to different vaults and cannot be linked') + return undefined } - const affectedItems = this.itemManager.findItems(affectedUuids) as DecryptedItemInterface[] + let tagsToAdd = [tag] - return { - affectedItems: affectedItems, - errorCount: decryptedPayloadsOrError.length - validPayloads.length, + if (addHierarchy) { + const parentChainTags = this.itemManager.getTagParentChain(tag) + tagsToAdd = [...parentChainTags, tag] } + + return Promise.all( + tagsToAdd.map((tagToAdd) => { + return this.changeTag(tagToAdd, (mutator) => { + mutator.addFile(file) + }) as Promise + }), + ) + } + + public async linkNoteToNote(note: SNNote, otherNote: SNNote): Promise { + return this.changeItem(note, (mutator) => { + mutator.addNote(otherNote) + }) + } + + public async linkFileToFile(file: FileItem, otherFile: FileItem): Promise { + return this.changeItem(file, (mutator) => { + mutator.addFile(otherFile) + }) + } + + public async unlinkItems( + itemA: DecryptedItemInterface, + itemB: DecryptedItemInterface, + ): Promise> { + const relationshipDirection = this.itemManager.relationshipDirectionBetweenItems(itemA, itemB) + + if (relationshipDirection === ItemRelationshipDirection.NoRelationship) { + throw new Error('Trying to unlink already unlinked items') + } + + const itemToChange = relationshipDirection === ItemRelationshipDirection.AReferencesB ? itemA : itemB + const itemToRemove = itemToChange === itemA ? itemB : itemA + + return this.changeItem(itemToChange, (mutator) => { + mutator.removeItemAsRelationship(itemToRemove) + }) } } diff --git a/packages/snjs/lib/Services/Payloads/PayloadManager.ts b/packages/snjs/lib/Services/Payloads/PayloadManager.ts index 6ab84667b..bc83ebf1f 100644 --- a/packages/snjs/lib/Services/Payloads/PayloadManager.ts +++ b/packages/snjs/lib/Services/Payloads/PayloadManager.ts @@ -300,7 +300,7 @@ export class PayloadManager extends AbstractService implements PayloadManagerInt return Uuids(payloads) } - public removePayloadLocally(payload: FullyFormedPayloadInterface) { + public removePayloadLocally(payload: FullyFormedPayloadInterface | FullyFormedPayloadInterface[]): void { this.collection.discard(payload) } diff --git a/packages/snjs/lib/Services/Preferences/PreferencesService.ts b/packages/snjs/lib/Services/Preferences/PreferencesService.ts index 49a7f1166..5ec0a3c4a 100644 --- a/packages/snjs/lib/Services/Preferences/PreferencesService.ts +++ b/packages/snjs/lib/Services/Preferences/PreferencesService.ts @@ -10,6 +10,7 @@ import { ApplicationStage, PreferenceServiceInterface, PreferencesServiceEvent, + MutatorClientInterface, } from '@standardnotes/services' export class SNPreferencesService @@ -24,7 +25,8 @@ export class SNPreferencesService constructor( private singletonManager: SNSingletonManager, - private itemManager: ItemManager, + itemManager: ItemManager, + private mutator: MutatorClientInterface, private syncService: SNSyncService, protected override internalEventBus: InternalEventBusInterface, ) { @@ -45,7 +47,7 @@ export class SNPreferencesService this.removeItemObserver?.() this.removeSyncObserver?.() ;(this.singletonManager as unknown) = undefined - ;(this.itemManager as unknown) = undefined + ;(this.mutator as unknown) = undefined super.deinit() } @@ -77,7 +79,7 @@ export class SNPreferencesService return } - this.preferences = (await this.itemManager.changeItem(this.preferences, (m) => { + this.preferences = (await this.mutator.changeItem(this.preferences, (m) => { m.setPref(key, value) })) as SNUserPrefs diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts b/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts index fa4eecae6..753b35a65 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts @@ -6,6 +6,7 @@ import { InternalEventBusInterface, ChallengeReason, EncryptionService, + MutatorClientInterface, } from '@standardnotes/services' import { UuidGenerator } from '@standardnotes/utils' import { @@ -22,6 +23,7 @@ const setupRandomUuid = () => { } describe('protectionService', () => { + let mutator: MutatorClientInterface let protocolService: EncryptionService let challengeService: ChallengeService let storageService: DiskStorageService @@ -29,7 +31,7 @@ describe('protectionService', () => { let protectionService: SNProtectionService const createService = () => { - return new SNProtectionService(protocolService, challengeService, storageService, internalEventBus) + return new SNProtectionService(protocolService, mutator, challengeService, storageService, internalEventBus) } const createFile = (name: string, isProtected?: boolean) => { @@ -60,6 +62,8 @@ describe('protectionService', () => { protocolService = {} as jest.Mocked protocolService.hasAccount = jest.fn().mockReturnValue(true) protocolService.hasPasscode = jest.fn().mockReturnValue(false) + + mutator = {} as jest.Mocked }) describe('files', () => { diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts index 80fe8dc74..5c3e60977 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -1,6 +1,13 @@ import { ChallengeService } from './../Challenge/ChallengeService' import { SNLog } from '@Lib/Log' -import { DecryptedItem } from '@standardnotes/models' +import { + DecryptedItem, + DecryptedItemInterface, + DecryptedItemMutator, + FileItem, + MutationType, + SNNote, +} from '@standardnotes/models' import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService' import { isNullOrUndefined } from '@standardnotes/utils' import { @@ -9,7 +16,6 @@ import { StorageValueModes, ApplicationStage, StorageKey, - DiagnosticInfo, Challenge, ChallengeReason, ChallengePrompt, @@ -18,6 +24,7 @@ import { MobileUnlockTiming, TimingDisplayOption, ProtectionsClientInterface, + MutatorClientInterface, } from '@standardnotes/services' import { ContentType } from '@standardnotes/common' @@ -70,6 +77,7 @@ export class SNProtectionService extends AbstractService implem constructor( private protocolService: EncryptionService, + private mutator: MutatorClientInterface, private challengeService: ChallengeService, private storageService: DiskStorageService, protected override internalEventBus: InternalEventBusInterface, @@ -435,15 +443,69 @@ export class SNProtectionService extends AbstractService implem this.sessionExpiryTimeout = setTimeout(timer, expiryDate.getTime() - Date.now()) } - override getDiagnostics(): Promise { - return Promise.resolve({ - protections: { - getSessionExpiryDate: this.getSessionExpiryDate(), - getLastSessionLength: this.getLastSessionLength(), - hasProtectionSources: this.hasProtectionSources(), - hasUnprotectedAccessSession: this.hasUnprotectedAccessSession(), - hasBiometricsEnabled: this.hasBiometricsEnabled(), + async protectItems(items: I[]): Promise { + const protectedItems = await this.mutator.changeItems( + items, + (mutator) => { + mutator.protected = true }, - }) + MutationType.NoUpdateUserTimestamps, + ) + + return protectedItems + } + + async unprotectItems( + items: I[], + reason: ChallengeReason, + ): Promise { + if ( + !(await this.authorizeAction(reason, { + fallBackToAccountPassword: true, + requireAccountPassword: false, + forcePrompt: false, + })) + ) { + return undefined + } + + const unprotectedItems = await this.mutator.changeItems( + items, + (mutator) => { + mutator.protected = false + }, + MutationType.NoUpdateUserTimestamps, + ) + + return unprotectedItems + } + + public async protectNote(note: SNNote): Promise { + const result = await this.protectItems([note]) + return result[0] + } + + public async unprotectNote(note: SNNote): Promise { + const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote) + return result ? result[0] : undefined + } + + public async protectNotes(notes: SNNote[]): Promise { + return this.protectItems(notes) + } + + public async unprotectNotes(notes: SNNote[]): Promise { + const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote) + return results || [] + } + + async protectFile(file: FileItem): Promise { + const result = await this.protectItems([file]) + return result[0] + } + + async unprotectFile(file: FileItem): Promise { + const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile) + return result ? result[0] : undefined } } diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index e8c2c3191..e185d98e0 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -3,7 +3,6 @@ import { AbstractService, InternalEventBusInterface, StorageKey, - DiagnosticInfo, ChallengePrompt, ChallengeValidation, ChallengeKeyboardType, @@ -26,8 +25,12 @@ import { InternalEventInterface, ApiServiceEvent, SessionRefreshedData, + SessionEvent, + UserKeyPairChangedEventData, + InternalFeatureService, + InternalFeature, } from '@standardnotes/services' -import { Base64String } from '@standardnotes/sncrypto-common' +import { Base64String, PkcKeyPair } from '@standardnotes/sncrypto-common' import { ClientDisplayableError, SessionBody, @@ -43,7 +46,7 @@ import { SessionListResponse, HttpSuccessResponse, } from '@standardnotes/responses' -import { CopyPayloadWithContentOverride } from '@standardnotes/models' +import { CopyPayloadWithContentOverride, RootKeyWithKeyPairsInterface } from '@standardnotes/models' import { LegacySession, MapperInterface, Result, Session, SessionToken } from '@standardnotes/domain-core' import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey } from '@standardnotes/encryption' import { Subscription } from '@standardnotes/security' @@ -56,7 +59,7 @@ import { DiskStorageService } from '../Storage/DiskStorageService' import { SNWebSocketsService } from '../Api/WebsocketsService' import { Strings } from '@Lib/Strings' import { UuidString } from '@Lib/Types/UuidString' -import { ChallengeService } from '../Challenge' +import { ChallengeResponse, ChallengeService } from '../Challenge' import { ApiCallError, ErrorMessage, @@ -72,11 +75,6 @@ const cleanedEmailString = (email: string) => { return email.trim().toLowerCase() } -export enum SessionEvent { - Restored = 'SessionRestored', - Revoked = 'SessionRevoked', -} - /** * The session manager is responsible for loading initial user state, and any relevant * server credentials, such as the session token. It also exposes methods for registering @@ -139,18 +137,19 @@ export class SNSessionManager } } - private setUser(user?: User) { + private memoizeUser(user?: User) { this.user = user + this.apiService.setUser(user) } async initializeFromDisk() { - this.setUser(this.diskStorageService.getValue(StorageKey.User)) + this.memoizeUser(this.diskStorageService.getValue(StorageKey.User)) if (!this.user) { const legacyUuidLookup = this.diskStorageService.getValue(StorageKey.LegacyUuid) if (legacyUuidLookup) { - this.setUser({ uuid: legacyUuidLookup, email: legacyUuidLookup }) + this.memoizeUser({ uuid: legacyUuidLookup, email: legacyUuidLookup }) } } @@ -193,6 +192,36 @@ export class SNSessionManager return this.user } + public getSureUser(): User { + return this.user as User + } + + isUserMissingKeyPair(): boolean { + try { + return this.getPublicKey() == undefined + } catch (error) { + return true + } + } + + public getPublicKey(): string { + return this.protocolService.getKeyPair().publicKey + } + + public getSigningPublicKey(): string { + return this.protocolService.getSigningKeyPair().publicKey + } + + public get userUuid(): string { + const user = this.getUser() + + if (!user) { + throw Error('Attempting to access userUuid when user is undefined') + } + + return user.uuid + } + isCurrentSessionReadOnly(): boolean | undefined { if (this.session === undefined) { return undefined @@ -205,16 +234,13 @@ export class SNSessionManager return this.session.isReadOnly() } - public getSureUser() { - return this.user as User - } - public getSession() { return this.apiService.getSession() } public async signOut() { - this.setUser(undefined) + this.memoizeUser(undefined) + const session = this.apiService.getSession() if (session && session instanceof Session) { await this.apiService.signOut() @@ -268,7 +294,11 @@ export class SNSessionManager currentKeyParams?.version, ) if (isErrorResponse(response)) { - this.challengeService.setValidationStatusForChallenge(challenge, challengeResponse!.values[1], false) + this.challengeService.setValidationStatusForChallenge( + challenge, + (challengeResponse as ChallengeResponse).values[1], + false, + ) onResponse?.(response) } else { resolve() @@ -373,11 +403,20 @@ export class SNSessionManager email = cleanedEmailString(email) - const rootKey = await this.protocolService.createRootKey(email, password, Common.KeyParamsOrigination.Registration) + const rootKey = await this.protocolService.createRootKey( + email, + password, + Common.KeyParamsOrigination.Registration, + ) const serverPassword = rootKey.serverPassword as string const keyParams = rootKey.keyParams - const registerResponse = await this.userApiService.register({ email, serverPassword, keyParams, ephemeral }) + const registerResponse = await this.userApiService.register({ + email, + serverPassword, + keyParams, + ephemeral, + }) if ('error' in registerResponse.data) { throw new ApiCallError(registerResponse.data.error.message) @@ -485,7 +524,7 @@ export class SNSessionManager response: paramsResult.response, } } - const keyParams = paramsResult.keyParams! + const keyParams = paramsResult.keyParams as SNRootKeyParams if (!this.protocolService.supportedVersions().includes(keyParams.version)) { if (this.protocolService.isVersionNewerThanLibraryVersion(keyParams.version)) { return { @@ -563,7 +602,7 @@ export class SNSessionManager const signInResponse = await this.apiService.signIn({ email, - serverPassword: rootKey.serverPassword!, + serverPassword: rootKey.serverPassword as string, ephemeral, }) @@ -585,20 +624,49 @@ export class SNSessionManager public async changeCredentials(parameters: { currentServerPassword: string - newRootKey: SNRootKey + newRootKey: RootKeyWithKeyPairsInterface wrappingKey?: SNRootKey newEmail?: string }): Promise { - const userUuid = this.user!.uuid - const response = await this.apiService.changeCredentials({ + const userUuid = this.getSureUser().uuid + const rawResponse = await this.apiService.changeCredentials({ userUuid, currentServerPassword: parameters.currentServerPassword, - newServerPassword: parameters.newRootKey.serverPassword!, + newServerPassword: parameters.newRootKey.serverPassword as string, newKeyParams: parameters.newRootKey.keyParams, newEmail: parameters.newEmail, }) - return this.processChangeCredentialsResponse(response, parameters.newRootKey, parameters.wrappingKey) + let oldKeyPair: PkcKeyPair | undefined + let oldSigningKeyPair: PkcKeyPair | undefined + + try { + oldKeyPair = this.protocolService.getKeyPair() + oldSigningKeyPair = this.protocolService.getSigningKeyPair() + } catch (error) { + void error + } + + const processedResponse = await this.processChangeCredentialsResponse( + rawResponse, + parameters.newRootKey, + parameters.wrappingKey, + ) + + if (!isErrorResponse(rawResponse)) { + if (InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) { + const eventData: UserKeyPairChangedEventData = { + oldKeyPair, + oldSigningKeyPair, + newKeyPair: parameters.newRootKey.encryptionKeyPair, + newSigningKeyPair: parameters.newRootKey.signingKeyPair, + } + + void this.notifyEvent(SessionEvent.UserKeyPairChanged, eventData) + } + } + + return processedResponse } public async getSessionsList(): Promise> { @@ -669,12 +737,10 @@ export class SNSessionManager ) { await this.protocolService.setRootKey(rootKey, wrappingKey) - this.setUser(user) - + this.memoizeUser(user) this.diskStorageService.setValue(StorageKey.User, user) void this.apiService.setHost(host) - this.httpService.setHost(host) this.setSession(session) @@ -777,16 +843,4 @@ export class SNSessionManager return Result.ok(sessionOrError.getValue()) } - - override getDiagnostics(): Promise { - return Promise.resolve({ - session: { - isSessionRenewChallengePresented: this.isSessionRenewChallengePresented, - online: this.online(), - offline: this.offline(), - isSignedIn: this.isSignedIn(), - isSignedIntoFirstPartyServer: this.isSignedIntoFirstPartyServer(), - }, - }) - } } diff --git a/packages/snjs/lib/Services/Singleton/SingletonManager.ts b/packages/snjs/lib/Services/Singleton/SingletonManager.ts index c37f91c91..ff26b1c57 100644 --- a/packages/snjs/lib/Services/Singleton/SingletonManager.ts +++ b/packages/snjs/lib/Services/Singleton/SingletonManager.ts @@ -10,10 +10,17 @@ import { PayloadEmitSource, PayloadTimestampDefaults, getIncrementedDirtyIndex, + Predicate, } from '@standardnotes/models' import { arrayByRemovingFromIndex, extendArray, UuidGenerator } from '@standardnotes/utils' import { SNSyncService } from '../Sync/SyncService' -import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standardnotes/services' +import { + AbstractService, + InternalEventBusInterface, + MutatorClientInterface, + SingletonManagerInterface, + SyncEvent, +} from '@standardnotes/services' /** * The singleton manager allow consumers to ensure that only 1 item exists of a certain @@ -26,7 +33,7 @@ import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standard * 2. Items can override isSingleton, singletonPredicate, and strategyWhenConflictingWithItem (optional) * to automatically gain singleton resolution. */ -export class SNSingletonManager extends AbstractService { +export class SNSingletonManager extends AbstractService implements SingletonManagerInterface { private resolveQueue: DecryptedItemInterface[] = [] private removeItemObserver!: () => void @@ -34,6 +41,7 @@ export class SNSingletonManager extends AbstractService { constructor( private itemManager: ItemManager, + private mutator: MutatorClientInterface, private payloadManager: PayloadManager, private syncService: SNSyncService, protected override internalEventBus: InternalEventBusInterface, @@ -44,6 +52,7 @@ export class SNSingletonManager extends AbstractService { public override deinit(): void { ;(this.syncService as unknown) = undefined + ;(this.mutator as unknown) = undefined ;(this.itemManager as unknown) = undefined ;(this.payloadManager as unknown) = undefined @@ -148,7 +157,7 @@ export class SNSingletonManager extends AbstractService { }) const deleteItems = arrayByRemovingFromIndex(earliestFirst, 0) - await this.itemManager.setItemsToBeDeleted(deleteItems) + await this.mutator.setItemsToBeDeleted(deleteItems) } public findSingleton( @@ -222,7 +231,66 @@ export class SNSingletonManager extends AbstractService { ...PayloadTimestampDefaults(), }) - const item = await this.itemManager.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted) + const item = await this.mutator.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted) + + void this.syncService.sync({ sourceDescription: 'After find or create singleton' }) + + return item as T + } + + public async findOrCreateSingleton< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, + >(predicate: Predicate, contentType: ContentType, createContent: ItemContent): Promise { + const existingItems = this.itemManager.itemsMatchingPredicate(contentType, predicate) + if (existingItems.length > 0) { + return existingItems[0] + } + + /** Item not found, safe to create after full sync has completed */ + if (!this.syncService.getLastSyncDate()) { + /** + * Add a temporary observer in case of long-running sync request, where + * the item we're looking for ends up resolving early or in the middle. + */ + let matchingItem: DecryptedItemInterface | undefined + + const removeObserver = this.itemManager.addObserver(contentType, ({ inserted }) => { + if (inserted.length > 0) { + const matchingItems = inserted.filter((i) => i.satisfiesPredicate(predicate)) + + if (matchingItems.length > 0) { + matchingItem = matchingItems[0] + } + } + }) + + await this.syncService.sync({ sourceDescription: 'Find or create singleton, before any sync has completed' }) + + removeObserver() + + if (matchingItem) { + return matchingItem as T + } + + /** Check again */ + const refreshedItems = this.itemManager.itemsMatchingPredicate(contentType, predicate) + if (refreshedItems.length > 0) { + return refreshedItems[0] as T + } + } + + /** Safe to create */ + const dirtyPayload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: createContent, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + ...PayloadTimestampDefaults(), + }) + + const item = await this.mutator.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted) void this.syncService.sync({ sourceDescription: 'After find or create singleton' }) diff --git a/packages/snjs/lib/Services/Storage/DiskStorageService.ts b/packages/snjs/lib/Services/Storage/DiskStorageService.ts index e7e77b771..8d386cdb9 100644 --- a/packages/snjs/lib/Services/Storage/DiskStorageService.ts +++ b/packages/snjs/lib/Services/Storage/DiskStorageService.ts @@ -1,10 +1,9 @@ import { ContentType } from '@standardnotes/common' -import { Copy, extendArray, UuidGenerator } from '@standardnotes/utils' +import { Copy, extendArray, UuidGenerator, Uuids } from '@standardnotes/utils' import { SNLog } from '../../Log' import { isErrorDecryptingParameters, SNRootKey } from '@standardnotes/encryption' import * as Encryption from '@standardnotes/encryption' import * as Services from '@standardnotes/services' -import { DiagnosticInfo } from '@standardnotes/services' import { CreateDecryptedLocalStorageContextPayload, CreateDeletedLocalStorageContextPayload, @@ -252,7 +251,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv return rawContent as Services.StorageValuesObject } - public setValue(key: string, value: unknown, mode = Services.StorageValueModes.Default): void { + public setValue(key: string, value: T, mode = Services.StorageValueModes.Default): void { this.setValueWithNoPersist(key, value, mode) void this.persistValuesToDisk() @@ -292,6 +291,14 @@ export class DiskStorageService extends Services.AbstractService implements Serv return value != undefined ? (value as T) : (defaultValue as T) } + public getAllKeys(mode = Services.StorageValueModes.Default): string[] { + if (!this.values) { + throw Error('Attempting to get all keys before loading local storage.') + } + + return Object.keys(this.values[this.domainKeyForMode(mode)]) + } + public async removeValue(key: string, mode = Services.StorageValueModes.Default): Promise { if (!this.values) { throw Error(`Attempting to remove storage key ${key} before loading local storage.`) @@ -370,20 +377,28 @@ export class DiskStorageService extends Services.AbstractService implements Serv const encryptable: DecryptedPayloadInterface[] = [] const unencryptable: DecryptedPayloadInterface[] = [] - const split = Encryption.SplitPayloadsByEncryptionType(decrypted) - if (split.itemsKeyEncryption) { - extendArray(encryptable, split.itemsKeyEncryption) + const { rootKeyEncryption, keySystemRootKeyEncryption, itemsKeyEncryption } = + Encryption.SplitPayloadsByEncryptionType(decrypted) + + if (itemsKeyEncryption) { + extendArray(encryptable, itemsKeyEncryption) } - if (split.rootKeyEncryption) { + if (keySystemRootKeyEncryption) { + extendArray(encryptable, keySystemRootKeyEncryption) + } + + if (rootKeyEncryption) { if (!rootKeyEncryptionAvailable) { - extendArray(unencryptable, split.rootKeyEncryption) + extendArray(unencryptable, rootKeyEncryption) } else { - extendArray(encryptable, split.rootKeyEncryption) + extendArray(encryptable, rootKeyEncryption) } } - await this.deletePayloads(discardable) + if (discardable.length > 0) { + await this.deletePayloads(discardable) + } const encryptableSplit = Encryption.SplitPayloadsByEncryptionType(encryptable) @@ -406,16 +421,18 @@ export class DiskStorageService extends Services.AbstractService implements Serv } public async deletePayloads(payloads: DeletedPayloadInterface[]) { - await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid))) + await this.deletePayloadsWithUuids(Uuids(payloads)) } - public async forceDeletePayloads(payloads: FullyFormedPayloadInterface[]) { - await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid))) + public async deletePayloadsWithUuids(uuids: string[]): Promise { + await this.executeCriticalFunction(async () => { + await Promise.all(uuids.map((uuid) => this.deviceInterface.removeDatabaseEntry(uuid, this.identifier))) + }) } - public async deletePayloadWithId(uuid: string) { + public async deletePayloadWithUuid(uuid: string) { return this.executeCriticalFunction(async () => { - return this.deviceInterface.removeDatabaseEntry(uuid, this.identifier) + await this.deviceInterface.removeDatabaseEntry(uuid, this.identifier) }) } @@ -437,17 +454,4 @@ export class DiskStorageService extends Services.AbstractService implements Serv await this.deviceInterface.removeRawStorageValue(this.getPersistenceKey()) }) } - - override async getDiagnostics(): Promise { - return { - storage: { - storagePersistable: this.storagePersistable, - persistencePolicy: Services.StoragePersistencePolicies[this.persistencePolicy], - needsPersist: this.needsPersist, - currentPersistPromise: this.currentPersistPromise != undefined, - isStorageWrapped: this.isStorageWrapped(), - allRawPayloadsCount: (await this.getAllRawPayloads()).length, - }, - } - } } diff --git a/packages/snjs/lib/Services/Sync/Account/Operation.ts b/packages/snjs/lib/Services/Sync/Account/Operation.ts index 6d621c2d6..b62f76fe1 100644 --- a/packages/snjs/lib/Services/Sync/Account/Operation.ts +++ b/packages/snjs/lib/Services/Sync/Account/Operation.ts @@ -21,17 +21,15 @@ export class AccountSyncOperation { * @param receiver A function that receives callback multiple times during the operation */ constructor( - private payloads: ServerSyncPushContextualPayload[], + public readonly payloads: ServerSyncPushContextualPayload[], private receiver: ResponseSignalReceiver, - private lastSyncToken: string, - private paginationToken: string, private apiService: SNApiService, + public readonly options: { + syncToken?: string + paginationToken?: string + sharedVaultUuids?: string[] + }, ) { - this.payloads = payloads - this.lastSyncToken = lastSyncToken - this.paginationToken = paginationToken - this.apiService = apiService - this.receiver = receiver this.pendingPayloads = payloads.slice() } @@ -55,13 +53,19 @@ export class AccountSyncOperation { }) const payloads = this.popPayloads(this.upLimit) - const rawResponse = await this.apiService.sync(payloads, this.lastSyncToken, this.paginationToken, this.downLimit) + const rawResponse = await this.apiService.sync( + payloads, + this.options.syncToken, + this.options.paginationToken, + this.downLimit, + this.options.sharedVaultUuids, + ) const response = new ServerSyncResponse(rawResponse) this.responses.push(response) - this.lastSyncToken = response.lastSyncToken as string - this.paginationToken = response.paginationToken as string + this.options.syncToken = response.lastSyncToken as string + this.options.paginationToken = response.paginationToken as string try { await this.receiver(SyncSignal.Response, response) @@ -75,7 +79,7 @@ export class AccountSyncOperation { } get done() { - return this.pendingPayloads.length === 0 && !this.paginationToken + return this.pendingPayloads.length === 0 && !this.options.paginationToken } private get pendingUploadCount() { diff --git a/packages/snjs/lib/Services/Sync/Account/Response.ts b/packages/snjs/lib/Services/Sync/Account/Response.ts index 3732520f4..5e8e02d10 100644 --- a/packages/snjs/lib/Services/Sync/Account/Response.ts +++ b/packages/snjs/lib/Services/Sync/Account/Response.ts @@ -1,27 +1,36 @@ import { ApiEndpointParam, ConflictParams, - ConflictType, + SharedVaultInviteServerHash, + SharedVaultServerHash, HttpError, HttpResponse, isErrorResponse, RawSyncResponse, - ServerItemResponse, + UserEventServerHash, + AsymmetricMessageServerHash, } from '@standardnotes/responses' import { FilterDisallowedRemotePayloadsAndMap, CreateServerSyncSavedPayload, ServerSyncSavedContextualPayload, FilteredServerItem, + TrustedConflictParams, } from '@standardnotes/models' import { deepFreeze } from '@standardnotes/utils' +import { TrustedServerConflictMap } from './ServerConflictMap' export class ServerSyncResponse { - public readonly savedPayloads: ServerSyncSavedContextualPayload[] - public readonly retrievedPayloads: FilteredServerItem[] - public readonly uuidConflictPayloads: FilteredServerItem[] - public readonly dataConflictPayloads: FilteredServerItem[] - public readonly rejectedPayloads: FilteredServerItem[] + readonly savedPayloads: ServerSyncSavedContextualPayload[] + readonly retrievedPayloads: FilteredServerItem[] + readonly conflicts: TrustedServerConflictMap + + readonly asymmetricMessages: AsymmetricMessageServerHash[] + readonly vaults: SharedVaultServerHash[] + readonly vaultInvites: SharedVaultInviteServerHash[] + readonly userEvents: UserEventServerHash[] + + private readonly rawConflictObjects: ConflictParams[] private successResponseData: RawSyncResponse | undefined @@ -32,6 +41,10 @@ export class ServerSyncResponse { this.successResponseData = rawResponse.data } + const conflicts = this.successResponseData?.conflicts || [] + const legacyConflicts = this.successResponseData?.unsaved || [] + this.rawConflictObjects = conflicts.concat(legacyConflicts) + this.savedPayloads = FilterDisallowedRemotePayloadsAndMap(this.successResponseData?.saved_items || []).map( (rawItem) => { return CreateServerSyncSavedPayload(rawItem) @@ -40,15 +53,53 @@ export class ServerSyncResponse { this.retrievedPayloads = FilterDisallowedRemotePayloadsAndMap(this.successResponseData?.retrieved_items || []) - this.dataConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawDataConflictItems) + this.conflicts = this.filterConflicts() - this.uuidConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawUuidConflictItems) + this.vaults = this.successResponseData?.shared_vaults || [] - this.rejectedPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawRejectedPayloads) + this.vaultInvites = this.successResponseData?.shared_vault_invites || [] + + this.asymmetricMessages = this.successResponseData?.asymmetric_messages || [] + + this.userEvents = this.successResponseData?.user_events || [] deepFreeze(this) } + private filterConflicts(): TrustedServerConflictMap { + const conflicts = this.rawConflictObjects + const trustedConflicts: TrustedServerConflictMap = {} + + for (const conflict of conflicts) { + let serverItem: FilteredServerItem | undefined + let unsavedItem: FilteredServerItem | undefined + + if (conflict.unsaved_item) { + unsavedItem = FilterDisallowedRemotePayloadsAndMap([conflict.unsaved_item])[0] + } + + if (conflict.server_item) { + serverItem = FilterDisallowedRemotePayloadsAndMap([conflict.server_item])[0] + } + + if (!trustedConflicts[conflict.type]) { + trustedConflicts[conflict.type] = [] + } + + const conflictArray = trustedConflicts[conflict.type] + if (conflictArray) { + const entry: TrustedConflictParams = { + type: conflict.type, + server_item: serverItem, + unsaved_item: unsavedItem, + } + conflictArray.push(entry) + } + } + + return trustedConflicts + } + public get error(): HttpError | undefined { return isErrorResponse(this.rawResponse) ? this.rawResponse.data?.error : undefined } @@ -66,56 +117,9 @@ export class ServerSyncResponse { } public get numberOfItemsInvolved(): number { - return this.allFullyFormedPayloads.length - } + const allPayloads = [...this.retrievedPayloads, ...this.rawConflictObjects] - private get allFullyFormedPayloads(): FilteredServerItem[] { - return [ - ...this.retrievedPayloads, - ...this.dataConflictPayloads, - ...this.uuidConflictPayloads, - ...this.rejectedPayloads, - ] - } - - private get rawUuidConflictItems(): ServerItemResponse[] { - return this.rawConflictObjects - .filter((conflict) => { - return conflict.type === ConflictType.UuidConflict - }) - .map((conflict) => { - return conflict.unsaved_item || conflict.item! - }) - } - - private get rawDataConflictItems(): ServerItemResponse[] { - return this.rawConflictObjects - .filter((conflict) => { - return conflict.type === ConflictType.ConflictingData - }) - .map((conflict) => { - return conflict.server_item || conflict.item! - }) - } - - private get rawRejectedPayloads(): ServerItemResponse[] { - return this.rawConflictObjects - .filter((conflict) => { - return ( - conflict.type === ConflictType.ContentTypeError || - conflict.type === ConflictType.ContentError || - conflict.type === ConflictType.ReadOnlyError - ) - }) - .map((conflict) => { - return conflict.unsaved_item! - }) - } - - private get rawConflictObjects(): ConflictParams[] { - const conflicts = this.successResponseData?.conflicts || [] - const legacyConflicts = this.successResponseData?.unsaved || [] - return conflicts.concat(legacyConflicts) + return allPayloads.length } public get hasError(): boolean { diff --git a/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts b/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts index f739513cc..646606db3 100644 --- a/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts +++ b/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts @@ -1,3 +1,4 @@ +import { ConflictParams, ConflictType } from '@standardnotes/responses' import { ImmutablePayloadCollection, HistoryMap, @@ -11,13 +12,12 @@ import { DeltaRemoteRejected, DeltaEmit, } from '@standardnotes/models' +import { DecryptedServerConflictMap } from './ServerConflictMap' type PayloadSet = { retrievedPayloads: FullyFormedPayloadInterface[] savedPayloads: ServerSyncSavedContextualPayload[] - uuidConflictPayloads: FullyFormedPayloadInterface[] - dataConflictPayloads: FullyFormedPayloadInterface[] - rejectedPayloads: FullyFormedPayloadInterface[] + conflicts: DecryptedServerConflictMap } /** @@ -39,8 +39,8 @@ export class ServerSyncResponseResolver { emits.push(this.processRetrievedPayloads()) emits.push(this.processSavedPayloads()) - emits.push(this.processUuidConflictPayloads()) - emits.push(this.processDataConflictPayloads()) + emits.push(this.processUuidConflictUnsavedPayloads()) + emits.push(this.processDataConflictServerPayloads()) emits.push(this.processRejectedPayloads()) return emits @@ -60,27 +60,42 @@ export class ServerSyncResponseResolver { return delta.result() } - private processDataConflictPayloads(): DeltaEmit { - const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.dataConflictPayloads) + private getConflictsForType>(type: ConflictType): T[] { + const results = this.payloadSet.conflicts[type] || [] - const delta = new DeltaRemoteDataConflicts(this.baseCollection, collection, this.historyMap) + return results as T[] + } + + private processDataConflictServerPayloads(): DeltaEmit { + const delta = new DeltaRemoteDataConflicts( + this.baseCollection, + this.getConflictsForType(ConflictType.ConflictingData), + this.historyMap, + ) return delta.result() } - private processUuidConflictPayloads(): DeltaEmit { - const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.uuidConflictPayloads) - - const delta = new DeltaRemoteUuidConflicts(this.baseCollection, collection) + private processUuidConflictUnsavedPayloads(): DeltaEmit { + const delta = new DeltaRemoteUuidConflicts(this.baseCollection, this.getConflictsForType(ConflictType.UuidConflict)) return delta.result() } private processRejectedPayloads(): DeltaEmit { - const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.rejectedPayloads) + const conflicts = [ + ...this.getConflictsForType(ConflictType.ContentTypeError), + ...this.getConflictsForType(ConflictType.ContentError), + ...this.getConflictsForType(ConflictType.ReadOnlyError), + ...this.getConflictsForType(ConflictType.UuidError), + ...this.getConflictsForType(ConflictType.SharedVaultSnjsVersionError), + ...this.getConflictsForType(ConflictType.SharedVaultInsufficientPermissionsError), + ...this.getConflictsForType(ConflictType.SharedVaultNotMemberError), + ...this.getConflictsForType(ConflictType.SharedVaultInvalidState), + ] - const delta = new DeltaRemoteRejected(this.baseCollection, collection) - - return delta.result() + const delta = new DeltaRemoteRejected(this.baseCollection, conflicts) + const result = delta.result() + return result } } diff --git a/packages/snjs/lib/Services/Sync/Account/ServerConflictMap.ts b/packages/snjs/lib/Services/Sync/Account/ServerConflictMap.ts new file mode 100644 index 000000000..1951bda56 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Account/ServerConflictMap.ts @@ -0,0 +1,5 @@ +import { ConflictType, ConflictParams } from '@standardnotes/responses' +import { FullyFormedPayloadInterface, TrustedConflictParams } from '@standardnotes/models' + +export type TrustedServerConflictMap = Partial> +export type DecryptedServerConflictMap = Partial[]>> diff --git a/packages/snjs/lib/Services/Sync/SyncService.ts b/packages/snjs/lib/Services/Sync/SyncService.ts index ddcce3fac..9ecb80911 100644 --- a/packages/snjs/lib/Services/Sync/SyncService.ts +++ b/packages/snjs/lib/Services/Sync/SyncService.ts @@ -1,15 +1,15 @@ +import { ConflictParams, ConflictType } from '@standardnotes/responses' import { log, LoggingDomain } from './../../Logging' import { AccountSyncOperation } from '@Lib/Services/Sync/Account/Operation' import { ContentType } from '@standardnotes/common' import { + Uuids, extendArray, isNotUndefined, isNullOrUndefined, removeFromIndex, sleep, subtractFromArray, - useBoolean, - Uuids, } from '@standardnotes/utils' import { ItemManager } from '@Lib/Services/Items/ItemManager' import { OfflineSyncOperation } from '@Lib/Services/Sync/Offline/Operation' @@ -56,6 +56,12 @@ import { getIncrementedDirtyIndex, getCurrentDirtyIndex, ItemContent, + KeySystemItemsKeyContent, + KeySystemItemsKeyInterface, + FullyFormedTransferPayload, + ItemMutator, + isDecryptedOrDeletedItem, + MutationType, } from '@standardnotes/models' import { AbstractService, @@ -71,11 +77,14 @@ import { SyncOptions, SyncQueueStrategy, SyncServiceInterface, - DiagnosticInfo, EncryptionService, DeviceInterface, isFullEntryLoadChunkResponse, isChunkFullEntry, + SyncEventReceivedSharedVaultInvitesData, + SyncEventReceivedRemoteSharedVaultsData, + SyncEventReceivedUserEventsData, + SyncEventReceivedAsymmetricMessagesData, } from '@standardnotes/services' import { OfflineSyncResponse } from './Offline/Response' import { @@ -86,10 +95,23 @@ import { } from '@standardnotes/encryption' import { CreatePayloadFromRawServerItem } from './Account/Utilities' import { ApplicationSyncOptions } from '@Lib/Application/Options/OptionalOptions' +import { DecryptedServerConflictMap, TrustedServerConflictMap } from './Account/ServerConflictMap' const DEFAULT_MAJOR_CHANGE_THRESHOLD = 15 const INVALID_SESSION_RESPONSE_STATUS = 401 +/** Content types appearing first are always mapped first */ +const ContentTypeLocalLoadPriorty = [ + ContentType.ItemsKey, + ContentType.KeySystemRootKey, + ContentType.KeySystemItemsKey, + ContentType.VaultListing, + ContentType.TrustedContact, + ContentType.UserPrefs, + ContentType.Component, + ContentType.Theme, +] + /** * The sync service orchestrates with the model manager, api service, and storage service * to ensure consistent state between the three. When a change is made to an item, consumers @@ -100,7 +122,7 @@ const INVALID_SESSION_RESPONSE_STATUS = 401 * The sync service largely does not perform any task unless it is called upon. */ export class SNSyncService - extends AbstractService + extends AbstractService implements SyncServiceInterface, InternalEventHandlerInterface, SyncClientInterface { private dirtyIndexAtLastPresyncSave?: number @@ -128,14 +150,6 @@ export class SNSyncService public lastSyncInvokationPromise?: Promise public currentSyncRequestPromise?: Promise - /** Content types appearing first are always mapped first */ - private readonly localLoadPriorty = [ - ContentType.ItemsKey, - ContentType.UserPrefs, - ContentType.Component, - ContentType.Theme, - ] - constructor( private itemManager: ItemManager, private sessionManager: SNSessionManager, @@ -225,29 +239,21 @@ export class SNSyncService return this.databaseLoaded } - private async processItemsKeysFirstDuringDatabaseLoad( - itemsKeysPayloads: FullyFormedPayloadInterface[], - ): Promise { - if (itemsKeysPayloads.length === 0) { + private async processPriorityItemsForDatabaseLoad(items: FullyFormedPayloadInterface[]): Promise { + if (items.length === 0) { return } - const encryptedItemsKeysPayloads = itemsKeysPayloads.filter(isEncryptedPayload) + const encryptedPayloads = items.filter(isEncryptedPayload) + const alreadyDecryptedPayloads = items.filter(isDecryptedPayload) as DecryptedPayloadInterface[] - const originallyDecryptedItemsKeysPayloads = itemsKeysPayloads.filter( - isDecryptedPayload, - ) as DecryptedPayloadInterface[] + const encryptionSplit = SplitPayloadsByEncryptionType(encryptedPayloads) + const decryptionSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit) - const itemsKeysSplit: KeyedDecryptionSplit = { - usesRootKeyWithKeyLookup: { - items: encryptedItemsKeysPayloads, - }, - } - - const newlyDecryptedItemsKeys = await this.protocolService.decryptSplit(itemsKeysSplit) + const newlyDecryptedPayloads = await this.protocolService.decryptSplit(decryptionSplit) await this.payloadManager.emitPayloads( - [...originallyDecryptedItemsKeysPayloads, ...newlyDecryptedItemsKeys], + [...alreadyDecryptedPayloads, ...newlyDecryptedPayloads], PayloadEmitSource.LocalDatabaseLoaded, ) } @@ -262,7 +268,7 @@ export class SNSyncService const chunks = await this.device.getDatabaseLoadChunks( { batchSize: this.options.loadBatchSize, - contentTypePriority: this.localLoadPriorty, + contentTypePriority: ContentTypeLocalLoadPriorty, uuidPriority: this.launchPriorityUuids, }, this.identifier, @@ -272,18 +278,30 @@ export class SNSyncService ? chunks.fullEntries.itemsKeys.entries : await this.device.getDatabaseEntries(this.identifier, chunks.keys.itemsKeys.keys) - const itemsKeyPayloads = itemsKeyEntries - .map((entry) => { - try { - return CreatePayload(entry, PayloadSource.Constructor) - } catch (e) { - console.error('Creating payload failed', e) - return undefined - } - }) - .filter(isNotUndefined) + const keySystemRootKeyEntries = isFullEntryLoadChunkResponse(chunks) + ? chunks.fullEntries.keySystemRootKeys.entries + : await this.device.getDatabaseEntries(this.identifier, chunks.keys.keySystemRootKeys.keys) - await this.processItemsKeysFirstDuringDatabaseLoad(itemsKeyPayloads) + const keySystemItemsKeyEntries = isFullEntryLoadChunkResponse(chunks) + ? chunks.fullEntries.keySystemItemsKeys.entries + : await this.device.getDatabaseEntries(this.identifier, chunks.keys.keySystemItemsKeys.keys) + + const createPayloadFromEntry = (entry: FullyFormedTransferPayload) => { + try { + return CreatePayload(entry, PayloadSource.LocalDatabaseLoaded) + } catch (e) { + console.error('Creating payload failed', e) + return undefined + } + } + + await this.processPriorityItemsForDatabaseLoad(itemsKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined)) + await this.processPriorityItemsForDatabaseLoad( + keySystemRootKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined), + ) + await this.processPriorityItemsForDatabaseLoad( + keySystemItemsKeyEntries.map(createPayloadFromEntry).filter(isNotUndefined), + ) /** * Map in batches to give interface a chance to update. Note that total decryption @@ -308,7 +326,7 @@ export class SNSyncService const payloads = dbEntries .map((entry) => { try { - return CreatePayload(entry, PayloadSource.Constructor) + return CreatePayload(entry, PayloadSource.LocalDatabaseLoaded) } catch (e) { console.error('Creating payload failed', e) return undefined @@ -348,13 +366,10 @@ export class SNSyncService } } - const split: KeyedDecryptionSplit = { - usesItemsKeyWithKeyLookup: { - items: encrypted, - }, - } + const encryptionSplit = SplitPayloadsByEncryptionType(encrypted) + const decryptionSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit) - const results = await this.protocolService.decryptSplit(split) + const results = await this.protocolService.decryptSplit(decryptionSplit) await this.payloadManager.emitPayloads([...nonencrypted, ...results], PayloadEmitSource.LocalDatabaseLoaded) @@ -616,11 +631,7 @@ export class SNSyncService if (useStrategy === SyncQueueStrategy.ResolveOnNext) { return this.queueStrategyResolveOnNext() } else if (useStrategy === SyncQueueStrategy.ForceSpawnNew) { - return this.queueStrategyForceSpawnNew({ - mode: options.mode, - checkIntegrity: options.checkIntegrity, - source: options.source, - }) + return this.queueStrategyForceSpawnNew(options) } else { throw Error(`Unhandled timing strategy ${useStrategy}`) } @@ -634,7 +645,7 @@ export class SNSyncService ) { this.opStatus.setDidBegin() - await this.notifyEvent(SyncEvent.SyncWillBegin) + await this.notifyEvent(SyncEvent.SyncDidBeginProcessing) /** * Subtract from array as soon as we're sure they'll be called. @@ -647,12 +658,41 @@ export class SNSyncService * Setting this value means the item was 100% sent to the server. */ if (items.length > 0) { - return this.itemManager.setLastSyncBeganForItems(items, beginDate, frozenDirtyIndex) + return this.setLastSyncBeganForItems(items, beginDate, frozenDirtyIndex) } else { return items } } + private async setLastSyncBeganForItems( + itemsToLookupUuidsFor: (DecryptedItemInterface | DeletedItemInterface)[], + date: Date, + globalDirtyIndex: number, + ): Promise<(DecryptedItemInterface | DeletedItemInterface)[]> { + const uuids = Uuids(itemsToLookupUuidsFor) + + const items = this.itemManager.getCollection().findAll(uuids).filter(isDecryptedOrDeletedItem) + + const payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[] = [] + + for (const item of items) { + const mutator = new ItemMutator( + item, + MutationType.NonDirtying, + ) + + mutator.setBeginSync(date, globalDirtyIndex) + + const payload = mutator.getResult() + + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, PayloadEmitSource.PreSyncSave) + + return this.itemManager.findAnyItems(uuids) as (DecryptedItemInterface | DeletedItemInterface)[] + } + /** * The InTime resolve queue refers to any sync requests that were made while we still * have not sent out the current request. So, anything in the InTime resolve queue @@ -725,12 +765,15 @@ export class SNSyncService private async createServerSyncOperation( payloads: ServerSyncPushContextualPayload[], - checkIntegrity: boolean, - source: SyncSource, + options: SyncOptions, mode: SyncMode = SyncMode.Default, ) { - const syncToken = await this.getLastSyncToken() - const paginationToken = await this.getPaginationToken() + const syncToken = + options.sharedVaultUuids && options.sharedVaultUuids.length > 0 && options.syncSharedVaultsFromScratch + ? undefined + : await this.getLastSyncToken() + const paginationToken = + options.sharedVaultUuids && options.syncSharedVaultsFromScratch ? undefined : await this.getPaginationToken() const operation = new AccountSyncOperation( payloads, @@ -753,20 +796,23 @@ export class SNSyncService break } }, - syncToken, - paginationToken, this.apiService, + { + syncToken, + paginationToken, + sharedVaultUuids: options.sharedVaultUuids, + }, ) log( LoggingDomain.Sync, 'Syncing online user', 'source', - SyncSource[source], + SyncSource[options.source], 'operation id', operation.id, 'integrity check', - checkIntegrity, + options.checkIntegrity, 'mode', SyncMode[mode], 'syncToken', @@ -789,12 +835,7 @@ export class SNSyncService const { uploadPayloads, syncMode } = await this.getOnlineSyncParameters(payloads, options.mode) return { - operation: await this.createServerSyncOperation( - uploadPayloads, - useBoolean(options.checkIntegrity, false), - options.source, - syncMode, - ), + operation: await this.createServerSyncOperation(uploadPayloads, options, syncMode), mode: syncMode, } } else { @@ -867,6 +908,7 @@ export class SNSyncService await this.notifyEventSync(SyncEvent.SyncCompletedWithAllItemsUploadedAndDownloaded, { source: options.source, + options, }) this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue) @@ -889,7 +931,7 @@ export class SNSyncService this.opStatus.clearError() - await this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response) + await this.notifyEvent(SyncEvent.PaginatedSyncRequestCompleted, response) } private handleErrorServerResponse(response: ServerSyncResponse) { @@ -917,19 +959,36 @@ export class SNSyncService const historyMap = this.historyService.getHistoryMapCopy() + if (response.userEvents) { + await this.notifyEventSync(SyncEvent.ReceivedUserEvents, response.userEvents as SyncEventReceivedUserEventsData) + } + + if (response.asymmetricMessages) { + await this.notifyEventSync( + SyncEvent.ReceivedAsymmetricMessages, + response.asymmetricMessages as SyncEventReceivedAsymmetricMessagesData, + ) + } + + if (response.vaults) { + await this.notifyEventSync( + SyncEvent.ReceivedRemoteSharedVaults, + response.vaults as SyncEventReceivedRemoteSharedVaultsData, + ) + } + + if (response.vaultInvites) { + await this.notifyEventSync( + SyncEvent.ReceivedSharedVaultInvites, + response.vaultInvites as SyncEventReceivedSharedVaultInvitesData, + ) + } + const resolver = new ServerSyncResponseResolver( { retrievedPayloads: await this.processServerPayloads(response.retrievedPayloads, PayloadSource.RemoteRetrieved), savedPayloads: response.savedPayloads, - uuidConflictPayloads: await this.processServerPayloads( - response.uuidConflictPayloads, - PayloadSource.RemoteRetrieved, - ), - dataConflictPayloads: await this.processServerPayloads( - response.dataConflictPayloads, - PayloadSource.RemoteRetrieved, - ), - rejectedPayloads: await this.processServerPayloads(response.rejectedPayloads, PayloadSource.RemoteRetrieved), + conflicts: await this.decryptServerConflicts(response.conflicts), }, masterCollection, operation.payloadsSavedOrSaving, @@ -954,11 +1013,69 @@ export class SNSyncService await this.persistPayloads(payloadsToPersist) } - await Promise.all([ - this.setLastSyncToken(response.lastSyncToken as string), - this.setPaginationToken(response.paginationToken as string), - this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response), - ]) + if (!operation.options.sharedVaultUuids) { + await Promise.all([ + this.setLastSyncToken(response.lastSyncToken as string), + this.setPaginationToken(response.paginationToken as string), + ]) + } + + await this.notifyEvent(SyncEvent.PaginatedSyncRequestCompleted, { + ...response, + uploadedPayloads: operation.payloads, + options: operation.options, + }) + } + + private async decryptServerConflicts(conflictMap: TrustedServerConflictMap): Promise { + const decrypted: DecryptedServerConflictMap = {} + + for (const conflictType of Object.keys(conflictMap)) { + const conflictsForType = conflictMap[conflictType as ConflictType] + if (!conflictsForType) { + continue + } + + if (!decrypted[conflictType as ConflictType]) { + decrypted[conflictType as ConflictType] = [] + } + + const decryptedConflictsForType = decrypted[conflictType as ConflictType] + if (!decryptedConflictsForType) { + throw Error('Decrypted conflicts for type should exist') + } + + for (const conflict of conflictsForType) { + const decryptedUnsavedItem = conflict.unsaved_item + ? await this.processServerPayload(conflict.unsaved_item, PayloadSource.RemoteRetrieved) + : undefined + + const decryptedServerItem = conflict.server_item + ? await this.processServerPayload(conflict.server_item, PayloadSource.RemoteRetrieved) + : undefined + + const decryptedEntry: ConflictParams = < + ConflictParams + >{ + type: conflict.type, + unsaved_item: decryptedUnsavedItem, + server_item: decryptedServerItem, + } + + decryptedConflictsForType.push(decryptedEntry) + } + } + + return decrypted + } + + private async processServerPayload( + item: FilteredServerItem, + source: PayloadSource, + ): Promise { + const result = await this.processServerPayloads([item], source) + + return result[0] } private async processServerPayloads( @@ -971,7 +1088,8 @@ export class SNSyncService const results: FullyFormedPayloadInterface[] = [...deleted] - const { rootKeyEncryption, itemsKeyEncryption } = SplitPayloadsByEncryptionType(encrypted) + const { rootKeyEncryption, itemsKeyEncryption, keySystemRootKeyEncryption } = + SplitPayloadsByEncryptionType(encrypted) const { results: rootKeyDecryptionResults, map: processedItemsKeys } = await this.decryptServerItemsKeys( rootKeyEncryption || [], @@ -979,8 +1097,16 @@ export class SNSyncService extendArray(results, rootKeyDecryptionResults) + const { results: keySystemRootKeyDecryptionResults, map: processedKeySystemItemsKeys } = + await this.decryptServerKeySystemItemsKeys(keySystemRootKeyEncryption || []) + + extendArray(results, keySystemRootKeyDecryptionResults) + if (itemsKeyEncryption) { - const decryptionResults = await this.decryptProcessedServerPayloads(itemsKeyEncryption, processedItemsKeys) + const decryptionResults = await this.decryptProcessedServerPayloads(itemsKeyEncryption, { + ...processedItemsKeys, + ...processedKeySystemItemsKeys, + }) extendArray(results, decryptionResults) } @@ -1017,17 +1143,53 @@ export class SNSyncService } } + private async decryptServerKeySystemItemsKeys(payloads: EncryptedPayloadInterface[]) { + const map: Record> = {} + + if (payloads.length === 0) { + return { + results: [], + map, + } + } + + const keySystemRootKeySplit: KeyedDecryptionSplit = { + usesKeySystemRootKeyWithKeyLookup: { + items: payloads, + }, + } + + const results = await this.protocolService.decryptSplit(keySystemRootKeySplit) + + results.forEach((result) => { + if ( + isDecryptedPayload(result) && + result.content_type === ContentType.KeySystemItemsKey + ) { + map[result.uuid] = result + } + }) + + return { + results, + map, + } + } + private async decryptProcessedServerPayloads( payloads: EncryptedPayloadInterface[], - map: Record>, + map: Record>, ): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> { return Promise.all( payloads.map(async (encrypted) => { - const previouslyProcessedItemsKey: DecryptedPayloadInterface | undefined = - map[encrypted.items_key_id as string] + const previouslyProcessedItemsKey: + | DecryptedPayloadInterface + | undefined = map[encrypted.items_key_id as string] const itemsKey = previouslyProcessedItemsKey - ? (CreateDecryptedItemFromPayload(previouslyProcessedItemsKey) as ItemsKeyInterface) + ? (CreateDecryptedItemFromPayload(previouslyProcessedItemsKey) as + | ItemsKeyInterface + | KeySystemItemsKeyInterface) : undefined const keyedSplit: KeyedDecryptionSplit = {} @@ -1251,26 +1413,13 @@ export class SNSyncService await this.persistPayloads(emit.emits) } - override async getDiagnostics(): Promise { - const dirtyUuids = Uuids(this.itemsNeedingSync()) - - return { - sync: { - syncToken: await this.getLastSyncToken(), - cursorToken: await this.getPaginationToken(), - dirtyIndexAtLastPresyncSave: this.dirtyIndexAtLastPresyncSave, - lastSyncDate: this.lastSyncDate, - outOfSync: this.outOfSync, - completedOnlineDownloadFirstSync: this.completedOnlineDownloadFirstSync, - clientLocked: this.clientLocked, - databaseLoaded: this.databaseLoaded, - syncLock: this.syncLock, - dealloced: this.dealloced, - itemsNeedingSync: dirtyUuids, - itemsNeedingSyncCount: dirtyUuids.length, - pendingRequestCount: this.resolveQueue.length + this.spawnQueue.length, - }, - } + async syncSharedVaultsFromScratch(sharedVaultUuids: string[]): Promise { + await this.sync({ + sharedVaultUuids: sharedVaultUuids, + syncSharedVaultsFromScratch: true, + queueStrategy: SyncQueueStrategy.ForceSpawnNew, + awaitAll: true, + }) } /** @e2e_testing */ diff --git a/packages/snjs/lib/Strings/Input.ts b/packages/snjs/lib/Strings/Input.ts deleted file mode 100644 index d1f1082de..000000000 --- a/packages/snjs/lib/Strings/Input.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const InputStrings = { - FileAccountPassword: 'File account password', -} diff --git a/packages/snjs/lib/Strings/index.ts b/packages/snjs/lib/Strings/index.ts index ca0aa6255..7da9fe31d 100644 --- a/packages/snjs/lib/Strings/index.ts +++ b/packages/snjs/lib/Strings/index.ts @@ -1,9 +1,7 @@ import { ConfirmStrings } from './Confirm' -import { InputStrings } from './Input' import { NetworkStrings } from './Network' export const Strings = { Network: NetworkStrings, Confirm: ConfirmStrings, - Input: InputStrings, } diff --git a/packages/snjs/lib/index.ts b/packages/snjs/lib/index.ts index 92d8f264a..ca3479706 100644 --- a/packages/snjs/lib/index.ts +++ b/packages/snjs/lib/index.ts @@ -9,6 +9,7 @@ export * from './Types' export * from './Version' export * from '@standardnotes/common' export * from '@standardnotes/domain-core' +export * from '@standardnotes/api' export * from '@standardnotes/encryption' export * from '@standardnotes/features' export * from '@standardnotes/files' diff --git a/packages/snjs/lib/tsconfig.json b/packages/snjs/lib/tsconfig.json index 5940d3fb3..5b1319423 100644 --- a/packages/snjs/lib/tsconfig.json +++ b/packages/snjs/lib/tsconfig.json @@ -8,6 +8,8 @@ "emitDeclarationOnly": true, "esModuleInterop": true, "isolatedModules": true, + "lib": ["es6", "dom", "es2016", "es2017"], + "module": "esnext", "moduleResolution": "node", "newLine": "lf", "noFallthroughCasesInSwitch": true, @@ -17,11 +19,11 @@ "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, - "skipLibCheck": true, "outDir": "../dist/@types", + "skipLibCheck": true, "strict": true, "strictNullChecks": true, - "target": "esnext", + "target": "es6", "paths": { "@Lib/*": ["*"], "@Services/*": ["Services/*"] diff --git a/packages/snjs/mocha/000.test.js b/packages/snjs/mocha/000.test.js index 5f9b984c4..f612e88ae 100644 --- a/packages/snjs/mocha/000.test.js +++ b/packages/snjs/mocha/000.test.js @@ -22,7 +22,7 @@ describe('000 legacy protocol operations', () => { let error try { - protocol004.generateDecryptedParametersSync({ + protocol004.generateDecryptedParameters({ uuid: 'foo', content: string, content_type: 'foo', diff --git a/packages/snjs/mocha/004.test.js b/packages/snjs/mocha/004.test.js index 1a99816df..2b08a9041 100644 --- a/packages/snjs/mocha/004.test.js +++ b/packages/snjs/mocha/004.test.js @@ -7,16 +7,16 @@ const expect = chai.expect describe('004 protocol operations', function () { const _identifier = 'hello@test.com' const _password = 'password' - let _keyParams - let _key + let rootKeyParams + let rootKey const application = Factory.createApplicationWithRealCrypto() const protocol004 = new SNProtocolOperator004(new SNWebCrypto()) before(async function () { await Factory.initializeApplication(application) - _key = await protocol004.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) - _keyParams = _key.keyParams + rootKey = await protocol004.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + rootKeyParams = rootKey.keyParams }) after(async function () { @@ -69,43 +69,58 @@ describe('004 protocol operations', function () { }) it('properly encrypts and decrypts', async function () { - const text = 'hello world' - const rawKey = _key.masterKey - const nonce = await application.protocolService.crypto.generateRandomKey(192) + const payload = new DecryptedPayload({ + uuid: '123', + content_type: ContentType.ItemsKey, + content: FillItemContent({ + title: 'foo', + text: 'bar', + }), + }) + const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) - const authenticatedData = { foo: 'bar' } - const encString = await operator.encryptString004(text, rawKey, nonce, authenticatedData) - const decString = await operator.decryptString004( - encString, - rawKey, - nonce, - await operator.authenticatedDataToString(authenticatedData), - ) - expect(decString).to.equal(text) + + const encrypted = await operator.generateEncryptedParameters(payload, rootKey) + const decrypted = await operator.generateDecryptedParameters(encrypted, rootKey) + + expect(decrypted.content.title).to.equal('foo') + expect(decrypted.content.text).to.equal('bar') }) it('fails to decrypt non-matching aad', async function () { - const text = 'hello world' - const rawKey = _key.masterKey - const nonce = await application.protocolService.crypto.generateRandomKey(192) + const payload = new DecryptedPayload({ + uuid: '123', + content_type: ContentType.ItemsKey, + content: FillItemContent({ + title: 'foo', + text: 'bar', + }), + }) + const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) - const aad = { foo: 'bar' } - const nonmatchingAad = { foo: 'rab' } - const encString = await operator.encryptString004(text, rawKey, nonce, aad) - const decString = await operator.decryptString004(encString, rawKey, nonce, nonmatchingAad) - expect(decString).to.not.be.ok + + const encrypted = await operator.generateEncryptedParameters(payload, rootKey) + const decrypted = await operator.generateDecryptedParameters( + { + ...encrypted, + uuid: 'nonmatching', + }, + rootKey, + ) + + expect(decrypted.errorDecrypting).to.equal(true) }) it('generates existing keys for key params', async function () { - const key = await application.protocolService.computeRootKey(_password, _keyParams) - expect(key.compare(_key)).to.be.true + const key = await application.protocolService.computeRootKey(_password, rootKeyParams) + expect(key.compare(rootKey)).to.be.true }) it('can decrypt encrypted params', async function () { const payload = Factory.createNotePayload() const key = await protocol004.createItemsKey() - const params = await protocol004.generateEncryptedParametersSync(payload, key) - const decrypted = await protocol004.generateDecryptedParametersSync(params, key) + const params = await protocol004.generateEncryptedParameters(payload, key) + const decrypted = await protocol004.generateDecryptedParameters(params, key) expect(decrypted.errorDecrypting).to.not.be.ok expect(decrypted.content).to.eql(payload.content) }) @@ -113,9 +128,9 @@ describe('004 protocol operations', function () { it('modifying the uuid of the payload should fail to decrypt', async function () { const payload = Factory.createNotePayload() const key = await protocol004.createItemsKey() - const params = await protocol004.generateEncryptedParametersSync(payload, key) + const params = await protocol004.generateEncryptedParameters(payload, key) params.uuid = 'foo' - const result = await protocol004.generateDecryptedParametersSync(params, key) + const result = await protocol004.generateDecryptedParameters(params, key) expect(result.errorDecrypting).to.equal(true) }) }) diff --git a/packages/snjs/mocha/TestRegistry/BaseTests.js b/packages/snjs/mocha/TestRegistry/BaseTests.js new file mode 100644 index 000000000..da672bf47 --- /dev/null +++ b/packages/snjs/mocha/TestRegistry/BaseTests.js @@ -0,0 +1,58 @@ +export const BaseTests = [ + 'memory.test.js', + 'protocol.test.js', + 'utils.test.js', + '000.test.js', + '001.test.js', + '002.test.js', + '003.test.js', + '004.test.js', + 'username.test.js', + 'app-group.test.js', + 'application.test.js', + 'payload.test.js', + 'payload_encryption.test.js', + 'item.test.js', + 'item_manager.test.js', + 'features.test.js', + 'settings.test.js', + 'mfa_service.test.js', + 'mutator.test.js', + 'mutator_service.test.js', + 'payload_manager.test.js', + 'collections.test.js', + 'note_display_criteria.test.js', + 'keys.test.js', + 'key_params.test.js', + 'key_recovery_service.test.js', + 'backups.test.js', + 'upgrading.test.js', + 'model_tests/importing.test.js', + 'model_tests/appmodels.test.js', + 'model_tests/items.test.js', + 'model_tests/mapping.test.js', + 'model_tests/notes_smart_tags.test.js', + 'model_tests/notes_tags.test.js', + 'model_tests/notes_tags_folders.test.js', + 'model_tests/performance.test.js', + 'sync_tests/offline.test.js', + 'sync_tests/notes_tags.test.js', + 'sync_tests/online.test.js', + 'sync_tests/conflicting.test.js', + 'sync_tests/integrity.test.js', + 'auth-fringe-cases.test.js', + 'auth.test.js', + 'device_auth.test.js', + 'storage.test.js', + 'protection.test.js', + 'singletons.test.js', + 'migrations/migration.test.js', + 'migrations/tags-to-folders.test.js', + 'history.test.js', + 'actions.test.js', + 'preferences.test.js', + 'files.test.js', + 'session.test.js', + 'subscriptions.test.js', + 'recovery.test.js', +]; diff --git a/packages/snjs/mocha/TestRegistry/MainRegistry.js b/packages/snjs/mocha/TestRegistry/MainRegistry.js new file mode 100644 index 000000000..a93e318a5 --- /dev/null +++ b/packages/snjs/mocha/TestRegistry/MainRegistry.js @@ -0,0 +1,7 @@ +import { BaseTests } from './BaseTests.js' +import { VaultTests } from './VaultTests.js' + +export default { + BaseTests, + VaultTests, +} diff --git a/packages/snjs/mocha/TestRegistry/VaultTests.js b/packages/snjs/mocha/TestRegistry/VaultTests.js new file mode 100644 index 000000000..0f5a5034a --- /dev/null +++ b/packages/snjs/mocha/TestRegistry/VaultTests.js @@ -0,0 +1,16 @@ + +export const VaultTests = [ + 'vaults/vaults.test.js', + 'vaults/pkc.test.js', + 'vaults/contacts.test.js', + 'vaults/crypto.test.js', + 'vaults/asymmetric-messages.test.js', + 'vaults/shared_vaults.test.js', + 'vaults/invites.test.js', + 'vaults/items.test.js', + 'vaults/conflicts.test.js', + 'vaults/deletion.test.js', + 'vaults/permissions.test.js', + 'vaults/key_rotation.test.js', + 'vaults/files.test.js', +]; diff --git a/packages/snjs/mocha/actions.test.js b/packages/snjs/mocha/actions.test.js index 8e6350c52..d28f026b3 100644 --- a/packages/snjs/mocha/actions.test.js +++ b/packages/snjs/mocha/actions.test.js @@ -170,10 +170,7 @@ describe('actions service', () => { }) // Extension item - const extensionItem = await this.application.itemManager.createItem( - ContentType.ActionsExtension, - this.actionsExtension, - ) + const extensionItem = await this.application.mutator.createItem(ContentType.ActionsExtension, this.actionsExtension) this.extensionItemUuid = extensionItem.uuid }) @@ -185,7 +182,7 @@ describe('actions service', () => { }) it('should get extension items', async function () { - await this.itemManager.createItem(ContentType.Note, { + await this.application.mutator.createItem(ContentType.Note, { title: 'A simple note', text: 'Standard Notes rocks! lml.', }) @@ -194,7 +191,7 @@ describe('actions service', () => { }) it('should get extensions in context of item', async function () { - const noteItem = await this.itemManager.createItem(ContentType.Note, { + const noteItem = await this.application.mutator.createItem(ContentType.Note, { title: 'Another note', text: 'Whiskey In The Jar', }) @@ -205,7 +202,7 @@ describe('actions service', () => { }) it('should get actions based on item context', async function () { - const tagItem = await this.itemManager.createItem(ContentType.Tag, { + const tagItem = await this.application.mutator.createItem(ContentType.Tag, { title: 'Music', }) @@ -217,7 +214,7 @@ describe('actions service', () => { }) it('should load extension in context of item', async function () { - const noteItem = await this.itemManager.createItem(ContentType.Note, { + const noteItem = await this.application.mutator.createItem(ContentType.Note, { title: 'Yet another note', text: 'And all things will end ♫', }) @@ -249,7 +246,7 @@ describe('actions service', () => { const sandbox = sinon.createSandbox() before(async function () { - this.noteItem = await this.itemManager.createItem(ContentType.Note, { + this.noteItem = await this.application.mutator.createItem(ContentType.Note, { title: 'Hey', text: 'Welcome To Paradise', }) @@ -331,7 +328,7 @@ describe('actions service', () => { const sandbox = sinon.createSandbox() before(async function () { - this.noteItem = await this.itemManager.createItem(ContentType.Note, { + this.noteItem = await this.application.mutator.createItem(ContentType.Note, { title: 'Excuse Me', text: 'Time To Be King 8)', }) diff --git a/packages/snjs/mocha/application.test.js b/packages/snjs/mocha/application.test.js index 16c468df8..5dc740f42 100644 --- a/packages/snjs/mocha/application.test.js +++ b/packages/snjs/mocha/application.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -75,7 +75,7 @@ describe('application instances', () => { /** Recreate app with different host */ const recreatedContext = await Factory.createAppContext({ identifier: 'app', - host: 'http://nonsense.host' + host: 'http://nonsense.host', }) await recreatedContext.launch() @@ -134,7 +134,7 @@ describe('application instances', () => { }) it('shows confirmation dialog when there are unsaved changes', async () => { - await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.mutator.setItemDirty(testNote1) await testSNApp.user.signOut() const expectedConfirmMessage = signOutConfirmMessage(1) @@ -154,7 +154,7 @@ describe('application instances', () => { }) it('does not show confirmation dialog when there are unsaved changes and the "force" option is set to true', async () => { - await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.mutator.setItemDirty(testNote1) await testSNApp.user.signOut(true) expect(confirmAlert.callCount).to.equal(0) @@ -166,7 +166,7 @@ describe('application instances', () => { confirmAlert.restore() confirmAlert = sinon.stub(testSNApp.alertService, 'confirm').callsFake((_message) => false) - await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.mutator.setItemDirty(testNote1) await testSNApp.user.signOut() const expectedConfirmMessage = signOutConfirmMessage(1) diff --git a/packages/snjs/mocha/auth-fringe-cases.test.js b/packages/snjs/mocha/auth-fringe-cases.test.js index 820e3f69e..e15f0c929 100644 --- a/packages/snjs/mocha/auth-fringe-cases.test.js +++ b/packages/snjs/mocha/auth-fringe-cases.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -85,14 +85,14 @@ describe('auth fringe cases', () => { const serverText = 'server text' - await context.application.mutator.changeAndSaveItem(firstVersionOfNote, (mutator) => { + await context.application.changeAndSaveItem(firstVersionOfNote, (mutator) => { mutator.text = serverText }) const newApplication = await Factory.signOutApplicationAndReturnNew(context.application) /** Create same note but now offline */ - await newApplication.itemManager.emitItemFromPayload(firstVersionOfNote.payload) + await newApplication.mutator.emitItemFromPayload(firstVersionOfNote.payload) /** Sign in and merge local data */ await newApplication.signIn(context.email, context.password, undefined, undefined, true, true) diff --git a/packages/snjs/mocha/auth.test.js b/packages/snjs/mocha/auth.test.js index d120e491b..24e2a2fde 100644 --- a/packages/snjs/mocha/auth.test.js +++ b/packages/snjs/mocha/auth.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -15,7 +15,7 @@ describe('basic auth', function () { beforeEach(async function () { localStorage.clear() - this.expectedItemCount = BaseItemCounts.DefaultItems + this.expectedItemCount = BaseItemCounts.DefaultItemsWithAccount this.context = await Factory.createAppContext() await this.context.launch() this.application = this.context.application @@ -262,7 +262,7 @@ describe('basic auth', function () { if (!didCompleteDownloadFirstSync) { return } - if (!didCompletePostDownloadFirstSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + if (!didCompletePostDownloadFirstSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) { didCompletePostDownloadFirstSync = true /** Should be in sync */ outOfSync = this.application.syncService.isOutOfSync() diff --git a/packages/snjs/mocha/backups.test.js b/packages/snjs/mocha/backups.test.js index a6320370a..4fbc406d7 100644 --- a/packages/snjs/mocha/backups.test.js +++ b/packages/snjs/mocha/backups.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -25,9 +25,6 @@ describe('backups', function () { this.application = null }) - const BASE_ITEM_COUNT_ENCRYPTED = BaseItemCounts.DefaultItems - const BASE_ITEM_COUNT_DECRYPTED = ['UserPreferences', 'DarkTheme'].length - it('backup file should have a version number', async function () { let data = await this.application.createDecryptedBackupFile() expect(data.version).to.equal(this.application.protocolService.getLatestVersion()) @@ -39,7 +36,9 @@ describe('backups', function () { it('no passcode + no account backup file should have correct number of items', async function () { await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)]) const data = await this.application.createDecryptedBackupFile() - expect(data.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + const offsetForNewItems = 2 + const offsetForNoItemsKey = -1 + expect(data.items.length).to.equal(BaseItemCounts.DefaultItems + offsetForNewItems + offsetForNoItemsKey) }) it('passcode + no account backup file should have correct number of items', async function () { @@ -49,12 +48,12 @@ describe('backups', function () { // Encrypted backup without authorization const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() - expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(encryptedData.items.length).to.equal(BaseItemCounts.DefaultItems + 2) // Encrypted backup with authorization Factory.handlePasswordChallenges(this.application, passcode) const authorizedEncryptedData = await this.application.createEncryptedBackupFile() - expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(authorizedEncryptedData.items.length).to.equal(BaseItemCounts.DefaultItems + 2) }) it('no passcode + account backup file should have correct number of items', async function () { @@ -68,17 +67,17 @@ describe('backups', function () { // Encrypted backup without authorization const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() - expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(encryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccount + 2) Factory.handlePasswordChallenges(this.application, this.password) // Decrypted backup const decryptedData = await this.application.createDecryptedBackupFile() - expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + expect(decryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccountWithoutItemsKey + 2) // Encrypted backup with authorization const authorizedEncryptedData = await this.application.createEncryptedBackupFile() - expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(authorizedEncryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccount + 2) }) it('passcode + account backup file should have correct number of items', async function () { @@ -91,17 +90,17 @@ describe('backups', function () { // Encrypted backup without authorization const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() - expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(encryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccount + 2) Factory.handlePasswordChallenges(this.application, passcode) // Decrypted backup const decryptedData = await this.application.createDecryptedBackupFile() - expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + expect(decryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccountWithoutItemsKey + 2) // Encrypted backup with authorization const authorizedEncryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() - expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + expect(authorizedEncryptedData.items.length).to.equal(BaseItemCounts.DefaultItemsWithAccount + 2) }) it('backup file item should have correct fields', async function () { @@ -154,7 +153,7 @@ describe('backups', function () { errorDecrypting: true, }) - await this.application.itemManager.emitItemFromPayload(errored) + await this.application.payloadManager.emitPayload(errored) const erroredItem = this.application.itemManager.findAnyItem(errored.uuid) @@ -162,7 +161,7 @@ describe('backups', function () { const backupData = await this.application.createDecryptedBackupFile() - expect(backupData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + expect(backupData.items.length).to.equal(BaseItemCounts.DefaultItemsNoAccounNoItemsKey + 2) }) it('decrypted backup file should not have keyParams', async function () { diff --git a/packages/snjs/mocha/features.test.js b/packages/snjs/mocha/features.test.js index 7d438a5b3..5442950a1 100644 --- a/packages/snjs/mocha/features.test.js +++ b/packages/snjs/mocha/features.test.js @@ -31,9 +31,9 @@ describe('features', () => { expires_at: tomorrow, } - sinon.spy(application.itemManager, 'createItem') - sinon.spy(application.itemManager, 'changeComponent') - sinon.spy(application.itemManager, 'setItemsToBeDeleted') + sinon.spy(application.mutator, 'createItem') + sinon.spy(application.mutator, 'changeComponent') + sinon.spy(application.mutator, 'setItemsToBeDeleted') getUserFeatures = sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => { return Promise.resolve({ @@ -82,7 +82,7 @@ describe('features', () => { it('should fetch user features and create items for features with content type', async () => { expect(application.apiService.getUserFeatures.callCount).to.equal(1) - expect(application.itemManager.createItem.callCount).to.equal(2) + expect(application.mutator.createItem.callCount).to.equal(2) const themeItems = application.items.getItems(ContentType.Theme) const systemThemeCount = 1 @@ -117,7 +117,7 @@ describe('features', () => { // Wipe roles from initial sync await application.featuresService.setOnlineRoles([]) // Create pre-existing item for theme without all the info - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.Theme, FillItemContent({ package_info: { @@ -129,7 +129,7 @@ describe('features', () => { await application.sync.sync() // Timeout since we don't await for features update await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(application.itemManager.changeComponent.callCount).to.equal(1) + expect(application.mutator.changeComponent.callCount).to.equal(1) const themeItems = application.items.getItems(ContentType.Theme) expect(themeItems).to.have.lengthOf(1) expect(themeItems[0].content).to.containSubset( @@ -172,7 +172,7 @@ describe('features', () => { // Timeout since we don't await for features update await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(application.itemManager.setItemsToBeDeleted.calledWith([sinon.match({ uuid: themeItem.uuid })])).to.equal( + expect(application.mutator.setItemsToBeDeleted.calledWith([sinon.match({ uuid: themeItem.uuid })])).to.equal( true, ) @@ -202,7 +202,7 @@ describe('features', () => { sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve) }) - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ url: `https://extensions.standardnotes.org/${extensionKey}`, @@ -224,7 +224,7 @@ describe('features', () => { // eslint-disable-next-line @typescript-eslint/no-empty-function .callsFake(() => {}) const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ url: `https://extensions.standardnotes.org/${extensionKey}`, @@ -255,7 +255,7 @@ describe('features', () => { return false }) const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ url: `https://extensions.standardnotes.org/${extensionKey}`, @@ -290,7 +290,7 @@ describe('features', () => { } }) }) - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ url: `https://extensions.standardnotes.org/${extensionKey}`, @@ -304,7 +304,7 @@ describe('features', () => { it('previous extension repo should be migrated to offline feature repo', async () => { application = await Factory.signOutApplicationAndReturnNew(application) const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') - await application.itemManager.createItem( + await application.mutator.createItem( ContentType.ExtensionRepo, FillItemContent({ url: `https://extensions.standardnotes.org/${extensionKey}`, diff --git a/packages/snjs/mocha/files.test.js b/packages/snjs/mocha/files.test.js index 2e2e2390e..622453dc9 100644 --- a/packages/snjs/mocha/files.test.js +++ b/packages/snjs/mocha/files.test.js @@ -1,4 +1,5 @@ import * as Factory from './lib/factory.js' +import * as Events from './lib/Events.js' import * as Utils from './lib/Utils.js' import * as Files from './lib/Files.js' @@ -38,22 +39,7 @@ describe('files', function () { }) if (subscription) { - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { - userEmail: context.email, - subscriptionId: subscriptionId++, - subscriptionName: 'PRO_PLAN', - subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000, - timestamp: Date.now(), - offline: false, - discountCode: null, - limitedDiscountPurchased: false, - newSubscriber: true, - totalActiveSubscriptionsCount: 1, - userRegisteredAt: 1, - billingFrequency: 12, - payAmount: 59.00 - }) - await Factory.sleep(2) + await context.publicMockSubscriptionPurchaseEvent() } } @@ -66,7 +52,7 @@ describe('files', function () { await setup({ fakeCrypto: true, subscription: true }) const remoteIdentifier = Utils.generateUuid() - const token = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + const token = await application.apiService.createUserFileValetToken(remoteIdentifier, 'write') expect(token.length).to.be.above(0) }) @@ -75,15 +61,15 @@ describe('files', function () { await setup({ fakeCrypto: true, subscription: false }) const remoteIdentifier = Utils.generateUuid() - const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + const tokenOrError = await application.apiService.createUserFileValetToken(remoteIdentifier, 'write') - expect(tokenOrError.tag).to.equal('no-subscription') + expect(isClientDisplayableError(tokenOrError)).to.equal(true) }) it('should not create valet token from server when user has an expired subscription - @paidfeature', async function () { await setup({ fakeCrypto: true, subscription: false }) - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { userEmail: context.email, subscriptionId: subscriptionId++, subscriptionName: 'PLUS_PLAN', @@ -96,27 +82,27 @@ describe('files', function () { totalActiveSubscriptionsCount: 1, userRegisteredAt: 1, billingFrequency: 12, - payAmount: 59.00 + payAmount: 59.0, }) await Factory.sleep(2) const remoteIdentifier = Utils.generateUuid() - const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + const tokenOrError = await application.apiService.createUserFileValetToken(remoteIdentifier, 'write') - expect(tokenOrError.tag).to.equal('expired-subscription') + expect(isClientDisplayableError(tokenOrError)).to.equal(true) }) it('creating two upload sessions successively should succeed - @paidfeature', async function () { await setup({ fakeCrypto: true, subscription: true }) - const firstToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write') - const firstSession = await application.apiService.startUploadSession(firstToken) + const firstToken = await application.apiService.createUserFileValetToken(Utils.generateUuid(), 'write') + const firstSession = await application.apiService.startUploadSession(firstToken, 'user') expect(firstSession.uploadId).to.be.ok - const secondToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write') - const secondSession = await application.apiService.startUploadSession(secondToken) + const secondToken = await application.apiService.createUserFileValetToken(Utils.generateUuid(), 'write') + const secondSession = await application.apiService.startUploadSession(secondToken, 'user') expect(secondSession.uploadId).to.be.ok }) @@ -129,7 +115,7 @@ describe('files', function () { const file = await Files.uploadFile(fileService, buffer, 'my-file', 'md', 1000) - const downloadedBytes = await Files.downloadFile(fileService, itemManager, file.remoteIdentifier) + const downloadedBytes = await Files.downloadFile(fileService, file) expect(downloadedBytes).to.eql(buffer) }) @@ -142,7 +128,7 @@ describe('files', function () { const file = await Files.uploadFile(fileService, buffer, 'my-file', 'md', 100000) - const downloadedBytes = await Files.downloadFile(fileService, itemManager, file.remoteIdentifier) + const downloadedBytes = await Files.downloadFile(fileService, file) expect(downloadedBytes).to.eql(buffer) }) diff --git a/packages/snjs/mocha/history.test.js b/packages/snjs/mocha/history.test.js index a373ea95f..8d189de2c 100644 --- a/packages/snjs/mocha/history.test.js +++ b/packages/snjs/mocha/history.test.js @@ -35,7 +35,7 @@ describe('history manager', () => { }) function setTextAndSync(application, item, text) { - return application.mutator.changeAndSaveItem( + return application.changeAndSaveItem( item, (mutator) => { mutator.text = text @@ -59,7 +59,7 @@ describe('history manager', () => { expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(0) /** Sync with different contents, should create new entry */ - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( item, (mutator) => { mutator.title = Math.random() @@ -79,7 +79,7 @@ describe('history manager', () => { const context = await Factory.createAppContext({ identifier }) await context.launch() expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0) - await context.application.mutator.changeAndSaveItem( + await context.application.changeAndSaveItem( item, (mutator) => { mutator.title = Math.random() @@ -97,13 +97,13 @@ describe('history manager', () => { it('creating new item and making 1 change should create 0 revisions', async function () { const context = await Factory.createAppContext() await context.launch() - const item = await context.application.mutator.createTemplateItem(ContentType.Note, { + const item = await context.application.items.createTemplateItem(ContentType.Note, { references: [], }) await context.application.mutator.insertItem(item) expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0) - await context.application.mutator.changeAndSaveItem( + await context.application.changeAndSaveItem( item, (mutator) => { mutator.title = Math.random() @@ -172,8 +172,8 @@ describe('history manager', () => { text: Factory.randomString(100), }), ) - let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) - await this.application.itemManager.setItemDirty(item) + let item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.mutator.setItemDirty(item) await this.application.syncService.sync(syncOptions) /** It should keep the first and last by default */ item = await setTextAndSync(this.application, item, item.content.text) @@ -202,9 +202,9 @@ describe('history manager', () => { }), ) - let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + let item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) - await this.application.itemManager.setItemDirty(item) + await this.application.mutator.setItemDirty(item) await this.application.syncService.sync(syncOptions) item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1)) @@ -241,9 +241,9 @@ describe('history manager', () => { it('unsynced entries should use payload created_at for preview titles', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) const item = this.application.items.findItem(payload.uuid) - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( item, (mutator) => { mutator.title = Math.random() @@ -306,7 +306,7 @@ describe('history manager', () => { expect(itemHistory.length).to.equal(1) /** Sync with different contents, should not create a new entry */ - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( item, (mutator) => { mutator.title = Math.random() @@ -327,7 +327,7 @@ describe('history manager', () => { await Factory.sleep(Factory.ServerRevisionFrequency) /** Sync with different contents, should create new entry */ const newTitleAfterFirstChange = `The title should be: ${Math.random()}` - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( item, (mutator) => { mutator.title = newTitleAfterFirstChange @@ -343,7 +343,10 @@ describe('history manager', () => { expect(itemHistory.length).to.equal(2) const oldestEntry = lastElement(itemHistory) - let revisionFromServerOrError = await this.application.getRevision.execute({ itemUuid: item.uuid, revisionUuid: oldestEntry.uuid }) + let revisionFromServerOrError = await this.application.getRevision.execute({ + itemUuid: item.uuid, + revisionUuid: oldestEntry.uuid, + }) const revisionFromServer = revisionFromServerOrError.getValue() expect(revisionFromServer).to.be.ok @@ -359,7 +362,7 @@ describe('history manager', () => { it('duplicate revisions should not have the originals uuid', async function () { const note = await Factory.createSyncedNote(this.application) await Factory.markDirtyAndSyncItem(this.application, note) - const dupe = await this.application.itemManager.duplicateItem(note, true) + const dupe = await this.application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(this.application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) @@ -367,7 +370,10 @@ describe('history manager', () => { const dupeHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid }) const dupeHistory = dupeHistoryOrError.getValue() - const dupeRevisionOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: dupeHistory[0].uuid }) + const dupeRevisionOrError = await this.application.getRevision.execute({ + itemUuid: dupe.uuid, + revisionUuid: dupeHistory[0].uuid, + }) const dupeRevision = dupeRevisionOrError.getValue() expect(dupeRevision.payload.uuid).to.equal(dupe.uuid) }) @@ -384,7 +390,7 @@ describe('history manager', () => { await Factory.sleep(Factory.ServerRevisionFrequency) await Factory.markDirtyAndSyncItem(this.application, note) - const dupe = await this.application.itemManager.duplicateItem(note, true) + const dupe = await this.application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(this.application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) @@ -405,12 +411,12 @@ describe('history manager', () => { await Factory.sleep(Factory.ServerRevisionFrequency) const changedText = `${Math.random()}` - await this.application.mutator.changeAndSaveItem(note, (mutator) => { + await this.application.changeAndSaveItem(note, (mutator) => { mutator.title = changedText }) await Factory.markDirtyAndSyncItem(this.application, note) - const dupe = await this.application.itemManager.duplicateItem(note, true) + const dupe = await this.application.mutator.duplicateItem(note, true) await Factory.markDirtyAndSyncItem(this.application, dupe) await Factory.sleep(Factory.ServerRevisionCreationDelay) @@ -420,7 +426,10 @@ describe('history manager', () => { expect(itemHistory.length).to.be.above(1) const newestRevision = itemHistory[0] - const fetchedOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: newestRevision.uuid }) + const fetchedOrError = await this.application.getRevision.execute({ + itemUuid: dupe.uuid, + revisionUuid: newestRevision.uuid, + }) const fetched = fetchedOrError.getValue() expect(fetched.payload.errorDecrypting).to.not.be.ok expect(fetched.payload.content.title).to.equal(changedText) diff --git a/packages/snjs/mocha/item_manager.test.js b/packages/snjs/mocha/item_manager.test.js index 6f23c2459..c4c6ed8dd 100644 --- a/packages/snjs/mocha/item_manager.test.js +++ b/packages/snjs/mocha/item_manager.test.js @@ -1,167 +1,120 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ import * as Factory from './lib/factory.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' + chai.use(chaiAsPromised) const expect = chai.expect describe('item manager', function () { + let context + let application + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + beforeEach(async function () { - this.payloadManager = new PayloadManager() - this.itemManager = new ItemManager(this.payloadManager) - this.createNote = async () => { - return this.itemManager.createItem(ContentType.Note, { - title: 'hello', - text: 'world', - }) - } + localStorage.clear() - this.createTag = async (notes = []) => { - const references = notes.map((note) => { - return { - uuid: note.uuid, - content_type: note.content_type, - } - }) - return this.itemManager.createItem(ContentType.Tag, { - title: 'thoughts', - references: references, - }) - } + context = await Factory.createAppContextWithFakeCrypto() + application = context.application + + await context.launch() }) - it('create item', async function () { - const item = await this.createNote() + const createNote = async () => { + return application.mutator.createItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + } - expect(item).to.be.ok - expect(item.title).to.equal('hello') - }) - - it('emitting item through payload and marking dirty should have userModifiedDate', async function () { - const payload = Factory.createNotePayload() - const item = await this.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) - const result = await this.itemManager.setItemDirty(item) - const appData = result.payload.content.appData - expect(appData[DecryptedItem.DefaultAppDomain()][AppDataField.UserModifiedDate]).to.be.ok - }) + const createTag = async (notes = []) => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return application.mutator.createItem(ContentType.Tag, { + title: 'thoughts', + references: references, + }) + } it('find items with valid uuid', async function () { - const item = await this.createNote() + const item = await createNote() - const results = await this.itemManager.findItems([item.uuid]) + const results = await application.items.findItems([item.uuid]) expect(results.length).to.equal(1) expect(results[0]).to.equal(item) }) it('find items with invalid uuid no blanks', async function () { - const results = await this.itemManager.findItems([Factory.generateUuidish()]) + const results = await application.items.findItems([Factory.generateUuidish()]) expect(results.length).to.equal(0) }) it('find items with invalid uuid include blanks', async function () { - const includeBlanks = true - const results = await this.itemManager.findItemsIncludingBlanks([Factory.generateUuidish()]) + const results = await application.items.findItemsIncludingBlanks([Factory.generateUuidish()]) expect(results.length).to.equal(1) expect(results[0]).to.not.be.ok }) it('item state', async function () { - await this.createNote() + await createNote() - expect(this.itemManager.items.length).to.equal(1) - expect(this.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.items.items.length).to.equal(1 + BaseItemCounts.DefaultItems) + expect(application.items.getDisplayableNotes().length).to.equal(1) }) it('find item', async function () { - const item = await this.createNote() + const item = await createNote() - const foundItem = this.itemManager.findItem(item.uuid) + const foundItem = application.items.findItem(item.uuid) expect(foundItem).to.be.ok }) it('reference map', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) + const note = await createNote() + const tag = await createTag([note]) - expect(this.itemManager.collection.referenceMap.directMap.get(tag.uuid)).to.eql([note.uuid]) + expect(application.items.collection.referenceMap.directMap.get(tag.uuid)).to.eql([note.uuid]) }) it('inverse reference map', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) + const note = await createNote() + const tag = await createTag([note]) - expect(this.itemManager.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([tag.uuid]) + expect(application.items.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([tag.uuid]) }) it('inverse reference map should not have duplicates', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) - await this.itemManager.changeItem(tag) + const note = await createNote() + const tag = await createTag([note]) + await application.mutator.changeItem(tag) - expect(this.itemManager.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([tag.uuid]) - }) - - it('deleting from reference map', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) - await this.itemManager.setItemToBeDeleted(note) - - expect(this.itemManager.collection.referenceMap.directMap.get(tag.uuid)).to.eql([]) - expect(this.itemManager.collection.referenceMap.inverseMap.get(note.uuid).length).to.equal(0) - }) - - it('deleting referenced item should update referencing item references', async function () { - const note = await this.createNote() - let tag = await this.createTag([note]) - await this.itemManager.setItemToBeDeleted(note) - - tag = this.itemManager.findItem(tag.uuid) - expect(tag.content.references.length).to.equal(0) - }) - - it('removing relationship should update reference map', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) - await this.itemManager.changeItem(tag, (mutator) => { - mutator.removeItemAsRelationship(note) - }) - - expect(this.itemManager.collection.referenceMap.directMap.get(tag.uuid)).to.eql([]) - expect(this.itemManager.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([]) - }) - - it('emitting discardable payload should remove it from our collection', async function () { - const note = await this.createNote() - - const payload = new DeletedPayload({ - ...note.payload.ejected(), - content: undefined, - deleted: true, - dirty: false, - }) - - expect(payload.discardable).to.equal(true) - - await this.itemManager.emitItemFromPayload(payload) - - expect(this.itemManager.findItem(note.uuid)).to.not.be.ok + expect(application.items.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([tag.uuid]) }) it('items that reference item', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) + const note = await createNote() + const tag = await createTag([note]) - const itemsThatReference = this.itemManager.itemsReferencingItem(note) + const itemsThatReference = application.items.itemsReferencingItem(note) expect(itemsThatReference.length).to.equal(1) expect(itemsThatReference[0]).to.equal(tag) }) it('observer', async function () { const observed = [] - this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, source, sourceKey }) => { + application.items.addObserver(ContentType.Any, ({ changed, inserted, removed, source, sourceKey }) => { observed.push({ changed, inserted, removed, source, sourceKey }) }) - const note = await this.createNote() - const tag = await this.createTag([note]) + const note = await createNote() + const tag = await createTag([note]) expect(observed.length).to.equal(2) const firstObserved = observed[0] @@ -171,59 +124,23 @@ describe('item manager', function () { expect(secondObserved.inserted).to.eql([tag]) }) - it('change existing item', async function () { - const note = await this.createNote() - const newTitle = String(Math.random()) - await this.itemManager.changeItem(note, (mutator) => { - mutator.title = newTitle - }) - - const latestVersion = this.itemManager.findItem(note.uuid) - expect(latestVersion.title).to.equal(newTitle) - }) - - it('change non-existant item through uuid should fail', async function () { - const note = await this.itemManager.createTemplateItem(ContentType.Note, { - title: 'hello', - text: 'world', - }) - - const changeFn = async () => { - const newTitle = String(Math.random()) - return this.itemManager.changeItem(note, (mutator) => { - mutator.title = newTitle - }) - } - await Factory.expectThrowsAsync(() => changeFn(), 'Attempting to change non-existant item') - }) - - it('set items dirty', async function () { - const note = await this.createNote() - await this.itemManager.setItemDirty(note) - - const dirtyItems = this.itemManager.getDirtyItems() - expect(dirtyItems.length).to.equal(1) - expect(dirtyItems[0].uuid).to.equal(note.uuid) - expect(dirtyItems[0].dirty).to.equal(true) - }) - it('dirty items should not include errored items', async function () { - const note = await this.itemManager.setItemDirty(await this.createNote()) + const note = await application.mutator.setItemDirty(await createNote()) const errorred = new EncryptedPayload({ ...note.payload, content: '004:...', errorDecrypting: true, }) - await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) - const dirtyItems = this.itemManager.getDirtyItems() + const dirtyItems = application.items.getDirtyItems() expect(dirtyItems.length).to.equal(0) }) it('dirty items should include errored items if they are being deleted', async function () { - const note = await this.itemManager.setItemDirty(await this.createNote()) + const note = await application.mutator.setItemDirty(await createNote()) const errorred = new DeletedPayload({ ...note.payload, content: undefined, @@ -231,181 +148,63 @@ describe('item manager', function () { deleted: true, }) - await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) - const dirtyItems = this.itemManager.getDirtyItems() + const dirtyItems = application.items.getDirtyItems() expect(dirtyItems.length).to.equal(1) }) - describe('duplicateItem', async function () { - const sandbox = sinon.createSandbox() - - beforeEach(async function () { - this.emitPayloads = sandbox.spy(this.itemManager.payloadManager, 'emitPayloads') - }) - - afterEach(async function () { - sandbox.restore() - }) - - it('should duplicate the item and set the duplicate_of property', async function () { - const note = await this.createNote() - await this.itemManager.duplicateItem(note) - sinon.assert.calledTwice(this.emitPayloads) - - const originalNote = this.itemManager.getDisplayableNotes()[0] - const duplicatedNote = this.itemManager.getDisplayableNotes()[1] - - expect(this.itemManager.items.length).to.equal(2) - expect(this.itemManager.getDisplayableNotes().length).to.equal(2) - expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) - expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) - expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) - expect(duplicatedNote.conflictOf).to.be.undefined - expect(duplicatedNote.payload.content.conflict_of).to.be.undefined - }) - - it('should duplicate the item and set the duplicate_of and conflict_of properties', async function () { - const note = await this.createNote() - await this.itemManager.duplicateItem(note, true) - sinon.assert.calledTwice(this.emitPayloads) - - const originalNote = this.itemManager.getDisplayableNotes()[0] - const duplicatedNote = this.itemManager.getDisplayableNotes()[1] - - expect(this.itemManager.items.length).to.equal(2) - expect(this.itemManager.getDisplayableNotes().length).to.equal(2) - expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) - expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) - expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) - expect(originalNote.uuid).to.equal(duplicatedNote.conflictOf) - expect(originalNote.uuid).to.equal(duplicatedNote.payload.content.conflict_of) - }) - - it('duplicate item with relationships', async function () { - const note = await this.createNote() - const tag = await this.createTag([note]) - const duplicate = await this.itemManager.duplicateItem(tag) - - expect(duplicate.content.references).to.have.length(1) - expect(this.itemManager.items).to.have.length(3) - expect(this.itemManager.getDisplayableTags()).to.have.length(2) - }) - - it('adds duplicated item as a relationship to items referencing it', async function () { - const note = await this.createNote() - let tag = await this.createTag([note]) - const duplicateNote = await this.itemManager.duplicateItem(note) - expect(tag.content.references).to.have.length(1) - - tag = this.itemManager.findItem(tag.uuid) - const references = tag.content.references.map((ref) => ref.uuid) - expect(references).to.have.length(2) - expect(references).to.include(note.uuid, duplicateNote.uuid) - }) - - it('duplicates item with additional content', async function () { - const note = await this.itemManager.createItem(ContentType.Note, { - title: 'hello', - text: 'world', - }) - const duplicateNote = await this.itemManager.duplicateItem(note, false, { - title: 'hello (copy)', - }) - - expect(duplicateNote.title).to.equal('hello (copy)') - expect(duplicateNote.text).to.equal('world') - }) - }) - - it('set item deleted', async function () { - const note = await this.createNote() - await this.itemManager.setItemToBeDeleted(note) - - /** Items should never be mutated directly */ - expect(note.deleted).to.not.be.ok - - const latestVersion = this.payloadManager.findOne(note.uuid) - expect(latestVersion.deleted).to.equal(true) - expect(latestVersion.dirty).to.equal(true) - expect(latestVersion.content).to.not.be.ok - - /** Deleted items do not show up in item manager's public interface */ - expect(this.itemManager.items.length).to.equal(0) - expect(this.itemManager.getDisplayableNotes().length).to.equal(0) - }) - it('system smart views', async function () { - expect(this.itemManager.systemSmartViews.length).to.be.above(0) + expect(application.items.systemSmartViews.length).to.be.above(0) }) it('find tag by title', async function () { - const tag = await this.createTag() + const tag = await createTag() - expect(this.itemManager.findTagByTitle(tag.title)).to.be.ok + expect(application.items.findTagByTitle(tag.title)).to.be.ok }) it('find tag by title should be case insensitive', async function () { - const tag = await this.createTag() + const tag = await createTag() - expect(this.itemManager.findTagByTitle(tag.title.toUpperCase())).to.be.ok + expect(application.items.findTagByTitle(tag.title.toUpperCase())).to.be.ok }) it('find or create tag by title', async function () { const title = 'foo' - expect(await this.itemManager.findOrCreateTagByTitle(title)).to.be.ok + expect(await application.mutator.findOrCreateTagByTitle({ title: title })).to.be.ok }) it('note count', async function () { - await this.createNote() - expect(this.itemManager.noteCount).to.equal(1) - }) - - it('trash', async function () { - const note = await this.createNote() - const versionTwo = await this.itemManager.changeItem(note, (mutator) => { - mutator.trashed = true - }) - - expect(this.itemManager.trashSmartView).to.be.ok - expect(versionTwo.trashed).to.equal(true) - expect(versionTwo.dirty).to.equal(true) - expect(versionTwo.content).to.be.ok - - expect(this.itemManager.items.length).to.equal(1) - expect(this.itemManager.trashedItems.length).to.equal(1) - - await this.itemManager.emptyTrash() - const versionThree = this.payloadManager.findOne(note.uuid) - expect(versionThree.deleted).to.equal(true) - expect(this.itemManager.trashedItems.length).to.equal(0) + await createNote() + expect(application.items.noteCount).to.equal(1) }) it('remove all items from memory', async function () { const observed = [] - this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { + application.items.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { observed.push({ changed, inserted, removed, ignored }) }) - await this.createNote() - await this.itemManager.removeAllItemsFromMemory() + await createNote() + await application.items.removeAllItemsFromMemory() const deletionEvent = observed[1] expect(deletionEvent.removed[0].deleted).to.equal(true) - expect(this.itemManager.items.length).to.equal(0) + expect(application.items.items.length).to.equal(0) }) it('remove item locally', async function () { const observed = [] - this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { + application.items.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { observed.push({ changed, inserted, removed, ignored }) }) - const note = await this.createNote() - await this.itemManager.removeItemLocally(note) + const note = await createNote() + await application.items.removeItemLocally(note) expect(observed.length).to.equal(1) - expect(this.itemManager.findItem(note.uuid)).to.not.be.ok + expect(application.items.findItem(note.uuid)).to.not.be.ok }) it('emitting a payload from within observer should queue to end', async function () { @@ -421,7 +220,7 @@ describe('item manager', function () { const changedTitle = 'changed title' let didEmit = false let latestVersion - this.itemManager.addObserver(ContentType.Note, ({ changed, inserted }) => { + application.items.addObserver(ContentType.Note, ({ changed, inserted }) => { const all = changed.concat(inserted) if (!didEmit) { didEmit = true @@ -431,60 +230,60 @@ describe('item manager', function () { title: changedTitle, }, }) - this.itemManager.emitItemFromPayload(changedPayload) + application.mutator.emitItemFromPayload(changedPayload) } latestVersion = all[0] }) - await this.itemManager.emitItemFromPayload(payload) + await application.mutator.emitItemFromPayload(payload) expect(latestVersion.title).to.equal(changedTitle) }) describe('searchTags', async function () { it('should return tag with query matching title', async function () { - const tag = await this.itemManager.findOrCreateTagByTitle('tag') + const tag = await application.mutator.findOrCreateTagByTitle({ title: 'tag' }) - const results = this.itemManager.searchTags('tag') + const results = application.items.searchTags('tag') expect(results).lengthOf(1) expect(results[0].title).to.equal(tag.title) }) it('should return all tags with query partially matching title', async function () { - const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one') - const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two') + const firstTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag one' }) + const secondTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag two' }) - const results = this.itemManager.searchTags('tag') + const results = application.items.searchTags('tag') expect(results).lengthOf(2) expect(results[0].title).to.equal(firstTag.title) expect(results[1].title).to.equal(secondTag.title) }) it('should be case insensitive', async function () { - const tag = await this.itemManager.findOrCreateTagByTitle('Tag') + const tag = await application.mutator.findOrCreateTagByTitle({ title: 'Tag' }) - const results = this.itemManager.searchTags('tag') + const results = application.items.searchTags('tag') expect(results).lengthOf(1) expect(results[0].title).to.equal(tag.title) }) it('should return tag with query matching delimiter separated component', async function () { - const tag = await this.itemManager.findOrCreateTagByTitle('parent.child') + const tag = await application.mutator.findOrCreateTagByTitle({ title: 'parent.child' }) - const results = this.itemManager.searchTags('child') + const results = application.items.searchTags('child') expect(results).lengthOf(1) expect(results[0].title).to.equal(tag.title) }) it('should return tags with matching query including delimiter', async function () { - const tag = await this.itemManager.findOrCreateTagByTitle('parent.child') + const tag = await application.mutator.findOrCreateTagByTitle({ title: 'parent.child' }) - const results = this.itemManager.searchTags('parent.chi') + const results = application.items.searchTags('parent.chi') expect(results).lengthOf(1) expect(results[0].title).to.equal(tag.title) }) it('should return tags in natural order', async function () { - const firstTag = await this.itemManager.findOrCreateTagByTitle('tag 100') - const secondTag = await this.itemManager.findOrCreateTagByTitle('tag 2') - const thirdTag = await this.itemManager.findOrCreateTagByTitle('tag b') - const fourthTag = await this.itemManager.findOrCreateTagByTitle('tag a') + const firstTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag 100' }) + const secondTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag 2' }) + const thirdTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag b' }) + const fourthTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag a' }) - const results = this.itemManager.searchTags('tag') + const results = application.items.searchTags('tag') expect(results).lengthOf(4) expect(results[0].title).to.equal(secondTag.title) expect(results[1].title).to.equal(firstTag.title) @@ -493,15 +292,15 @@ describe('item manager', function () { }) it('should not return tags associated with note', async function () { - const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one') - const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two') + const firstTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag one' }) + const secondTag = await application.mutator.findOrCreateTagByTitle({ title: 'tag two' }) - const note = await this.createNote() - await this.itemManager.changeItem(firstTag, (mutator) => { + const note = await createNote() + await application.mutator.changeItem(firstTag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note) }) - const results = this.itemManager.searchTags('tag', note) + const results = application.items.searchTags('tag', note) expect(results).lengthOf(1) expect(results[0].title).to.equal(secondTag.title) }) @@ -509,68 +308,68 @@ describe('item manager', function () { describe('smart views', async function () { it('all view should not include archived notes by default', async function () { - const normal = await this.createNote() + const normal = await createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await application.mutator.changeItem(normal, (mutator) => { mutator.archived = true }) - this.itemManager.setPrimaryItemDisplayOptions({ - views: [this.itemManager.allNotesSmartView], + application.items.setPrimaryItemDisplayOptions({ + views: [application.items.allNotesSmartView], }) - expect(this.itemManager.getDisplayableNotes().length).to.equal(0) + expect(application.items.getDisplayableNotes().length).to.equal(0) }) it('archived view should not include trashed notes by default', async function () { - const normal = await this.createNote() + const normal = await createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await application.mutator.changeItem(normal, (mutator) => { mutator.archived = true mutator.trashed = true }) - this.itemManager.setPrimaryItemDisplayOptions({ - views: [this.itemManager.archivedSmartView], + application.items.setPrimaryItemDisplayOptions({ + views: [application.items.archivedSmartView], }) - expect(this.itemManager.getDisplayableNotes().length).to.equal(0) + expect(application.items.getDisplayableNotes().length).to.equal(0) }) it('trashed view should include archived notes by default', async function () { - const normal = await this.createNote() + const normal = await createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await application.mutator.changeItem(normal, (mutator) => { mutator.archived = true mutator.trashed = true }) - this.itemManager.setPrimaryItemDisplayOptions({ - views: [this.itemManager.trashSmartView], + application.items.setPrimaryItemDisplayOptions({ + views: [application.items.trashSmartView], }) - expect(this.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.items.getDisplayableNotes().length).to.equal(1) }) }) describe('getSortedTagsForNote', async function () { it('should return tags associated with a note in natural order', async function () { const tags = [ - await this.itemManager.findOrCreateTagByTitle('tag 100'), - await this.itemManager.findOrCreateTagByTitle('tag 2'), - await this.itemManager.findOrCreateTagByTitle('tag b'), - await this.itemManager.findOrCreateTagByTitle('tag a'), + await application.mutator.findOrCreateTagByTitle({ title: 'tag 100' }), + await application.mutator.findOrCreateTagByTitle({ title: 'tag 2' }), + await application.mutator.findOrCreateTagByTitle({ title: 'tag b' }), + await application.mutator.findOrCreateTagByTitle({ title: 'tag a' }), ] - const note = await this.createNote() + const note = await createNote() tags.map(async (tag) => { - await this.itemManager.changeItem(tag, (mutator) => { + await application.mutator.changeItem(tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note) }) }) - const results = this.itemManager.getSortedTagsForItem(note) + const results = application.items.getSortedTagsForItem(note) expect(results).lengthOf(tags.length) expect(results[0].title).to.equal(tags[1].title) @@ -583,16 +382,16 @@ describe('item manager', function () { describe('getTagParentChain', function () { it('should return parent tags for a tag', async function () { const [parent, child, grandchild, _other] = await Promise.all([ - this.itemManager.findOrCreateTagByTitle('parent'), - this.itemManager.findOrCreateTagByTitle('parent.child'), - this.itemManager.findOrCreateTagByTitle('parent.child.grandchild'), - this.itemManager.findOrCreateTagByTitle('some other tag'), + application.mutator.findOrCreateTagByTitle({ title: 'parent' }), + application.mutator.findOrCreateTagByTitle({ title: 'parent.child' }), + application.mutator.findOrCreateTagByTitle({ title: 'parent.child.grandchild' }), + application.mutator.findOrCreateTagByTitle({ title: 'some other tag' }), ]) - await this.itemManager.setTagParent(parent, child) - await this.itemManager.setTagParent(child, grandchild) + await application.mutator.setTagParent(parent, child) + await application.mutator.setTagParent(child, grandchild) - const results = this.itemManager.getTagParentChain(grandchild) + const results = application.items.getTagParentChain(grandchild) expect(results).lengthOf(2) expect(results[0].uuid).to.equal(parent.uuid) diff --git a/packages/snjs/mocha/key_recovery_service.test.js b/packages/snjs/mocha/key_recovery_service.test.js index 74a426d25..d3db1febe 100644 --- a/packages/snjs/mocha/key_recovery_service.test.js +++ b/packages/snjs/mocha/key_recovery_service.test.js @@ -200,7 +200,9 @@ describe('key recovery service', function () { const receiveChallenge = (challenge) => { totalPromptCount++ /** Give unassociated password when prompted */ - application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)]) + application.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], unassociatedPassword), + ]) } await application.prepareForLaunch({ receiveChallenge }) await application.launch(true) @@ -272,7 +274,9 @@ describe('key recovery service', function () { expect(result.error).to.not.be.ok expect(contextB.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2) - const newItemsKey = contextB.application.items.getDisplayableItemsKeys().find((k) => k.uuid !== originalItemsKey.uuid) + const newItemsKey = contextB.application.items + .getDisplayableItemsKeys() + .find((k) => k.uuid !== originalItemsKey.uuid) const note = await Factory.createSyncedNote(contextB.application) @@ -432,6 +436,7 @@ describe('key recovery service', function () { expect(decryptedKey.content.itemsKey).to.equal(correctItemsKey.content.itemsKey) expect(application.syncService.isOutOfSync()).to.equal(false) + await context.deinit() }) @@ -457,6 +462,8 @@ describe('key recovery service', function () { updated_at: newUpdated, }) + context.disableKeyRecovery() + await context.receiveServerResponse({ retrievedItems: [errored.ejected()] }) /** Our current items key should not be overwritten */ @@ -567,7 +574,9 @@ describe('key recovery service', function () { const application = context.application const receiveChallenge = (challenge) => { /** Give unassociated password when prompted */ - application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)]) + application.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], unassociatedPassword), + ]) } await application.prepareForLaunch({ receiveChallenge }) await application.launch(true) @@ -667,13 +676,15 @@ describe('key recovery service', function () { const stored = (await appA.deviceInterface.getAllDatabaseEntries(appA.identifier)).find( (payload) => payload.uuid === newDefaultKey.uuid, ) - const storedParams = await appA.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(stored)) + const storedParams = await appA.protocolService.getKeyEmbeddedKeyParamsFromItemsKey(new EncryptedPayload(stored)) const correctStored = (await appB.deviceInterface.getAllDatabaseEntries(appB.identifier)).find( (payload) => payload.uuid === newDefaultKey.uuid, ) - const correctParams = await appB.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(correctStored)) + const correctParams = await appB.protocolService.getKeyEmbeddedKeyParamsFromItemsKey( + new EncryptedPayload(correctStored), + ) expect(storedParams).to.eql(correctParams) diff --git a/packages/snjs/mocha/keys.test.js b/packages/snjs/mocha/keys.test.js index bbdd4d46b..f9ed45132 100644 --- a/packages/snjs/mocha/keys.test.js +++ b/packages/snjs/mocha/keys.test.js @@ -141,7 +141,8 @@ describe('keys', function () { }) it('should use items key for encryption of note', async function () { - const keyToUse = await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption() + const notePayload = Factory.createNotePayload() + const keyToUse = await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption(notePayload) expect(keyToUse.content_type).to.equal(ContentType.ItemsKey) }) @@ -153,7 +154,7 @@ describe('keys', function () { }, }) - const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + const itemsKey = this.application.protocolService.itemsKeyForEncryptedPayload(encryptedPayload) expect(itemsKey).to.be.ok }) @@ -166,7 +167,7 @@ describe('keys', function () { }, }) - const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + const itemsKey = this.application.protocolService.itemsKeyForEncryptedPayload(encryptedPayload) expect(itemsKey).to.be.ok const decryptedPayload = await this.application.protocolService.decryptSplitSingle({ @@ -187,7 +188,7 @@ describe('keys', function () { }, }) - const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + const itemsKey = this.application.protocolService.itemsKeyForEncryptedPayload(encryptedPayload) await this.application.itemManager.removeItemLocally(itemsKey) @@ -197,14 +198,14 @@ describe('keys', function () { }, }) - await this.application.itemManager.emitItemsFromPayloads([erroredPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([erroredPayload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.findAnyItem(notePayload.uuid) expect(note.errorDecrypting).to.equal(true) expect(note.waitingForKey).to.equal(true) const keyPayload = new DecryptedPayload(itemsKey.payload.ejected()) - await this.application.itemManager.emitItemsFromPayloads([keyPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([keyPayload], PayloadEmitSource.LocalChanged) /** * Sleeping is required to trigger asyncronous protocolService.decryptItemsWaitingForKeys, @@ -238,7 +239,7 @@ describe('keys', function () { }, }) - await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [], options: {} }, response) const refreshedKey = this.application.payloadManager.findOne(itemsKey.uuid) @@ -273,10 +274,8 @@ describe('keys', function () { const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === itemsKey.uuid) const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload) - const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) - const comps = operator.deconstructEncryptedPayloadString(itemsKeyPayload.content) - const rawAuthenticatedData = comps.authenticatedData - const authenticatedData = await operator.stringToAuthenticatedData(rawAuthenticatedData) + + const authenticatedData = this.context.encryption.getEmbeddedPayloadAuthenticatedData(itemsKeyPayload) const rootKeyParams = await this.application.protocolService.getRootKeyParams() expect(authenticatedData.kp).to.be.ok @@ -649,7 +648,7 @@ describe('keys', function () { await contextB.deinit() }) - describe('changing password on 003 client while signed into 004 client should', function () { + describe('changing password on 003 client while signed into 004 client', function () { /** * When an 004 client signs into 003 account, it creates a root key based items key. * Then, if the 003 client changes its account password, and the 004 client @@ -658,7 +657,7 @@ describe('keys', function () { * items sync to the 004 client, it can't decrypt them with its existing items key * because its based on the old root key. */ - it.skip('add new items key', async function () { + it.skip('should add new items key', async function () { this.timeout(Factory.TwentySecondTimeout * 3) let oldClient = this.application @@ -718,7 +717,13 @@ describe('keys', function () { await Factory.safeDeinit(oldClient) }) - it('add new items key from migration if pw change already happened', async function () { + it('should add new items key from migration if pw change already happened', async function () { + this.context.anticipateConsoleError('Shared vault network errors due to not accepting JWT-based token') + this.context.anticipateConsoleError( + 'Cannot find items key to use for encryption', + 'No items keys being created in this test', + ) + /** Register an 003 account */ await Factory.registerOldUser({ application: this.application, @@ -734,7 +739,15 @@ describe('keys', function () { await this.application.protocolService.getRootKeyParams(), ) const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V003) - const newRootKey = await operator.createRootKey(this.email, this.password) + const newRootKeyTemplate = await operator.createRootKey(this.email, this.password) + const newRootKey = CreateNewRootKey({ + ...newRootKeyTemplate.content, + ...{ + encryptionKeyPair: {}, + signingKeyPair: {}, + }, + }) + Object.defineProperty(this.application.apiService, 'apiVersion', { get: function () { return '20190520' @@ -748,7 +761,7 @@ describe('keys', function () { currentServerPassword: currentRootKey.serverPassword, newRootKey, }) - await this.application.protocolService.reencryptItemsKeys() + await this.application.protocolService.reencryptApplicableItemsAfterUserRootKeyChange() /** Note: this may result in a deadlock if features_service syncs and results in an error */ await this.application.sync.sync({ awaitAll: true }) @@ -776,11 +789,16 @@ describe('keys', function () { * The corrective action was to do a final check in protocolService.handleDownloadFirstSyncCompletion * to ensure there exists an items key corresponding to the user's account version. */ + const promise = this.context.awaitNextSucessfulSync() + await this.context.sync() + await promise + await this.application.itemManager.removeAllItemsFromMemory() expect(this.application.protocolService.getSureDefaultItemsKey()).to.not.be.ok + const protocol003 = new SNProtocolOperator003(new SNWebCrypto()) const key = await protocol003.createItemsKey() - await this.application.itemManager.emitItemFromPayload( + await this.application.mutator.emitItemFromPayload( key.payload.copy({ content: { ...key.payload.content, @@ -791,17 +809,21 @@ describe('keys', function () { updated_at: Date.now(), }), ) + const defaultKey = this.application.protocolService.getSureDefaultItemsKey() expect(defaultKey.keyVersion).to.equal(ProtocolVersion.V003) expect(defaultKey.uuid).to.equal(key.uuid) + await Factory.registerUserToApplication({ application: this.application }) - expect(await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption()).to.be.ok + + const notePayload = Factory.createNotePayload() + expect(await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption(notePayload)).to.be.ok }) it('having unsynced items keys should resync them upon download first sync completion', async function () { await Factory.registerUserToApplication({ application: this.application }) const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] - await this.application.itemManager.emitItemFromPayload( + await this.application.mutator.emitItemFromPayload( itemsKey.payload.copy({ dirty: false, updated_at: new Date(0), diff --git a/packages/snjs/mocha/lib/AppContext.js b/packages/snjs/mocha/lib/AppContext.js index d1107d3cd..e428f0b1e 100644 --- a/packages/snjs/mocha/lib/AppContext.js +++ b/packages/snjs/mocha/lib/AppContext.js @@ -2,6 +2,7 @@ import FakeWebCrypto from './fake_web_crypto.js' import * as Applications from './Applications.js' import * as Utils from './Utils.js' import * as Defaults from './Defaults.js' +import * as Events from './Events.js' import { createNotePayload } from './Items.js' UuidGenerator.SetGenerator(new FakeWebCrypto().generateUUID) @@ -11,6 +12,8 @@ const MaximumSyncOptions = { awaitAll: true, } +let GlobalSubscriptionIdCounter = 1001 + export class AppContext { constructor({ identifier, crypto, email, password, passcode, host } = {}) { this.identifier = identifier || `${Math.random()}` @@ -46,6 +49,62 @@ export class AppContext { ) } + get vaults() { + return this.application.vaultService + } + + get sessions() { + return this.application.sessions + } + + get items() { + return this.application.items + } + + get mutator() { + return this.application.mutator + } + + get payloads() { + return this.application.payloadManager + } + + get encryption() { + return this.application.protocolService + } + + get contacts() { + return this.application.contactService + } + + get sharedVaults() { + return this.application.sharedVaultService + } + + get files() { + return this.application.fileService + } + + get keys() { + return this.application.keySystemKeyManager + } + + get asymmetric() { + return this.application.asymmetricMessageService + } + + get publicKey() { + return this.sessions.getPublicKey() + } + + get signingPublicKey() { + return this.sessions.getSigningPublicKey() + } + + get privateKey() { + return this.encryption.getKeyPair().privateKey + } + ignoreChallenges() { this.ignoringChallenges = true } @@ -118,7 +177,10 @@ export class AppContext { }, }) - return this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + return this.application.syncService.handleSuccessServerResponse( + { payloadsSavedOrSaving: [], options: {} }, + response, + ) } resolveWhenKeyRecovered(uuid) { @@ -131,6 +193,16 @@ export class AppContext { }) } + resolveWhenSharedVaultUserKeysResolved() { + return new Promise((resolve) => { + this.application.vaultService.collaboration.addEventObserver((eventName) => { + if (eventName === SharedVaultServiceEvent.SharedVaultStatusChanged) { + resolve() + } + }) + }) + } + async awaitSignInEvent() { return new Promise((resolve) => { this.application.userService.addEventObserver((eventName) => { @@ -182,6 +254,155 @@ export class AppContext { }) } + awaitNextSyncSharedVaultFromScratchEvent() { + return new Promise((resolve) => { + const removeObserver = this.application.syncService.addEventObserver((event, data) => { + if (event === SyncEvent.PaginatedSyncRequestCompleted && data?.options?.sharedVaultUuids) { + removeObserver() + resolve(data) + } + }) + }) + } + + resolveWithUploadedPayloads() { + return new Promise((resolve) => { + this.application.syncService.addEventObserver((event, data) => { + if (event === SyncEvent.PaginatedSyncRequestCompleted) { + resolve(data.uploadedPayloads) + } + }) + }) + } + + resolveWithConflicts() { + return new Promise((resolve) => { + this.application.syncService.addEventObserver((event, response) => { + if (event === SyncEvent.PaginatedSyncRequestCompleted) { + resolve(response.rawConflictObjects) + } + }) + }) + } + + resolveWhenSavedSyncPayloadsIncludesItemUuid(uuid) { + return new Promise((resolve) => { + this.application.syncService.addEventObserver((event, response) => { + if (event === SyncEvent.PaginatedSyncRequestCompleted) { + const savedPayload = response.savedPayloads.find((payload) => payload.uuid === uuid) + if (savedPayload) { + resolve() + } + } + }) + }) + } + + resolveWhenSavedSyncPayloadsIncludesItemThatIsDuplicatedOf(uuid) { + return new Promise((resolve) => { + this.application.syncService.addEventObserver((event, response) => { + if (event === SyncEvent.PaginatedSyncRequestCompleted) { + const savedPayload = response.savedPayloads.find((payload) => payload.duplicate_of === uuid) + if (savedPayload) { + resolve() + } + } + }) + }) + } + + resolveWhenItemCompletesAddingToVault(targetItem) { + return new Promise((resolve) => { + const objectToSpy = this.vaults + sinon.stub(objectToSpy, 'moveItemToVault').callsFake(async (vault, item) => { + objectToSpy.moveItemToVault.restore() + const result = await objectToSpy.moveItemToVault(vault, item) + if (!targetItem || item.uuid === targetItem.uuid) { + resolve() + } + return result + }) + }) + } + + resolveWhenItemCompletesRemovingFromVault(targetItem) { + return new Promise((resolve) => { + const objectToSpy = this.vaults + sinon.stub(objectToSpy, 'removeItemFromVault').callsFake(async (item) => { + objectToSpy.removeItemFromVault.restore() + const result = await objectToSpy.removeItemFromVault(item) + if (item.uuid === targetItem.uuid) { + resolve() + } + return result + }) + }) + } + + resolveWhenAsymmetricMessageProcessingCompletes() { + return new Promise((resolve) => { + const objectToSpy = this.asymmetric + sinon.stub(objectToSpy, 'handleRemoteReceivedAsymmetricMessages').callsFake(async (messages) => { + objectToSpy.handleRemoteReceivedAsymmetricMessages.restore() + const result = await objectToSpy.handleRemoteReceivedAsymmetricMessages(messages) + resolve() + return result + }) + }) + } + + resolveWhenUserMessagesProcessingCompletes() { + return new Promise((resolve) => { + const objectToSpy = this.application.userEventService + sinon.stub(objectToSpy, 'handleReceivedUserEvents').callsFake(async (params) => { + objectToSpy.handleReceivedUserEvents.restore() + const result = await objectToSpy.handleReceivedUserEvents(params) + resolve() + return result + }) + }) + } + + resolveWhenSharedVaultServiceSendsContactShareMessage() { + return new Promise((resolve) => { + const objectToSpy = this.sharedVaults + sinon.stub(objectToSpy, 'shareContactWithUserAdministeredSharedVaults').callsFake(async (contact) => { + objectToSpy.shareContactWithUserAdministeredSharedVaults.restore() + const result = await objectToSpy.shareContactWithUserAdministeredSharedVaults(contact) + resolve() + return result + }) + }) + } + + resolveWhenSharedVaultKeyRotationInvitesGetSent(targetVault) { + return new Promise((resolve) => { + const objectToSpy = this.sharedVaults + sinon.stub(objectToSpy, 'handleVaultRootKeyRotatedEvent').callsFake(async (vault) => { + objectToSpy.handleVaultRootKeyRotatedEvent.restore() + const result = await objectToSpy.handleVaultRootKeyRotatedEvent(vault) + if (vault.systemIdentifier === targetVault.systemIdentifier) { + resolve() + } + return result + }) + }) + } + + resolveWhenSharedVaultChangeInvitesAreSent(sharedVaultUuid) { + return new Promise((resolve) => { + const objectToSpy = this.sharedVaults + sinon.stub(objectToSpy, 'handleVaultRootKeyRotatedEvent').callsFake(async (vault) => { + objectToSpy.handleVaultRootKeyRotatedEvent.restore() + const result = await objectToSpy.handleVaultRootKeyRotatedEvent(vault) + if (vault.sharing.sharedVaultUuid === sharedVaultUuid) { + resolve() + } + return result + }) + }) + } + awaitUserPrefsSingletonCreation() { const preferences = this.application.preferencesService.preferences if (preferences) { @@ -232,6 +453,10 @@ export class AppContext { await this.application.sync.sync(options || { awaitAll: true }) } + async clearSyncPositionTokens() { + await this.application.sync.clearSyncPositionTokens() + } + async maximumSync() { await this.sync(MaximumSyncOptions) } @@ -290,22 +515,31 @@ export class AppContext { }) } - async createSyncedNote(title, text) { + async createSyncedNote(title = 'foo', text = 'bar') { const payload = createNotePayload(title, text) - const item = await this.application.items.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) - await this.application.items.setItemDirty(item) + const item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.mutator.setItemDirty(item) await this.application.syncService.sync(MaximumSyncOptions) const note = this.application.items.findItem(payload.uuid) return note } + lockSyncing() { + this.application.syncService.lockSyncing() + } + + unlockSyncing() { + this.application.syncService.unlockSyncing() + } + async deleteItemAndSync(item) { await this.application.mutator.deleteItem(item) + await this.sync() } async changeNoteTitle(note, title) { - return this.application.items.changeNote(note, (mutator) => { + return this.application.mutator.changeNote(note, (mutator) => { mutator.title = title }) } @@ -325,6 +559,10 @@ export class AppContext { return this.application.items.getDisplayableNotes().length } + get notes() { + return this.application.items.getDisplayableNotes() + } + async createConflictedNotes(otherContext) { const note = await this.createSyncedNote() @@ -341,4 +579,41 @@ export class AppContext { conflict: this.findNoteByTitle('title-2'), } } + + findDuplicateNote(duplicateOfUuid) { + const items = this.items.getDisplayableNotes() + return items.find((note) => note.duplicateOf === duplicateOfUuid) + } + + get userUuid() { + return this.application.sessions.user.uuid + } + + sleep(seconds) { + return Utils.sleep(seconds) + } + + anticipateConsoleError(message, _reason) { + console.warn('Anticipating a console error with message:', message) + } + + async publicMockSubscriptionPurchaseEvent() { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + userEmail: this.email, + subscriptionId: GlobalSubscriptionIdCounter++, + subscriptionName: 'PRO_PLAN', + subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000, + timestamp: Date.now(), + offline: false, + discountCode: null, + limitedDiscountPurchased: false, + newSubscriber: true, + totalActiveSubscriptionsCount: 1, + userRegisteredAt: 1, + billingFrequency: 12, + payAmount: 59.0, + }) + + await Utils.sleep(2) + } } diff --git a/packages/snjs/mocha/lib/Applications.js b/packages/snjs/mocha/lib/Applications.js index a606bc692..d0d4fac53 100644 --- a/packages/snjs/mocha/lib/Applications.js +++ b/packages/snjs/mocha/lib/Applications.js @@ -2,10 +2,6 @@ import WebDeviceInterface from './web_device_interface.js' import FakeWebCrypto from './fake_web_crypto.js' import * as Defaults from './Defaults.js' -export const BaseItemCounts = { - DefaultItems: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, -} - export function createApplicationWithOptions({ identifier, environment, platform, host, crypto, device }) { if (!device) { device = new WebDeviceInterface() diff --git a/packages/snjs/mocha/lib/BaseItemCounts.js b/packages/snjs/mocha/lib/BaseItemCounts.js new file mode 100644 index 000000000..837ca8d6c --- /dev/null +++ b/packages/snjs/mocha/lib/BaseItemCounts.js @@ -0,0 +1,35 @@ +const ExpectedItemCountsWithVaultFeatureEnabled = { + Items: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, + ItemsWithAccount: ['ItemsKey', 'UserPreferences', 'DarkTheme', 'TrustedSelfContact'].length, + ItemsWithAccountWithoutItemsKey: ['UserPreferences', 'DarkTheme', 'TrustedSelfContact'].length, + ItemsNoAccounNoItemsKey: ['UserPreferences', 'DarkTheme'].length, + BackupFileRootKeyEncryptedItems: ['TrustedSelfContact'].length, +} + +const ExpectedItemCountsWithVaultFeatureDisabled = { + Items: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, + ItemsWithAccount: ['ItemsKey', 'UserPreferences', 'DarkTheme'].length, + ItemsWithAccountWithoutItemsKey: ['UserPreferences', 'DarkTheme'].length, + ItemsNoAccounNoItemsKey: ['UserPreferences', 'DarkTheme'].length, + BackupFileRootKeyEncryptedItems: [].length, +} + +const isVaultsEnabled = InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults) + +export const BaseItemCounts = { + DefaultItems: isVaultsEnabled + ? ExpectedItemCountsWithVaultFeatureEnabled.Items + : ExpectedItemCountsWithVaultFeatureDisabled.Items, + DefaultItemsWithAccount: isVaultsEnabled + ? ExpectedItemCountsWithVaultFeatureEnabled.ItemsWithAccount + : ExpectedItemCountsWithVaultFeatureDisabled.ItemsWithAccount, + DefaultItemsWithAccountWithoutItemsKey: isVaultsEnabled + ? ExpectedItemCountsWithVaultFeatureEnabled.ItemsWithAccountWithoutItemsKey + : ExpectedItemCountsWithVaultFeatureDisabled.ItemsWithAccountWithoutItemsKey, + DefaultItemsNoAccounNoItemsKey: isVaultsEnabled + ? ExpectedItemCountsWithVaultFeatureEnabled.ItemsNoAccounNoItemsKey + : ExpectedItemCountsWithVaultFeatureDisabled.ItemsNoAccounNoItemsKey, + BackupFileRootKeyEncryptedItems: isVaultsEnabled + ? ExpectedItemCountsWithVaultFeatureEnabled.BackupFileRootKeyEncryptedItems + : ExpectedItemCountsWithVaultFeatureDisabled.BackupFileRootKeyEncryptedItems, +} diff --git a/packages/snjs/mocha/lib/Collaboration.js b/packages/snjs/mocha/lib/Collaboration.js new file mode 100644 index 000000000..c3328e0d4 --- /dev/null +++ b/packages/snjs/mocha/lib/Collaboration.js @@ -0,0 +1,140 @@ +import * as Factory from './factory.js' + +export const createContactContext = async () => { + const contactContext = await Factory.createAppContextWithRealCrypto() + await contactContext.launch() + await contactContext.register() + + return { + contactContext, + deinitContactContext: contactContext.deinit.bind(contactContext), + } +} + +export const createTrustedContactForUserOfContext = async ( + contextAddingNewContact, + contextImportingContactInfoFrom, +) => { + const contact = await contextAddingNewContact.application.contactService.createOrEditTrustedContact({ + name: 'John Doe', + publicKey: contextImportingContactInfoFrom.publicKey, + signingPublicKey: contextImportingContactInfoFrom.signingPublicKey, + contactUuid: contextImportingContactInfoFrom.userUuid, + }) + + return contact +} + +export const acceptAllInvites = async (context) => { + const inviteRecords = context.sharedVaults.getCachedPendingInviteRecords() + for (const record of inviteRecords) { + await context.sharedVaults.acceptPendingSharedVaultInvite(record) + } +} + +export const createSharedVaultWithAcceptedInvite = async (context, permissions = SharedVaultPermission.Write) => { + const { sharedVault, contact, contactContext, deinitContactContext } = + await createSharedVaultWithUnacceptedButTrustedInvite(context, permissions) + + const promise = contactContext.awaitNextSyncSharedVaultFromScratchEvent() + + await acceptAllInvites(contactContext) + + await promise + + const contactVault = contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + + return { sharedVault, contact, contactVault, contactContext, deinitContactContext } +} + +export const createSharedVaultWithAcceptedInviteAndNote = async ( + context, + permissions = SharedVaultPermission.Write, +) => { + const { sharedVault, contactContext, contact, deinitContactContext } = await createSharedVaultWithAcceptedInvite( + context, + permissions, + ) + const note = await context.createSyncedNote('foo', 'bar') + const updatedNote = await moveItemToVault(context, sharedVault, note) + await contactContext.sync() + + return { sharedVault, note: updatedNote, contact, contactContext, deinitContactContext } +} + +export const createSharedVaultWithUnacceptedButTrustedInvite = async ( + context, + permissions = SharedVaultPermission.Write, +) => { + const sharedVault = await createSharedVault(context) + + const { contactContext, deinitContactContext } = await createContactContext() + const contact = await createTrustedContactForUserOfContext(context, contactContext) + await createTrustedContactForUserOfContext(contactContext, context) + + const invite = await context.sharedVaults.inviteContactToSharedVault(sharedVault, contact, permissions) + await contactContext.sync() + + return { sharedVault, contact, contactContext, deinitContactContext, invite } +} + +export const createSharedVaultWithUnacceptedAndUntrustedInvite = async ( + context, + permissions = SharedVaultPermission.Write, +) => { + const sharedVault = await createSharedVault(context) + + const { contactContext, deinitContactContext } = await createContactContext() + const contact = await createTrustedContactForUserOfContext(context, contactContext) + + const invite = await context.sharedVaults.inviteContactToSharedVault(sharedVault, contact, permissions) + await contactContext.sync() + + return { sharedVault, contact, contactContext, deinitContactContext, invite } +} + +export const inviteNewPartyToSharedVault = async (context, sharedVault, permissions = SharedVaultPermission.Write) => { + const { contactContext: thirdPartyContext, deinitContactContext: deinitThirdPartyContext } = + await createContactContext() + + const thirdPartyContact = await createTrustedContactForUserOfContext(context, thirdPartyContext) + await createTrustedContactForUserOfContext(thirdPartyContext, context) + await context.sharedVaults.inviteContactToSharedVault(sharedVault, thirdPartyContact, permissions) + + await thirdPartyContext.sync() + + return { thirdPartyContext, thirdPartyContact, deinitThirdPartyContext } +} + +export const createPrivateVault = async (context) => { + const privateVault = await context.vaults.createRandomizedVault({ + name: 'My Private Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + + return privateVault +} + +export const createSharedVault = async (context) => { + const sharedVault = await context.sharedVaults.createSharedVault({ name: 'My Shared Vault' }) + + if (isClientDisplayableError(sharedVault)) { + throw new Error(sharedVault.text) + } + + return sharedVault +} + +export const createSharedVaultWithNote = async (context) => { + const sharedVault = await createSharedVault(context) + const note = await context.createSyncedNote() + const updatedNote = await moveItemToVault(context, sharedVault, note) + return { sharedVault, note: updatedNote } +} + +export const moveItemToVault = async (context, sharedVault, item) => { + const promise = context.resolveWhenItemCompletesAddingToVault(item) + const updatedItem = await context.vaults.moveItemToVault(sharedVault, item) + await promise + return updatedItem +} diff --git a/packages/snjs/mocha/lib/Events.js b/packages/snjs/mocha/lib/Events.js new file mode 100644 index 000000000..7deba7546 --- /dev/null +++ b/packages/snjs/mocha/lib/Events.js @@ -0,0 +1,19 @@ +import * as Defaults from './Defaults.js' + +export async function publishMockedEvent(eventType, eventPayload) { + const response = await fetch(`${Defaults.getDefaultMockedEventServiceUrl()}/events`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + eventType, + eventPayload, + }), + }) + + if (!response.ok) { + console.error(`Failed to publish mocked event: ${response.status} ${response.statusText}`) + } +} diff --git a/packages/snjs/mocha/lib/Files.js b/packages/snjs/mocha/lib/Files.js index 6c4cabfcd..954965bb0 100644 --- a/packages/snjs/mocha/lib/Files.js +++ b/packages/snjs/mocha/lib/Files.js @@ -1,5 +1,5 @@ -export async function uploadFile(fileService, buffer, name, ext, chunkSize) { - const operation = await fileService.beginNewFileUpload(buffer.byteLength) +export async function uploadFile(fileService, buffer, name, ext, chunkSize, vault) { + const operation = await fileService.beginNewFileUpload(buffer.byteLength, vault) let chunkId = 1 for (let i = 0; i < buffer.length; i += chunkSize) { @@ -18,14 +18,16 @@ export async function uploadFile(fileService, buffer, name, ext, chunkSize) { return file } -export async function downloadFile(fileService, itemManager, remoteIdentifier) { - const file = itemManager.getItems(ContentType.File).find((file) => file.remoteIdentifier === remoteIdentifier) - +export async function downloadFile(fileService, file) { let receivedBytes = new Uint8Array() - await fileService.downloadFile(file, (decryptedBytes) => { + const error = await fileService.downloadFile(file, (decryptedBytes) => { receivedBytes = new Uint8Array([...receivedBytes, ...decryptedBytes]) }) + if (error) { + throw new Error('Could not download file', error.text) + } + return receivedBytes } diff --git a/packages/snjs/mocha/lib/Items.js b/packages/snjs/mocha/lib/Items.js index 09c4e9841..d10a693bc 100644 --- a/packages/snjs/mocha/lib/Items.js +++ b/packages/snjs/mocha/lib/Items.js @@ -63,7 +63,7 @@ export function createRelatedNoteTagPairPayload({ noteTitle, noteText, tagTitle, export async function createSyncedNoteWithTag(application) { const payloads = createRelatedNoteTagPairPayload() - await application.itemManager.emitItemsFromPayloads(payloads) + await application.mutator.emitItemsFromPayloads(payloads) return application.sync.sync(MaximumSyncOptions) } diff --git a/packages/snjs/mocha/lib/factory.js b/packages/snjs/mocha/lib/factory.js index 7f60369f3..45e8b8f71 100644 --- a/packages/snjs/mocha/lib/factory.js +++ b/packages/snjs/mocha/lib/factory.js @@ -71,24 +71,6 @@ export function getDefaultHost() { return Defaults.getDefaultHost() } -export async function publishMockedEvent(eventType, eventPayload) { - const response = await fetch(`${Defaults.getDefaultMockedEventServiceUrl()}/events`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - eventType, - eventPayload, - }), - }) - - if (!response.ok) { - console.error(`Failed to publish mocked event: ${response.status} ${response.statusText}`) - } -} - export function createApplicationWithFakeCrypto(identifier, environment, platform, host) { return Applications.createApplicationWithFakeCrypto(identifier, environment, platform, host) } @@ -154,7 +136,7 @@ export async function registerOldUser({ application, email, password, version }) keyParams: accountKey.keyParams, }) /** Mark all existing items as dirty. */ - await application.itemManager.changeItems(application.itemManager.items, (m) => { + await application.mutator.changeItems(application.itemManager.items, (m) => { m.dirty = true }) await application.sessionManager.handleSuccessAuthResponse(response, accountKey) @@ -188,18 +170,18 @@ export function itemToStoragePayload(item) { export function createMappedNote(application, title, text, dirty = true) { const payload = createNotePayload(title, text, dirty) - return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + return application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) } export async function createMappedTag(application, tagParams = {}) { const payload = createStorageItemTagPayload(tagParams) - return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + return application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) } export async function createSyncedNote(application, title, text) { const payload = createNotePayload(title, text) - const item = await application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) - await application.itemManager.setItemDirty(item) + const item = await application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await application.mutator.setItemDirty(item) await application.syncService.sync(syncOptions) const note = application.items.findItem(payload.uuid) return note @@ -218,7 +200,7 @@ export async function createManyMappedNotes(application, count) { const createdNotes = [] for (let i = 0; i < count; i++) { const note = await createMappedNote(application) - await application.itemManager.setItemDirty(note) + await application.mutator.setItemDirty(note) createdNotes.push(note) } return createdNotes @@ -406,7 +388,7 @@ export function pinNote(application, note) { } export async function insertItemWithOverride(application, contentType, content, needsSync = false, errorDecrypting) { - const item = await application.itemManager.createItem(contentType, content, needsSync) + const item = await application.mutator.createItem(contentType, content, needsSync) if (errorDecrypting) { const encrypted = new EncryptedPayload({ @@ -415,12 +397,12 @@ export async function insertItemWithOverride(application, contentType, content, errorDecrypting, }) - await application.itemManager.emitItemFromPayload(encrypted) + await application.payloadManager.emitPayload(encrypted) } else { const decrypted = new DecryptedPayload({ ...item.payload.ejected(), }) - await application.itemManager.emitItemFromPayload(decrypted) + await application.payloadManager.emitPayload(decrypted) } return application.itemManager.findAnyItem(item.uuid) @@ -441,7 +423,7 @@ export async function markDirtyAndSyncItem(application, itemToLookupUuidFor) { throw Error('Attempting to save non-inserted item') } if (!item.dirty) { - await application.itemManager.changeItem(item, undefined, MutationType.NoUpdateUserTimestamps) + await application.mutator.changeItem(item, undefined, MutationType.NoUpdateUserTimestamps) } await application.sync.sync() } @@ -467,23 +449,22 @@ export async function changePayloadTimeStamp(application, payload, timestamp, co updated_at_timestamp: timestamp, }) - await application.itemManager.emitItemFromPayload(changedPayload) + await application.mutator.emitItemFromPayload(changedPayload) return application.itemManager.findAnyItem(payload.uuid) } export async function changePayloadUpdatedAt(application, payload, updatedAt) { const latestPayload = application.payloadManager.collection.find(payload.uuid) + const changedPayload = new DecryptedPayload({ - ...latestPayload, + ...latestPayload.ejected(), dirty: true, dirtyIndex: getIncrementedDirtyIndex(), updated_at: updatedAt, }) - await application.itemManager.emitItemFromPayload(changedPayload) - - return application.itemManager.findAnyItem(payload.uuid) + return application.mutator.emitItemFromPayload(changedPayload) } export async function changePayloadTimeStampDeleteAndSync(application, payload, timestamp, syncOptions) { @@ -497,6 +478,6 @@ export async function changePayloadTimeStampDeleteAndSync(application, payload, updated_at_timestamp: timestamp, }) - await application.itemManager.emitItemFromPayload(changedPayload) + await application.payloadManager.emitPayload(changedPayload) await application.sync.sync(syncOptions) } diff --git a/packages/snjs/mocha/lib/fake_web_crypto.js b/packages/snjs/mocha/lib/fake_web_crypto.js index f290a9e30..c898b693b 100644 --- a/packages/snjs/mocha/lib/fake_web_crypto.js +++ b/packages/snjs/mocha/lib/fake_web_crypto.js @@ -158,8 +158,39 @@ export default class FakeWebCrypto { return data.message } - sodiumCryptoBoxGenerateKeypair() { - return { publicKey: this.randomString(64), privateKey: this.randomString(64), keyType: 'x25519' } + sodiumCryptoSign(message, secretKey) { + const data = { + message, + secretKey, + } + return btoa(JSON.stringify(data)) + } + + sodiumCryptoKdfDeriveFromKey(key, subkeyNumber, subkeyLength, context) { + return btoa(key + subkeyNumber + subkeyLength + context) + } + + sodiumCryptoGenericHash(message, key) { + return btoa(message + key) + } + + sodiumCryptoSignVerify(message, signature, publicKey) { + return true + } + + sodiumCryptoBoxSeedKeypair(seed) { + return { + privateKey: seed, + publicKey: seed, + } + } + + + sodiumCryptoSignSeedKeypair(seed) { + return { + privateKey: seed, + publicKey: seed, + } } generateOtpSecret() { diff --git a/packages/snjs/mocha/lib/web_device_interface.js b/packages/snjs/mocha/lib/web_device_interface.js index 78daa9a25..9be392c8d 100644 --- a/packages/snjs/mocha/lib/web_device_interface.js +++ b/packages/snjs/mocha/lib/web_device_interface.js @@ -37,6 +37,10 @@ export default class WebDeviceInterface { return {} } + clearAllDataFromDevice() { + localStorage.clear() + } + _getDatabaseKeyPrefix(identifier) { if (identifier) { return `${identifier}-item-` @@ -61,29 +65,45 @@ export default class WebDeviceInterface { async getDatabaseLoadChunks(options, identifier) { const entries = await this.getAllDatabaseEntries(identifier) - const sorted = GetSortedPayloadsByPriority(entries, options) + const { + itemsKeyPayloads, + keySystemRootKeyPayloads, + keySystemItemsKeyPayloads, + contentTypePriorityPayloads, + remainingPayloads, + } = GetSortedPayloadsByPriority(entries, options) const itemsKeysChunk = { - entries: sorted.itemsKeyPayloads, + entries: itemsKeyPayloads, + } + + const keySystemRootKeysChunk = { + entries: keySystemRootKeyPayloads, + } + + const keySystemItemsKeysChunk = { + entries: keySystemItemsKeyPayloads, } const contentTypePriorityChunk = { - entries: sorted.contentTypePriorityPayloads, + entries: contentTypePriorityPayloads, } const remainingPayloadsChunks = [] - for (let i = 0; i < sorted.remainingPayloads.length; i += options.batchSize) { + for (let i = 0; i < remainingPayloads.length; i += options.batchSize) { remainingPayloadsChunks.push({ - entries: sorted.remainingPayloads.slice(i, i + options.batchSize), + entries: remainingPayloads.slice(i, i + options.batchSize), }) } const result = { fullEntries: { itemsKeys: itemsKeysChunk, + keySystemRootKeys: keySystemRootKeysChunk, + keySystemItemsKeys: keySystemItemsKeysChunk, remainingChunks: [contentTypePriorityChunk, ...remainingPayloadsChunks], }, - remainingChunksItemCount: sorted.contentTypePriorityPayloads.length + sorted.remainingPayloads.length, + remainingChunksItemCount: contentTypePriorityPayloads.length + remainingPayloads.length, } return result diff --git a/packages/snjs/mocha/migrations/migration.test.js b/packages/snjs/mocha/migrations/migration.test.js index d1ce10813..6c4fcdb5d 100644 --- a/packages/snjs/mocha/migrations/migration.test.js +++ b/packages/snjs/mocha/migrations/migration.test.js @@ -68,7 +68,7 @@ describe('migrations', () => { }), }), ) - await application.mutator.insertItem(mfaItem) + await application.mutator.insertItem(mfaItem, true) await application.sync.sync() expect(application.items.getItems('SF|MFA').length).to.equal(1) diff --git a/packages/snjs/mocha/model_tests/appmodels.test.js b/packages/snjs/mocha/model_tests/appmodels.test.js index 13847c349..f04ddc2f4 100644 --- a/packages/snjs/mocha/model_tests/appmodels.test.js +++ b/packages/snjs/mocha/model_tests/appmodels.test.js @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -70,8 +70,8 @@ describe('app models', () => { }, }) - await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) - await this.application.itemManager.emitItemsFromPayloads([params2], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params2], PayloadEmitSource.LocalChanged) const item1 = this.application.itemManager.findItem(params1.uuid) const item2 = this.application.itemManager.findItem(params2.uuid) @@ -93,11 +93,11 @@ describe('app models', () => { }, }) - let items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + let items = await this.application.mutator.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) let item = items[0] expect(item).to.be.ok - items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + items = await this.application.mutator.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) item = items[0] expect(item.content.foo).to.equal('bar') @@ -108,10 +108,10 @@ describe('app models', () => { const item1 = await Factory.createMappedNote(this.application) const item2 = await Factory.createMappedNote(this.application) - await this.application.itemManager.changeItem(item1, (mutator) => { + await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) - await this.application.itemManager.changeItem(item2, (mutator) => { + await this.application.mutator.changeItem(item2, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item1) }) @@ -123,10 +123,10 @@ describe('app models', () => { var item1 = await Factory.createMappedNote(this.application) var item2 = await Factory.createMappedNote(this.application) - await this.application.itemManager.changeItem(item1, (mutator) => { + await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) - await this.application.itemManager.changeItem(item2, (mutator) => { + await this.application.mutator.changeItem(item2, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item1) }) @@ -143,7 +143,7 @@ describe('app models', () => { references: [], }, }) - await this.application.itemManager.emitItemsFromPayloads([damagedPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([damagedPayload], PayloadEmitSource.LocalChanged) const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid) const refreshedItem2_2 = this.application.itemManager.findItem(item2.uuid) @@ -155,10 +155,10 @@ describe('app models', () => { it('creating and removing relationships between two items should have valid references', async function () { var item1 = await Factory.createMappedNote(this.application) var item2 = await Factory.createMappedNote(this.application) - await this.application.itemManager.changeItem(item1, (mutator) => { + await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) - await this.application.itemManager.changeItem(item2, (mutator) => { + await this.application.mutator.changeItem(item2, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item1) }) @@ -171,10 +171,10 @@ describe('app models', () => { expect(this.application.itemManager.itemsReferencingItem(item1)).to.include(refreshedItem2) expect(this.application.itemManager.itemsReferencingItem(item2)).to.include(refreshedItem1) - await this.application.itemManager.changeItem(item1, (mutator) => { + await this.application.mutator.changeItem(item1, (mutator) => { mutator.removeItemAsRelationship(item2) }) - await this.application.itemManager.changeItem(item2, (mutator) => { + await this.application.mutator.changeItem(item2, (mutator) => { mutator.removeItemAsRelationship(item1) }) @@ -190,7 +190,7 @@ describe('app models', () => { it('properly duplicates item with no relationships', async function () { const item = await Factory.createMappedNote(this.application) - const duplicate = await this.application.itemManager.duplicateItem(item) + const duplicate = await this.application.mutator.duplicateItem(item) expect(duplicate.uuid).to.not.equal(item.uuid) expect(item.isItemContentEqualWith(duplicate)).to.equal(true) expect(item.created_at.toISOString()).to.equal(duplicate.created_at.toISOString()) @@ -201,13 +201,13 @@ describe('app models', () => { const item1 = await Factory.createMappedNote(this.application) const item2 = await Factory.createMappedNote(this.application) - const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + const refreshedItem1 = await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) expect(refreshedItem1.content.references.length).to.equal(1) - const duplicate = await this.application.itemManager.duplicateItem(item1) + const duplicate = await this.application.mutator.duplicateItem(item1) expect(duplicate.uuid).to.not.equal(item1.uuid) expect(duplicate.content.references.length).to.equal(1) @@ -223,11 +223,11 @@ describe('app models', () => { it('removing references should update cross-refs', async function () { const item1 = await Factory.createMappedNote(this.application) const item2 = await Factory.createMappedNote(this.application) - const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + const refreshedItem1 = await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) - const refreshedItem1_2 = await this.application.itemManager.emitItemFromPayload( + const refreshedItem1_2 = await this.application.mutator.emitItemFromPayload( refreshedItem1.payloadRepresentation({ deleted: true, content: { @@ -247,7 +247,7 @@ describe('app models', () => { const item1 = await Factory.createMappedNote(this.application) const item2 = await Factory.createMappedNote(this.application) - const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + const refreshedItem1 = await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) @@ -290,12 +290,12 @@ describe('app models', () => { waitingForKey: true, }) - await this.application.itemManager.emitItemFromPayload(errored) + await this.application.payloadManager.emitPayload(errored) expect(this.application.payloadManager.findOne(item1.uuid).errorDecrypting).to.equal(true) expect(this.application.payloadManager.findOne(item1.uuid).items_key_id).to.equal(itemsKey.uuid) - sinon.stub(this.application.protocolService.itemsEncryption, 'decryptErroredPayloads').callsFake(() => { + sinon.stub(this.application.protocolService.itemsEncryption, 'decryptErroredItemPayloads').callsFake(() => { // prevent auto decryption }) @@ -310,7 +310,7 @@ describe('app models', () => { const item2 = await Factory.createMappedNote(this.application) this.expectedItemCount += 2 - await this.application.itemManager.changeItem(item1, (mutator) => { + await this.application.mutator.changeItem(item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) }) @@ -339,13 +339,13 @@ describe('app models', () => { it('maintains referencing relationships when duplicating', async function () { const tag = await Factory.createMappedTag(this.application) const note = await Factory.createMappedNote(this.application) - const refreshedTag = await this.application.itemManager.changeItem(tag, (mutator) => { + const refreshedTag = await this.application.mutator.changeItem(tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note) }) expect(refreshedTag.content.references.length).to.equal(1) - const noteCopy = await this.application.itemManager.duplicateItem(note) + const noteCopy = await this.application.mutator.duplicateItem(note) expect(note.uuid).to.not.equal(noteCopy.uuid) expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) @@ -358,7 +358,7 @@ describe('app models', () => { }) it('maintains editor reference when duplicating note', async function () { - const editor = await this.application.itemManager.createItem( + const editor = await this.application.mutator.createItem( ContentType.Component, { area: ComponentArea.Editor, package_info: { identifier: 'foo-editor' } }, true, @@ -369,7 +369,7 @@ describe('app models', () => { expect(this.application.componentManager.editorForNote(note).uuid).to.equal(editor.uuid) - const duplicate = await this.application.itemManager.duplicateItem(note, true) + const duplicate = await this.application.mutator.duplicateItem(note, true) expect(this.application.componentManager.editorForNote(duplicate).uuid).to.equal(editor.uuid) }) }) diff --git a/packages/snjs/mocha/model_tests/importing.test.js b/packages/snjs/mocha/model_tests/importing.test.js index f23bc4b26..6d374e50f 100644 --- a/packages/snjs/mocha/model_tests/importing.test.js +++ b/packages/snjs/mocha/model_tests/importing.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' import { createRelatedNoteTagPairPayload } from '../lib/Items.js' chai.use(chaiAsPromised) @@ -43,7 +43,7 @@ describe('importing', function () { it('should not import backups made from unsupported versions', async function () { await setup({ fakeCrypto: true }) - const result = await application.mutator.importData({ + const result = await application.importData({ version: '-1', items: [], }) @@ -58,7 +58,7 @@ describe('importing', function () { password, version: ProtocolVersion.V003, }) - const result = await application.mutator.importData({ + const result = await application.importData({ version: ProtocolVersion.V004, items: [], }) @@ -71,7 +71,7 @@ describe('importing', function () { const notePayload = pair[0] const tagPayload = pair[1] - await application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) expectedItemCount += 2 const note = application.itemManager.getItems([ContentType.Note])[0] const tag = application.itemManager.getItems([ContentType.Tag])[0] @@ -82,7 +82,7 @@ describe('importing', function () { expect(note.content.references.length).to.equal(0) expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1) - await application.mutator.importData( + await application.importData( { items: [notePayload, tagPayload], }, @@ -105,7 +105,7 @@ describe('importing', function () { */ await setup({ fakeCrypto: true }) const notePayload = Factory.createNotePayload() - await application.itemManager.emitItemFromPayload(notePayload, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemFromPayload(notePayload, PayloadEmitSource.LocalChanged) expectedItemCount++ const mutatedNote = new DecryptedPayload({ ...notePayload, @@ -114,7 +114,7 @@ describe('importing', function () { title: `${Math.random()}`, }, }) - await application.mutator.importData( + await application.importData( { items: [mutatedNote, mutatedNote, mutatedNote], }, @@ -130,7 +130,7 @@ describe('importing', function () { await setup({ fakeCrypto: true }) const pair = createRelatedNoteTagPairPayload() const tagPayload = pair[1] - await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) const mutatedTag = new DecryptedPayload({ ...tagPayload, content: { @@ -138,7 +138,7 @@ describe('importing', function () { references: [], }, }) - await application.mutator.importData( + await application.importData( { items: [mutatedTag], }, @@ -153,7 +153,7 @@ describe('importing', function () { const pair = createRelatedNoteTagPairPayload() const notePayload = pair[0] const tagPayload = pair[1] - await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) expectedItemCount += 2 const note = application.itemManager.getDisplayableNotes()[0] const tag = application.itemManager.getDisplayableTags()[0] @@ -171,7 +171,7 @@ describe('importing', function () { title: `${Math.random()}`, }, }) - await application.mutator.importData( + await application.importData( { items: [mutatedNote, mutatedTag], }, @@ -217,7 +217,7 @@ describe('importing', function () { const tag = await Factory.createMappedTag(application) expectedItemCount += 2 - await application.itemManager.changeItem(tag, (mutator) => { + await application.mutator.changeItem(tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note) }) @@ -240,7 +240,7 @@ describe('importing', function () { }, ) - await application.mutator.importData( + await application.importData( { items: [externalNote, externalTag], }, @@ -272,12 +272,14 @@ describe('importing', function () { await application.sync.sync({ awaitAll: true }) await application.mutator.deleteItem(note) + await application.sync.sync() expect(application.items.findItem(note.uuid)).to.not.exist await application.mutator.deleteItem(tag) + await application.sync.sync() expect(application.items.findItem(tag.uuid)).to.not.exist - await application.mutator.importData( + await application.importData( { items: [note, tag], }, @@ -311,7 +313,7 @@ describe('importing', function () { password: password, }) - await application.mutator.importData( + await application.importData( { items: [note.payload], }, @@ -341,7 +343,7 @@ describe('importing', function () { password: password, }) - await application.mutator.importData( + await application.importData( { items: [note], }, @@ -372,12 +374,14 @@ describe('importing', function () { await application.sync.sync({ awaitAll: true }) await application.mutator.deleteItem(note) + await application.sync.sync() expect(application.items.findItem(note.uuid)).to.not.exist await application.mutator.deleteItem(tag) + await application.sync.sync() expect(application.items.findItem(tag.uuid)).to.not.exist - await application.mutator.importData(backupData, true) + await application.importData(backupData, true) expect(application.itemManager.getDisplayableNotes().length).to.equal(1) expect(application.items.findItem(note.uuid).deleted).to.not.be.ok @@ -402,7 +406,7 @@ describe('importing', function () { application = await Factory.createInitAppWithFakeCrypto() Factory.handlePasswordChallenges(application, password) - await application.mutator.importData(backupData, true) + await application.importData(backupData, true) const importedNote = application.items.findItem(note.uuid) const importedTag = application.items.findItem(tag.uuid) @@ -427,7 +431,7 @@ describe('importing', function () { application = await Factory.createInitAppWithFakeCrypto() Factory.handlePasswordChallenges(application, password) - await application.mutator.importData(backupData, true) + await application.importData(backupData, true) const importedNote = application.items.findItem(note.uuid) const importedTag = application.items.findItem(tag.uuid) @@ -445,7 +449,7 @@ describe('importing', function () { version: oldVersion, }) - const noteItem = await application.itemManager.createItem(ContentType.Note, { + const noteItem = await application.mutator.createItem(ContentType.Note, { title: 'Encrypted note', text: 'On protocol version 003.', }) @@ -456,7 +460,7 @@ describe('importing', function () { application = await Factory.createInitAppWithFakeCrypto() Factory.handlePasswordChallenges(application, password) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(backupData.items.length) expect(result.errorCount).to.be.eq(0) @@ -512,7 +516,7 @@ describe('importing', function () { application = await Factory.createInitAppWithRealCrypto() Factory.handlePasswordChallenges(application, password) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(backupData.items.length) expect(result.errorCount).to.be.eq(0) @@ -526,7 +530,7 @@ describe('importing', function () { password: password, }) - const noteItem = await application.itemManager.createItem(ContentType.Note, { + const noteItem = await application.mutator.createItem(ContentType.Note, { title: 'Encrypted note', text: 'On protocol version 004.', }) @@ -537,7 +541,7 @@ describe('importing', function () { application = await Factory.createInitAppWithFakeCrypto() Factory.handlePasswordChallenges(application, password) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(backupData.items.length) expect(result.errorCount).to.be.eq(0) @@ -556,7 +560,7 @@ describe('importing', function () { password: password, }) - const noteItem = await application.itemManager.createItem(ContentType.Note, { + const noteItem = await application.mutator.createItem(ContentType.Note, { title: 'This is a valid, encrypted note', text: 'On protocol version 004.', }) @@ -577,7 +581,7 @@ describe('importing', function () { backupData.items = [...backupData.items, madeUpPayload] - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(backupData.items.length - 1) expect(result.errorCount).to.be.eq(1) @@ -594,7 +598,7 @@ describe('importing', function () { version: oldVersion, }) - await application.itemManager.createItem(ContentType.Note, { + await application.mutator.createItem(ContentType.Note, { title: 'Encrypted note', text: 'On protocol version 003.', }) @@ -615,7 +619,7 @@ describe('importing', function () { }, }) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(0) @@ -631,7 +635,7 @@ describe('importing', function () { password: password, }) - await application.itemManager.createItem(ContentType.Note, { + await application.mutator.createItem(ContentType.Note, { title: 'This is a valid, encrypted note', text: 'On protocol version 004.', }) @@ -647,7 +651,7 @@ describe('importing', function () { }, }) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined expect(result.affectedItems.length).to.be.eq(0) expect(result.errorCount).to.be.eq(backupData.items.length) @@ -662,7 +666,7 @@ describe('importing', function () { password: password, }) - await application.itemManager.createItem(ContentType.Note, { + await application.mutator.createItem(ContentType.Note, { title: 'Encrypted note', text: 'On protocol version 004.', }) @@ -673,7 +677,7 @@ describe('importing', function () { await Factory.safeDeinit(application) application = await Factory.createInitAppWithFakeCrypto() - const result = await application.mutator.importData(backupData) + const result = await application.importData(backupData) expect(result.error).to.be.ok }) @@ -687,7 +691,7 @@ describe('importing', function () { }) Factory.handlePasswordChallenges(application, password) - await application.itemManager.createItem(ContentType.Note, { + await application.mutator.createItem(ContentType.Note, { title: 'Encrypted note', text: 'On protocol version 004.', }) @@ -699,11 +703,13 @@ describe('importing', function () { application = await Factory.createInitAppWithFakeCrypto() Factory.handlePasswordChallenges(application, password) - const result = await application.mutator.importData(backupData, true) + const result = await application.importData(backupData, true) expect(result).to.not.be.undefined - expect(result.affectedItems.length).to.be.eq(0) - expect(result.errorCount).to.be.eq(backupData.items.length) + + expect(result.affectedItems.length).to.equal(BaseItemCounts.BackupFileRootKeyEncryptedItems) + + expect(result.errorCount).to.be.eq(backupData.items.length - BaseItemCounts.BackupFileRootKeyEncryptedItems) expect(application.itemManager.getDisplayableNotes().length).to.equal(0) }) @@ -784,7 +790,14 @@ describe('importing', function () { await application.prepareForLaunch({ receiveChallenge: (challenge) => { - if (challenge.prompts.length === 2) { + if (challenge.reason === ChallengeReason.Custom) { + return + } + + if ( + challenge.reason === ChallengeReason.DecryptEncryptedFile || + challenge.reason === ChallengeReason.ImportFile + ) { application.submitValuesForChallenge( challenge, challenge.prompts.map((prompt) => @@ -796,9 +809,6 @@ describe('importing', function () { ), ), ) - } else { - const prompt = challenge.prompts[0] - application.submitValuesForChallenge(challenge, [CreateChallengeValue(prompt, password)]) } }, }) @@ -827,7 +837,7 @@ describe('importing', function () { }, } - const result = await application.mutator.importData(backupFile, false) + const result = await application.importData(backupFile, false) expect(result.errorCount).to.equal(0) await Factory.safeDeinit(application) }) @@ -846,7 +856,7 @@ describe('importing', function () { Factory.handlePasswordChallenges(application, password) const pair = createRelatedNoteTagPairPayload() - await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) await application.sync.sync() @@ -862,7 +872,7 @@ describe('importing', function () { password: password, }) - await application.mutator.importData(backupData, true) + await application.importData(backupData, true) expect(application.itemManager.getDisplayableNotes().length).to.equal(1) expect(application.itemManager.getDisplayableTags().length).to.equal(1) @@ -872,4 +882,8 @@ describe('importing', function () { expect(application.itemManager.referencesForItem(importedTag).length).to.equal(1) expect(application.itemManager.itemsReferencingItem(importedNote).length).to.equal(1) }) + + it('should decrypt backup file which contains a vaulted note without a synced key system root key', async () => { + console.error('TODO: Implement this test') + }) }) diff --git a/packages/snjs/mocha/model_tests/items.test.js b/packages/snjs/mocha/model_tests/items.test.js index c79619ba9..8d7ac26ce 100644 --- a/packages/snjs/mocha/model_tests/items.test.js +++ b/packages/snjs/mocha/model_tests/items.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -22,11 +22,11 @@ describe('items', () => { it('setting an item as dirty should update its client updated at', async function () { const params = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) const item = this.application.itemManager.items[0] const prevDate = item.userModifiedDate.getTime() await Factory.sleep(0.1) - await this.application.itemManager.setItemDirty(item, true) + await this.application.mutator.setItemDirty(item, true) const refreshedItem = this.application.itemManager.findItem(item.uuid) const newDate = refreshedItem.userModifiedDate.getTime() expect(prevDate).to.not.equal(newDate) @@ -34,23 +34,23 @@ describe('items', () => { it('setting an item as dirty with option to skip client updated at', async function () { const params = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) const item = this.application.itemManager.items[0] const prevDate = item.userModifiedDate.getTime() await Factory.sleep(0.1) - await this.application.itemManager.setItemDirty(item) + await this.application.mutator.setItemDirty(item) const newDate = item.userModifiedDate.getTime() expect(prevDate).to.equal(newDate) }) it('properly pins, archives, and locks', async function () { const params = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) const item = this.application.itemManager.items[0] expect(item.pinned).to.not.be.ok - const refreshedItem = await this.application.mutator.changeAndSaveItem( + const refreshedItem = await this.application.changeAndSaveItem( item, (mutator) => { mutator.pinned = true @@ -69,7 +69,7 @@ describe('items', () => { it('properly compares item equality', async function () { const params1 = Factory.createNotePayload() const params2 = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) let item1 = this.application.itemManager.getDisplayableNotes()[0] let item2 = this.application.itemManager.getDisplayableNotes()[1] @@ -77,7 +77,7 @@ describe('items', () => { expect(item1.isItemContentEqualWith(item2)).to.equal(true) // items should ignore this field when checking for equality - item1 = await this.application.mutator.changeAndSaveItem( + item1 = await this.application.changeAndSaveItem( item1, (mutator) => { mutator.userModifiedDate = new Date() @@ -86,7 +86,7 @@ describe('items', () => { undefined, syncOptions, ) - item2 = await this.application.mutator.changeAndSaveItem( + item2 = await this.application.changeAndSaveItem( item2, (mutator) => { mutator.userModifiedDate = undefined @@ -98,7 +98,7 @@ describe('items', () => { expect(item1.isItemContentEqualWith(item2)).to.equal(true) - item1 = await this.application.mutator.changeAndSaveItem( + item1 = await this.application.changeAndSaveItem( item1, (mutator) => { mutator.mutableContent.foo = 'bar' @@ -110,7 +110,7 @@ describe('items', () => { expect(item1.isItemContentEqualWith(item2)).to.equal(false) - item2 = await this.application.mutator.changeAndSaveItem( + item2 = await this.application.changeAndSaveItem( item2, (mutator) => { mutator.mutableContent.foo = 'bar' @@ -123,7 +123,7 @@ describe('items', () => { expect(item1.isItemContentEqualWith(item2)).to.equal(true) expect(item2.isItemContentEqualWith(item1)).to.equal(true) - item1 = await this.application.mutator.changeAndSaveItem( + item1 = await this.application.changeAndSaveItem( item1, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item2) @@ -132,7 +132,7 @@ describe('items', () => { undefined, syncOptions, ) - item2 = await this.application.mutator.changeAndSaveItem( + item2 = await this.application.changeAndSaveItem( item2, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(item1) @@ -147,7 +147,7 @@ describe('items', () => { expect(item1.isItemContentEqualWith(item2)).to.equal(false) - item1 = await this.application.mutator.changeAndSaveItem( + item1 = await this.application.changeAndSaveItem( item1, (mutator) => { mutator.removeItemAsRelationship(item2) @@ -156,7 +156,7 @@ describe('items', () => { undefined, syncOptions, ) - item2 = await this.application.mutator.changeAndSaveItem( + item2 = await this.application.changeAndSaveItem( item2, (mutator) => { mutator.removeItemAsRelationship(item1) @@ -174,12 +174,12 @@ describe('items', () => { it('content equality should not have side effects', async function () { const params1 = Factory.createNotePayload() const params2 = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) let item1 = this.application.itemManager.getDisplayableNotes()[0] const item2 = this.application.itemManager.getDisplayableNotes()[1] - item1 = await this.application.mutator.changeAndSaveItem( + item1 = await this.application.changeAndSaveItem( item1, (mutator) => { mutator.mutableContent.foo = 'bar' @@ -203,7 +203,7 @@ describe('items', () => { // There was an issue where calling that function would modify values directly to omit keys // in contentKeysToIgnoreWhenCheckingEquality. - await this.application.itemManager.setItemsDirty([item1, item2]) + await this.application.mutator.setItemsDirty([item1, item2]) expect(item1.userModifiedDate).to.be.ok expect(item2.userModifiedDate).to.be.ok diff --git a/packages/snjs/mocha/model_tests/mapping.test.js b/packages/snjs/mocha/model_tests/mapping.test.js index 791c04adb..06ede1a7a 100644 --- a/packages/snjs/mocha/model_tests/mapping.test.js +++ b/packages/snjs/mocha/model_tests/mapping.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' import { createNoteParams } from '../lib/Items.js' chai.use(chaiAsPromised) @@ -20,7 +20,7 @@ describe('model manager mapping', () => { it('mapping nonexistent item creates it', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) this.expectedItemCount++ expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) }) @@ -31,13 +31,13 @@ describe('model manager mapping', () => { dirty: false, deleted: true, }) - await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.payloadManager.emitPayload(payload, PayloadEmitSource.LocalChanged) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) }) it('mapping and deleting nonexistent item creates and deletes it', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) this.expectedItemCount++ @@ -51,7 +51,7 @@ describe('model manager mapping', () => { this.expectedItemCount-- - await this.application.itemManager.emitItemsFromPayloads([changedParams], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([changedParams], PayloadEmitSource.LocalChanged) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) }) @@ -59,22 +59,22 @@ describe('model manager mapping', () => { it('mapping deleted but dirty item should not delete it', async function () { const payload = Factory.createNotePayload() - const [item] = await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + const [item] = await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) this.expectedItemCount++ - await this.application.itemManager.emitItemFromPayload(new DeleteItemMutator(item).getDeletedResult()) + await this.application.payloadManager.emitPayload(new DeleteItemMutator(item).getDeletedResult()) const payload2 = new DeletedPayload(this.application.payloadManager.findOne(payload.uuid).ejected()) - await this.application.itemManager.emitItemsFromPayloads([payload2], PayloadEmitSource.LocalChanged) + await this.application.payloadManager.emitPayloads([payload2], PayloadEmitSource.LocalChanged) expect(this.application.payloadManager.collection.all().length).to.equal(this.expectedItemCount) }) it('mapping existing item updates its properties', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) const newTitle = 'updated title' const mutated = new DecryptedPayload({ @@ -84,7 +84,7 @@ describe('model manager mapping', () => { title: newTitle, }, }) - await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) const item = this.application.itemManager.getDisplayableNotes()[0] expect(item.content.title).to.equal(newTitle) @@ -92,9 +92,9 @@ describe('model manager mapping', () => { it('setting an item dirty should retrieve it in dirty items', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getDisplayableNotes()[0] - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) const dirtyItems = this.application.itemManager.getDirtyItems() expect(Uuids(dirtyItems).includes(note.uuid)) }) @@ -106,7 +106,7 @@ describe('model manager mapping', () => { for (let i = 0; i < count; i++) { payloads.push(Factory.createNotePayload()) } - await this.application.itemManager.emitItemsFromPayloads(payloads, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads(payloads, PayloadEmitSource.LocalChanged) await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() const dirtyItems = this.application.itemManager.getDirtyItems() @@ -115,14 +115,14 @@ describe('model manager mapping', () => { it('sync observers should be notified of changes', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) const item = this.application.itemManager.items[0] return new Promise((resolve) => { this.application.itemManager.addObserver(ContentType.Any, ({ changed }) => { expect(changed[0].uuid === item.uuid) resolve() }) - this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + this.application.mutator.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) }) }) }) diff --git a/packages/snjs/mocha/model_tests/notes_tags.test.js b/packages/snjs/mocha/model_tests/notes_tags.test.js index 2ebe3d37a..5936e8992 100644 --- a/packages/snjs/mocha/model_tests/notes_tags.test.js +++ b/packages/snjs/mocha/model_tests/notes_tags.test.js @@ -2,7 +2,7 @@ import * as Factory from '../lib/factory.js' import * as Utils from '../lib/Utils.js' import { createRelatedNoteTagPairPayload } from '../lib/Items.js' -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -25,7 +25,7 @@ describe('notes and tags', () => { it('uses proper class for note', async function () { const payload = Factory.createNotePayload() - await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] expect(note.constructor === SNNote).to.equal(true) }) @@ -33,7 +33,7 @@ describe('notes and tags', () => { it('properly constructs syncing params', async function () { const title = 'Foo' const text = 'Bar' - const note = await this.application.mutator.createTemplateItem(ContentType.Note, { + const note = await this.application.items.createTemplateItem(ContentType.Note, { title, text, }) @@ -41,7 +41,7 @@ describe('notes and tags', () => { expect(note.content.title).to.equal(title) expect(note.content.text).to.equal(text) - const tag = await this.application.mutator.createTemplateItem(ContentType.Tag, { + const tag = await this.application.items.createTemplateItem(ContentType.Tag, { title, }) @@ -73,7 +73,7 @@ describe('notes and tags', () => { }, }) - await this.application.itemManager.emitItemsFromPayloads([mutatedNote, mutatedTag], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([mutatedNote, mutatedTag], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] const tag = this.application.itemManager.getItems([ContentType.Tag])[0] @@ -89,7 +89,7 @@ describe('notes and tags', () => { expect(notePayload.content.references.length).to.equal(0) expect(tagPayload.content.references.length).to.equal(1) - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getDisplayableNotes()[0] let tag = this.application.itemManager.getDisplayableTags()[0] @@ -106,7 +106,7 @@ describe('notes and tags', () => { expect(note.payload.references.length).to.equal(0) expect(tag.noteCount).to.equal(1) - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) tag = this.application.itemManager.getDisplayableTags()[0] @@ -130,7 +130,7 @@ describe('notes and tags', () => { const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getItems([ContentType.Note])[0] let tag = this.application.itemManager.getItems([ContentType.Tag])[0] @@ -147,7 +147,7 @@ describe('notes and tags', () => { references: [], }, }) - await this.application.itemManager.emitItemsFromPayloads([mutatedTag], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([mutatedTag], PayloadEmitSource.LocalChanged) note = this.application.itemManager.findItem(note.uuid) tag = this.application.itemManager.findItem(tag.uuid) @@ -177,14 +177,14 @@ describe('notes and tags', () => { const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] let tag = this.application.itemManager.getItems([ContentType.Tag])[0] expect(note.content.references.length).to.equal(0) expect(tag.content.references.length).to.equal(1) - tag = await this.application.mutator.changeAndSaveItem( + tag = await this.application.changeAndSaveItem( tag, (mutator) => { mutator.removeItemAsRelationship(note) @@ -200,11 +200,11 @@ describe('notes and tags', () => { it('properly handles tag duplication', async function () { const pair = createRelatedNoteTagPairPayload() - await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getDisplayableNotes()[0] let tag = this.application.itemManager.getDisplayableTags()[0] - const duplicateTag = await this.application.itemManager.duplicateItem(tag, true) + const duplicateTag = await this.application.mutator.duplicateItem(tag, true) await this.application.syncService.sync(syncOptions) note = this.application.itemManager.findItem(note.uuid) @@ -232,9 +232,9 @@ describe('notes and tags', () => { const pair = createRelatedNoteTagPairPayload() const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] - const duplicateNote = await this.application.itemManager.duplicateItem(note, true) + const duplicateNote = await this.application.mutator.duplicateItem(note, true) expect(note.uuid).to.not.equal(duplicateNote.uuid) expect(this.application.itemManager.itemsReferencingItem(duplicateNote).length).to.equal( @@ -246,7 +246,7 @@ describe('notes and tags', () => { const pair = createRelatedNoteTagPairPayload() const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] let tag = this.application.itemManager.getItems([ContentType.Tag])[0] @@ -256,16 +256,16 @@ describe('notes and tags', () => { expect(note.content.references.length).to.equal(0) expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) - await this.application.itemManager.setItemToBeDeleted(tag) + await this.application.mutator.setItemToBeDeleted(tag) tag = this.application.itemManager.findItem(tag.uuid) expect(tag).to.not.be.ok }) it('modifying item content should not modify payload content', async function () { const notePayload = Factory.createNotePayload() - await this.application.itemManager.emitItemsFromPayloads([notePayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload], PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getItems([ContentType.Note])[0] - note = await this.application.mutator.changeAndSaveItem( + note = await this.application.changeAndSaveItem( note, (mutator) => { mutator.mutableContent.title = Math.random() @@ -285,12 +285,12 @@ describe('notes and tags', () => { const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getItems([ContentType.Note])[0] let tag = this.application.itemManager.getItems([ContentType.Tag])[0] await this.application.syncService.sync(syncOptions) - await this.application.itemManager.setItemToBeDeleted(tag) + await this.application.mutator.setItemToBeDeleted(tag) note = this.application.itemManager.findItem(note.uuid) this.application.itemManager.findItem(tag.uuid) @@ -302,7 +302,7 @@ describe('notes and tags', () => { await Promise.all( ['Y', 'Z', 'A', 'B'].map(async (title) => { return this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { title }), + await this.application.items.createTemplateItem(ContentType.Note, { title }), ) }), ) @@ -316,7 +316,7 @@ describe('notes and tags', () => { }) it('setting a note dirty should collapse its properties into content', async function () { - let note = await this.application.mutator.createTemplateItem(ContentType.Note, { + let note = await this.application.items.createTemplateItem(ContentType.Note, { title: 'Foo', }) await this.application.mutator.insertItem(note) @@ -339,7 +339,7 @@ describe('notes and tags', () => { mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) }) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -379,7 +379,7 @@ describe('notes and tags', () => { await Promise.all( ['Y', 'Z', 'A', 'B'].map(async (title) => { return this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title, }), ) @@ -413,17 +413,17 @@ describe('notes and tags', () => { describe('Smart views', function () { it('"title", "startsWith", "Foo"', async function () { const note = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'Foo 🎲', }), ) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'Not Foo 🎲', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Foo Notes', predicate: { keypath: 'title', @@ -447,7 +447,7 @@ describe('notes and tags', () => { it('"pinned", "=", true', async function () { const note = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -455,13 +455,13 @@ describe('notes and tags', () => { mutator.pinned = true }) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'B', pinned: false, }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Pinned', predicate: { keypath: 'pinned', @@ -485,7 +485,7 @@ describe('notes and tags', () => { it('"pinned", "=", false', async function () { const pinnedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -493,12 +493,12 @@ describe('notes and tags', () => { mutator.pinned = true }) const unpinnedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'B', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Not pinned', predicate: { keypath: 'pinned', @@ -522,19 +522,19 @@ describe('notes and tags', () => { it('"text.length", ">", 500', async function () { const longNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', text: Array(501).fill(0).join(''), }), ) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'B', text: 'b', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Long', predicate: { keypath: 'text.length', @@ -563,18 +563,20 @@ describe('notes and tags', () => { }) const recentNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), + true, ) await this.application.sync.sync() const olderNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'B', text: 'b', }), + true, ) const threeDays = 3 * 24 * 60 * 60 * 1000 @@ -582,13 +584,13 @@ describe('notes and tags', () => { /** Create an unsynced note which shouldn't get an updated_at */ await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'B', text: 'b', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'One day ago', predicate: { keypath: 'serverUpdatedAt', @@ -598,6 +600,9 @@ describe('notes and tags', () => { }), ) const matches = this.application.items.notesMatchingSmartView(view) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(recentNote.uuid) + this.application.items.setPrimaryItemDisplayOptions({ sortBy: 'title', sortDirection: 'asc', @@ -605,13 +610,11 @@ describe('notes and tags', () => { }) const displayedNotes = this.application.items.getDisplayableNotes() expect(displayedNotes).to.deep.equal(matches) - expect(matches.length).to.equal(1) - expect(matches[0].uuid).to.equal(recentNote.uuid) }) it('"tags.length", "=", 0', async function () { const untaggedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -622,7 +625,7 @@ describe('notes and tags', () => { }) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Untagged', predicate: { keypath: 'tags.length', @@ -650,13 +653,13 @@ describe('notes and tags', () => { mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) }) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'B-tags', predicate: { keypath: 'tags', @@ -685,7 +688,7 @@ describe('notes and tags', () => { }) const pinnedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -694,7 +697,7 @@ describe('notes and tags', () => { }) const lockedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -703,7 +706,7 @@ describe('notes and tags', () => { }) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Pinned & Locked', predicate: { operator: 'and', @@ -733,7 +736,7 @@ describe('notes and tags', () => { }) const pinnedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -742,7 +745,7 @@ describe('notes and tags', () => { }) const pinnedAndProtectedNote = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) @@ -752,13 +755,13 @@ describe('notes and tags', () => { }) await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.Note, { + await this.application.items.createTemplateItem(ContentType.Note, { title: 'A', }), ) const view = await this.application.mutator.insertItem( - await this.application.mutator.createTemplateItem(ContentType.SmartView, { + await this.application.items.createTemplateItem(ContentType.SmartView, { title: 'Protected or Pinned', predicate: { operator: 'or', @@ -794,7 +797,7 @@ describe('notes and tags', () => { const notePayload3 = Factory.createNotePayload('Bar') const notePayload4 = Factory.createNotePayload('Testing') - await this.application.itemManager.emitItemsFromPayloads( + await this.application.mutator.emitItemsFromPayloads( [notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1], PayloadEmitSource.LocalChanged, ) @@ -824,7 +827,7 @@ describe('notes and tags', () => { const notePayload3 = Factory.createNotePayload('Testing FOO (Bar)') const notePayload4 = Factory.createNotePayload('This should not match') - await this.application.itemManager.emitItemsFromPayloads( + await this.application.mutator.emitItemsFromPayloads( [notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1], PayloadEmitSource.LocalChanged, ) diff --git a/packages/snjs/mocha/model_tests/notes_tags_folders.test.js b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js index f63bd8e55..ffa239153 100644 --- a/packages/snjs/mocha/model_tests/notes_tags_folders.test.js +++ b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js @@ -75,8 +75,8 @@ describe('tags as folders', () => { const note2 = await Factory.createMappedNote(this.application, 'my second note') // ## The user add a note to the child tag - await this.application.items.addTagToNote(note1, tags.child, true) - await this.application.items.addTagToNote(note2, tags.another, true) + await this.application.mutator.addTagToNote(note1, tags.child, true) + await this.application.mutator.addTagToNote(note2, tags.another, true) // ## The note has been added to other tags const note1Tags = await this.application.items.getSortedTagsForItem(note1) diff --git a/packages/snjs/mocha/model_tests/performance.test.js b/packages/snjs/mocha/model_tests/performance.test.js index 233cf56e5..8552eb346 100644 --- a/packages/snjs/mocha/model_tests/performance.test.js +++ b/packages/snjs/mocha/model_tests/performance.test.js @@ -56,7 +56,7 @@ describe('mapping performance', () => { const batchSize = 100 for (let i = 0; i < payloads.length; i += batchSize) { const subArray = payloads.slice(currentIndex, currentIndex + batchSize) - await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) currentIndex += batchSize } @@ -117,7 +117,7 @@ describe('mapping performance', () => { const batchSize = 100 for (let i = 0; i < payloads.length; i += batchSize) { var subArray = payloads.slice(currentIndex, currentIndex + batchSize) - await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) + await application.mutator.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) currentIndex += batchSize } diff --git a/packages/snjs/mocha/mutator.test.js b/packages/snjs/mocha/mutator.test.js index 88b8aeee9..898283127 100644 --- a/packages/snjs/mocha/mutator.test.js +++ b/packages/snjs/mocha/mutator.test.js @@ -4,7 +4,7 @@ import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect -describe('mutator', () => { +describe('item mutator', () => { beforeEach(async function () { this.createBarePayload = () => { return new DecryptedPayload({ diff --git a/packages/snjs/mocha/mutator_service.test.js b/packages/snjs/mocha/mutator_service.test.js new file mode 100644 index 000000000..de993fd0e --- /dev/null +++ b/packages/snjs/mocha/mutator_service.test.js @@ -0,0 +1,271 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('mutator service', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let application + let mutator + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithFakeCrypto() + application = context.application + mutator = application.mutator + + await context.launch() + }) + + const createNote = async () => { + return mutator.createItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + } + + const createTag = async (notes = []) => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return mutator.createItem(ContentType.Tag, { + title: 'thoughts', + references: references, + }) + } + + it('create item', async function () { + const item = await createNote() + + expect(item).to.be.ok + expect(item.title).to.equal('hello') + }) + + it('emitting item through payload and marking dirty should have userModifiedDate', async function () { + const payload = Factory.createNotePayload() + const item = await mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const result = await mutator.setItemDirty(item) + const appData = result.payload.content.appData + expect(appData[DecryptedItem.DefaultAppDomain()][AppDataField.UserModifiedDate]).to.be.ok + }) + + it('deleting an item should make it immediately unfindable', async () => { + const note = await context.createSyncedNote() + await mutator.setItemToBeDeleted(note) + const foundNote = application.items.findItem(note.uuid) + expect(foundNote).to.not.be.ok + }) + + it('deleting from reference map', async function () { + const note = await createNote() + const tag = await createTag([note]) + await mutator.setItemToBeDeleted(note) + + expect(application.items.collection.referenceMap.directMap.get(tag.uuid)).to.eql([]) + expect(application.items.collection.referenceMap.inverseMap.get(note.uuid).length).to.equal(0) + }) + + it('deleting referenced item should update referencing item references', async function () { + const note = await createNote() + let tag = await createTag([note]) + await mutator.setItemToBeDeleted(note) + + tag = application.items.findItem(tag.uuid) + expect(tag.content.references.length).to.equal(0) + }) + + it('removing relationship should update reference map', async function () { + const note = await createNote() + const tag = await createTag([note]) + await mutator.changeItem(tag, (mutator) => { + mutator.removeItemAsRelationship(note) + }) + + expect(application.items.collection.referenceMap.directMap.get(tag.uuid)).to.eql([]) + expect(application.items.collection.referenceMap.inverseMap.get(note.uuid)).to.eql([]) + }) + + it('emitting discardable payload should remove it from our collection', async function () { + const note = await createNote() + + const payload = new DeletedPayload({ + ...note.payload.ejected(), + content: undefined, + deleted: true, + dirty: false, + }) + + expect(payload.discardable).to.equal(true) + + await context.payloads.emitPayload(payload) + + expect(application.items.findItem(note.uuid)).to.not.be.ok + }) + + it('change existing item', async function () { + const note = await createNote() + const newTitle = String(Math.random()) + await mutator.changeItem(note, (mutator) => { + mutator.title = newTitle + }) + + const latestVersion = application.items.findItem(note.uuid) + expect(latestVersion.title).to.equal(newTitle) + }) + + it('change non-existant item through uuid should fail', async function () { + const note = await application.items.createTemplateItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + + const changeFn = async () => { + const newTitle = String(Math.random()) + return mutator.changeItem(note, (mutator) => { + mutator.title = newTitle + }) + } + await Factory.expectThrowsAsync(() => changeFn(), 'Attempting to change non-existant item') + }) + + it('set items dirty', async function () { + const note = await createNote() + await mutator.setItemDirty(note) + + const dirtyItems = application.items.getDirtyItems() + expect(dirtyItems.length).to.equal(1) + expect(dirtyItems[0].uuid).to.equal(note.uuid) + expect(dirtyItems[0].dirty).to.equal(true) + }) + + describe('duplicateItem', async function () { + const sandbox = sinon.createSandbox() + + beforeEach(async function () { + this.emitPayloads = sandbox.spy(application.items.payloadManager, 'emitPayloads') + }) + + afterEach(async function () { + sandbox.restore() + }) + + it('should duplicate the item and set the duplicate_of property', async function () { + const note = await createNote() + await mutator.duplicateItem(note) + sinon.assert.calledTwice(this.emitPayloads) + + const originalNote = application.items.getDisplayableNotes()[0] + const duplicatedNote = application.items.getDisplayableNotes()[1] + + expect(application.items.items.length).to.equal(2 + BaseItemCounts.DefaultItems) + expect(application.items.getDisplayableNotes().length).to.equal(2) + expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) + expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) + expect(duplicatedNote.conflictOf).to.be.undefined + expect(duplicatedNote.payload.content.conflict_of).to.be.undefined + }) + + it('should duplicate the item and set the duplicate_of and conflict_of properties', async function () { + const note = await createNote() + await mutator.duplicateItem(note, true) + sinon.assert.calledTwice(this.emitPayloads) + + const originalNote = application.items.getDisplayableNotes()[0] + const duplicatedNote = application.items.getDisplayableNotes()[1] + + expect(application.items.items.length).to.equal(2 + BaseItemCounts.DefaultItems) + expect(application.items.getDisplayableNotes().length).to.equal(2) + expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) + expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) + expect(originalNote.uuid).to.equal(duplicatedNote.conflictOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.content.conflict_of) + }) + + it('duplicate item with relationships', async function () { + const note = await createNote() + const tag = await createTag([note]) + const duplicate = await mutator.duplicateItem(tag) + + expect(duplicate.content.references).to.have.length(1) + expect(application.items.items).to.have.length(3 + BaseItemCounts.DefaultItems) + expect(application.items.getDisplayableTags()).to.have.length(2) + }) + + it('adds duplicated item as a relationship to items referencing it', async function () { + const note = await createNote() + let tag = await createTag([note]) + const duplicateNote = await mutator.duplicateItem(note) + expect(tag.content.references).to.have.length(1) + + tag = application.items.findItem(tag.uuid) + const references = tag.content.references.map((ref) => ref.uuid) + expect(references).to.have.length(2) + expect(references).to.include(note.uuid, duplicateNote.uuid) + }) + + it('duplicates item with additional content', async function () { + const note = await mutator.createItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + const duplicateNote = await mutator.duplicateItem(note, false, { + title: 'hello (copy)', + }) + + expect(duplicateNote.title).to.equal('hello (copy)') + expect(duplicateNote.text).to.equal('world') + }) + }) + + it('set item deleted', async function () { + const note = await createNote() + await mutator.setItemToBeDeleted(note) + + /** Items should never be mutated directly */ + expect(note.deleted).to.not.be.ok + + const latestVersion = context.payloads.findOne(note.uuid) + expect(latestVersion.deleted).to.equal(true) + expect(latestVersion.dirty).to.equal(true) + expect(latestVersion.content).to.not.be.ok + + /** Deleted items do not show up in item manager's public interface */ + expect(application.items.items.length).to.equal(BaseItemCounts.DefaultItems) + expect(application.items.getDisplayableNotes().length).to.equal(0) + }) + + it('should empty trash', async function () { + const note = await createNote() + const versionTwo = await mutator.changeItem(note, (mutator) => { + mutator.trashed = true + }) + + expect(application.items.trashSmartView).to.be.ok + expect(versionTwo.trashed).to.equal(true) + expect(versionTwo.dirty).to.equal(true) + expect(versionTwo.content).to.be.ok + + expect(application.items.items.length).to.equal(1 + BaseItemCounts.DefaultItems) + expect(application.items.trashedItems.length).to.equal(1) + + await application.mutator.emptyTrash() + const versionThree = context.payloads.findOne(note.uuid) + expect(versionThree.deleted).to.equal(true) + expect(application.items.trashedItems.length).to.equal(0) + }) +}) diff --git a/packages/snjs/mocha/note_display_criteria.test.js b/packages/snjs/mocha/note_display_criteria.test.js index 750bef622..dfb384605 100644 --- a/packages/snjs/mocha/note_display_criteria.test.js +++ b/packages/snjs/mocha/note_display_criteria.test.js @@ -6,9 +6,10 @@ describe('note display criteria', function () { beforeEach(async function () { this.payloadManager = new PayloadManager() this.itemManager = new ItemManager(this.payloadManager) + this.mutator = new MutatorService(this.itemManager, this.payloadManager) this.createNote = async (title = 'hello', text = 'world') => { - return this.itemManager.createItem(ContentType.Note, { + return this.mutator.createItem(ContentType.Note, { title: title, text: text, }) @@ -21,7 +22,7 @@ describe('note display criteria', function () { content_type: note.content_type, } }) - return this.itemManager.createItem(ContentType.Tag, { + return this.mutator.createItem(ContentType.Tag, { title: title, references: references, }) @@ -31,138 +32,168 @@ describe('note display criteria', function () { it('includePinned off', async function () { await this.createNote() const pendingPin = await this.createNote() - await this.itemManager.changeItem(pendingPin, (mutator) => { + await this.mutator.changeItem(pendingPin, (mutator) => { mutator.pinned = true }) const criteria = { includePinned: false, } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(1) }) it('includePinned on', async function () { await this.createNote() const pendingPin = await this.createNote() - await this.itemManager.changeItem(pendingPin, (mutator) => { + await this.mutator.changeItem(pendingPin, (mutator) => { mutator.pinned = true }) const criteria = { includePinned: true } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(2) }) it('includeTrashed off', async function () { await this.createNote() const pendingTrash = await this.createNote() - await this.itemManager.changeItem(pendingTrash, (mutator) => { + await this.mutator.changeItem(pendingTrash, (mutator) => { mutator.trashed = true }) const criteria = { includeTrashed: false } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(1) }) it('includeTrashed on', async function () { await this.createNote() const pendingTrash = await this.createNote() - await this.itemManager.changeItem(pendingTrash, (mutator) => { + await this.mutator.changeItem(pendingTrash, (mutator) => { mutator.trashed = true }) const criteria = { includeTrashed: true } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(2) }) it('includeArchived off', async function () { await this.createNote() const pendingArchive = await this.createNote() - await this.itemManager.changeItem(pendingArchive, (mutator) => { + await this.mutator.changeItem(pendingArchive, (mutator) => { mutator.archived = true }) const criteria = { includeArchived: false } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(1) }) it('includeArchived on', async function () { await this.createNote() const pendingArchive = await this.createNote() - await this.itemManager.changeItem(pendingArchive, (mutator) => { + await this.mutator.changeItem(pendingArchive, (mutator) => { mutator.archived = true }) const criteria = { includeArchived: true, } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(2) }) it('includeProtected off', async function () { await this.createNote() const pendingProtected = await this.createNote() - await this.itemManager.changeItem(pendingProtected, (mutator) => { + await this.mutator.changeItem(pendingProtected, (mutator) => { mutator.protected = true }) const criteria = { includeProtected: false } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(1) }) it('includeProtected on', async function () { await this.createNote() const pendingProtected = await this.createNote() - await this.itemManager.changeItem(pendingProtected, (mutator) => { + await this.mutator.changeItem(pendingProtected, (mutator) => { mutator.protected = true }) const criteria = { includeProtected: true, } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(2) }) it('protectedSearchEnabled false', async function () { const normal = await this.createNote('hello', 'world') - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.protected = true }) const criteria = { searchQuery: { query: 'world', includeProtectedNoteText: false }, } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(0) }) it('protectedSearchEnabled true', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.protected = true }) const criteria = { searchQuery: { query: 'world', includeProtectedNoteText: true }, } expect( - itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) - .length, + notesAndFilesMatchingOptions( + criteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, ).to.equal(1) }) @@ -175,7 +206,7 @@ describe('note display criteria', function () { tags: [tag], } expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( matchingCriteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection, @@ -186,7 +217,7 @@ describe('note display criteria', function () { tags: [looseTag], } expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( nonmatchingCriteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection, @@ -198,7 +229,7 @@ describe('note display criteria', function () { it('normal note', async function () { await this.createNote() expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], }, @@ -208,7 +239,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -218,7 +249,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], }, @@ -230,12 +261,12 @@ describe('note display criteria', function () { it('trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeTrashed: false, @@ -246,7 +277,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -256,7 +287,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], }, @@ -268,12 +299,12 @@ describe('note display criteria', function () { it('archived note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = false mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: false, @@ -284,7 +315,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -294,7 +325,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], }, @@ -306,13 +337,13 @@ describe('note display criteria', function () { it('archived + trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], }, @@ -322,7 +353,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -332,7 +363,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], }, @@ -348,7 +379,7 @@ describe('note display criteria', function () { await this.createNote() expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeTrashed: true, @@ -359,7 +390,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeTrashed: true, @@ -373,12 +404,12 @@ describe('note display criteria', function () { it('trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeTrashed: false, @@ -389,7 +420,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeTrashed: true, @@ -400,7 +431,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeTrashed: true, @@ -411,7 +442,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], includeTrashed: true, @@ -425,13 +456,13 @@ describe('note display criteria', function () { it('archived + trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], }, @@ -441,7 +472,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -451,7 +482,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], }, @@ -467,7 +498,7 @@ describe('note display criteria', function () { await this.createNote() expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: true, @@ -478,7 +509,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeArchived: true, @@ -491,12 +522,12 @@ describe('note display criteria', function () { it('archived note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: false, @@ -507,7 +538,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: true, @@ -518,7 +549,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeArchived: true, @@ -529,7 +560,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], includeArchived: false, @@ -542,13 +573,13 @@ describe('note display criteria', function () { it('archived + trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: true, @@ -559,7 +590,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeArchived: true, @@ -570,7 +601,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], includeArchived: true, @@ -587,7 +618,7 @@ describe('note display criteria', function () { await this.createNote() expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [ this.itemManager.allNotesSmartView, @@ -601,7 +632,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], }, @@ -613,12 +644,12 @@ describe('note display criteria', function () { it('archived note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: false, @@ -629,7 +660,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: true, @@ -640,7 +671,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeArchived: true, @@ -651,7 +682,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], includeArchived: false, @@ -664,13 +695,13 @@ describe('note display criteria', function () { it('archived + trashed note', async function () { const normal = await this.createNote() - await this.itemManager.changeItem(normal, (mutator) => { + await this.mutator.changeItem(normal, (mutator) => { mutator.trashed = true mutator.archived = true }) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.allNotesSmartView], includeArchived: true, @@ -681,7 +712,7 @@ describe('note display criteria', function () { ).to.equal(0) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.trashSmartView], includeArchived: true, @@ -692,7 +723,7 @@ describe('note display criteria', function () { ).to.equal(1) expect( - itemsMatchingOptions( + notesAndFilesMatchingOptions( { views: [this.itemManager.archivedSmartView], includeArchived: true, diff --git a/packages/snjs/mocha/protection.test.js b/packages/snjs/mocha/protection.test.js index afcfbf2f3..d84ededbf 100644 --- a/packages/snjs/mocha/protection.test.js +++ b/packages/snjs/mocha/protection.test.js @@ -48,7 +48,7 @@ describe('protections', function () { }) let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) + note = await application.protections.protectNote(note) expect(await application.authorizeNoteAccess(note)).to.be.true expect(challengePrompts).to.equal(1) @@ -57,7 +57,7 @@ describe('protections', function () { it('sets `note.protected` to true', async function () { application = await Factory.createInitAppWithFakeCrypto() let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) + note = await application.protections.protectNote(note) expect(note.protected).to.be.true }) @@ -87,7 +87,7 @@ describe('protections', function () { await application.addPasscode(passcode) let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) + note = await application.protections.protectNote(note) expect(await application.authorizeNoteAccess(note)).to.be.true expect(challengePrompts).to.equal(1) @@ -120,8 +120,8 @@ describe('protections', function () { await application.addPasscode(passcode) let note = await Factory.createMappedNote(application) const uuid = note.uuid - note = await application.mutator.protectNote(note) - note = await application.mutator.unprotectNote(note) + note = await application.protections.protectNote(note) + note = await application.protections.unprotectNote(note) expect(note.uuid).to.equal(uuid) expect(note.protected).to.equal(false) expect(challengePrompts).to.equal(1) @@ -142,8 +142,8 @@ describe('protections', function () { await application.addPasscode(passcode) let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) - const result = await application.mutator.unprotectNote(note) + note = await application.protections.protectNote(note) + const result = await application.protections.unprotectNote(note) expect(result).to.be.undefined expect(challengePrompts).to.equal(1) }) @@ -174,7 +174,7 @@ describe('protections', function () { await application.addPasscode(passcode) let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) + note = await application.protections.protectNote(note) expect(await application.authorizeNoteAccess(note)).to.be.true expect(await application.authorizeNoteAccess(note)).to.be.true @@ -226,7 +226,7 @@ describe('protections', function () { application = await Factory.createInitAppWithFakeCrypto() let note = await Factory.createMappedNote(application) - note = await application.mutator.protectNote(note) + note = await application.protections.protectNote(note) expect(await application.authorizeNoteAccess(note)).to.be.true }) @@ -431,8 +431,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes[0] = await application.mutator.protectNote(notes[0]) - notes[1] = await application.mutator.protectNote(notes[1]) + notes[0] = await application.protections.protectNote(notes[0]) + notes[1] = await application.protections.protectNote(notes[1]) expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf( NOTE_COUNT, @@ -468,8 +468,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes[0] = await application.mutator.protectNote(notes[0]) - notes[1] = await application.mutator.protectNote(notes[1]) + notes[0] = await application.protections.protectNote(notes[0]) + notes[1] = await application.protections.protectNote(notes[1]) expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf( NOTE_COUNT, @@ -493,8 +493,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes[0] = await application.mutator.protectNote(notes[0]) - notes[1] = await application.mutator.protectNote(notes[1]) + notes[0] = await application.protections.protectNote(notes[0]) + notes[1] = await application.protections.protectNote(notes[1]) expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf(1) expect(challengePrompts).to.equal(1) @@ -513,7 +513,7 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes = await application.mutator.protectNotes(notes) + notes = await application.protections.protectNotes(notes) for (const note of notes) { expect(note.protected).to.be.true @@ -550,8 +550,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes = await application.mutator.protectNotes(notes) - notes = await application.mutator.unprotectNotes(notes) + notes = await application.protections.protectNotes(notes) + notes = await application.protections.unprotectNotes(notes) for (const note of notes) { expect(note.protected).to.be.false @@ -587,8 +587,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes = await application.mutator.protectNotes(notes) - notes = await application.mutator.unprotectNotes(notes) + notes = await application.protections.protectNotes(notes) + notes = await application.protections.unprotectNotes(notes) for (const note of notes) { expect(note.protected).to.be.false @@ -612,8 +612,8 @@ describe('protections', function () { const NOTE_COUNT = 3 let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) - notes = await application.mutator.protectNotes(notes) - notes = await application.mutator.unprotectNotes(notes) + notes = await application.protections.protectNotes(notes) + notes = await application.protections.unprotectNotes(notes) for (const note of notes) { expect(note.protected).to.be(true) diff --git a/packages/snjs/mocha/session.test.js b/packages/snjs/mocha/session.test.js index b1e9d51c3..e96ffd275 100644 --- a/packages/snjs/mocha/session.test.js +++ b/packages/snjs/mocha/session.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' import WebDeviceInterface from './lib/web_device_interface.js' chai.use(chaiAsPromised) diff --git a/packages/snjs/mocha/settings.test.js b/packages/snjs/mocha/settings.test.js index cb8feae08..6e1f89880 100644 --- a/packages/snjs/mocha/settings.test.js +++ b/packages/snjs/mocha/settings.test.js @@ -1,5 +1,6 @@ import * as Factory from './lib/factory.js' import * as Files from './lib/Files.js' +import * as Events from './lib/Events.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -98,26 +99,43 @@ describe('settings service', function () { }) it('reads a nonexistent sensitive setting', async () => { - const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.create(SettingName.NAMES.MfaSecret).getValue()) + const setting = await application.settings.getDoesSensitiveSettingExist( + SettingName.create(SettingName.NAMES.MfaSecret).getValue(), + ) expect(setting).to.equal(false) }) it('creates and reads a sensitive setting', async () => { - await application.settings.updateSetting(SettingName.create(SettingName.NAMES.MfaSecret).getValue(), 'fake_secret', true) - const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.create(SettingName.NAMES.MfaSecret).getValue()) + await application.settings.updateSetting( + SettingName.create(SettingName.NAMES.MfaSecret).getValue(), + 'fake_secret', + true, + ) + const setting = await application.settings.getDoesSensitiveSettingExist( + SettingName.create(SettingName.NAMES.MfaSecret).getValue(), + ) expect(setting).to.equal(true) }) it('creates and lists a sensitive setting', async () => { - await application.settings.updateSetting(SettingName.create(SettingName.NAMES.MfaSecret).getValue(), 'fake_secret', true) - await application.settings.updateSetting(SettingName.create(SettingName.NAMES.MuteFailedBackupsEmails).getValue(), MuteFailedBackupsEmailsOption.Muted) + await application.settings.updateSetting( + SettingName.create(SettingName.NAMES.MfaSecret).getValue(), + 'fake_secret', + true, + ) + await application.settings.updateSetting( + SettingName.create(SettingName.NAMES.MuteFailedBackupsEmails).getValue(), + MuteFailedBackupsEmailsOption.Muted, + ) const settings = await application.settings.listSettings() - expect(settings.getSettingValue(SettingName.create(SettingName.NAMES.MuteFailedBackupsEmails).getValue())).to.eql(MuteFailedBackupsEmailsOption.Muted) + expect(settings.getSettingValue(SettingName.create(SettingName.NAMES.MuteFailedBackupsEmails).getValue())).to.eql( + MuteFailedBackupsEmailsOption.Muted, + ) expect(settings.getSettingValue(SettingName.create(SettingName.NAMES.MfaSecret).getValue())).to.not.be.ok }) it('reads a subscription setting - @paidfeature', async () => { - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { userEmail: context.email, subscriptionId: subscriptionId++, subscriptionName: 'PRO_PLAN', @@ -130,19 +148,21 @@ describe('settings service', function () { totalActiveSubscriptionsCount: 1, userRegisteredAt: 1, billingFrequency: 12, - payAmount: 59.00 + payAmount: 59.0, }) await Factory.sleep(2) - const setting = await application.settings.getSubscriptionSetting(SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue()) + const setting = await application.settings.getSubscriptionSetting( + SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(), + ) expect(setting).to.be.a('string') }) it('persist irreplaceable subscription settings between subsequent subscriptions - @paidfeature', async () => { await reInitializeApplicationWithRealCrypto() - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { userEmail: context.email, subscriptionId: subscriptionId, subscriptionName: 'PRO_PLAN', @@ -155,7 +175,7 @@ describe('settings service', function () { totalActiveSubscriptionsCount: 1, userRegisteredAt: 1, billingFrequency: 12, - payAmount: 59.00 + payAmount: 59.0, }) await Factory.sleep(1) @@ -166,13 +186,17 @@ describe('settings service', function () { await Factory.sleep(1) - const limitSettingBefore = await application.settings.getSubscriptionSetting(SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue()) + const limitSettingBefore = await application.settings.getSubscriptionSetting( + SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(), + ) expect(limitSettingBefore).to.equal('107374182400') - const usedSettingBefore = await application.settings.getSubscriptionSetting(SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue()) + const usedSettingBefore = await application.settings.getSubscriptionSetting( + SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue(), + ) expect(usedSettingBefore).to.equal('196') - await Factory.publishMockedEvent('SUBSCRIPTION_EXPIRED', { + await Events.publishMockedEvent('SUBSCRIPTION_EXPIRED', { userEmail: context.email, subscriptionId: subscriptionId++, subscriptionName: 'PRO_PLAN', @@ -181,11 +205,11 @@ describe('settings service', function () { totalActiveSubscriptionsCount: 1, userExistingSubscriptionsCount: 1, billingFrequency: 12, - payAmount: 59.00 + payAmount: 59.0, }) await Factory.sleep(1) - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { userEmail: context.email, subscriptionId: subscriptionId++, subscriptionName: 'PRO_PLAN', @@ -198,14 +222,18 @@ describe('settings service', function () { totalActiveSubscriptionsCount: 2, userRegisteredAt: 1, billingFrequency: 12, - payAmount: 59.00 + payAmount: 59.0, }) await Factory.sleep(1) - const limitSettingAfter = await application.settings.getSubscriptionSetting(SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue()) + const limitSettingAfter = await application.settings.getSubscriptionSetting( + SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(), + ) expect(limitSettingAfter).to.equal(limitSettingBefore) - const usedSettingAfter = await application.settings.getSubscriptionSetting(SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue()) + const usedSettingAfter = await application.settings.getSubscriptionSetting( + SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue(), + ) expect(usedSettingAfter).to.equal(usedSettingBefore) }) }) diff --git a/packages/snjs/mocha/singletons.test.js b/packages/snjs/mocha/singletons.test.js index e5a1c8d9c..3bd340ceb 100644 --- a/packages/snjs/mocha/singletons.test.js +++ b/packages/snjs/mocha/singletons.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' import WebDeviceInterface from './lib/web_device_interface.js' chai.use(chaiAsPromised) @@ -38,7 +38,9 @@ describe('singletons', function () { this.email = UuidGenerator.GenerateUuid() this.password = UuidGenerator.GenerateUuid() + this.registerUser = async () => { + this.expectedItemCount = BaseItemCounts.DefaultItemsWithAccount await Factory.registerUserToApplication({ application: this.application, email: this.email, @@ -62,7 +64,7 @@ describe('singletons', function () { ]) this.createExtMgr = () => { - return this.application.itemManager.createItem( + return this.application.mutator.createItem( ContentType.Component, { package_info: { @@ -93,11 +95,11 @@ describe('singletons', function () { const prefs2 = createPrefsPayload() const prefs3 = createPrefsPayload() - const items = await this.application.itemManager.emitItemsFromPayloads( + const items = await this.application.mutator.emitItemsFromPayloads( [prefs1, prefs2, prefs3], PayloadEmitSource.LocalChanged, ) - await this.application.itemManager.setItemsDirty(items) + await this.application.mutator.setItemsDirty(items) await this.application.syncService.sync(syncOptions) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) }) @@ -192,7 +194,7 @@ describe('singletons', function () { if (!beginCheckingResponse) { return } - if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + if (!didCompleteRelevantSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) { didCompleteRelevantSync = true const saved = data.savedPayloads expect(saved.length).to.equal(1) @@ -327,7 +329,7 @@ describe('singletons', function () { it('alternating the uuid of a singleton should return correct result', async function () { const payload = createPrefsPayload() - const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) await this.application.syncService.sync(syncOptions) const predicate = new Predicate('content_type', '=', item.content_type) let resolvedItem = await this.application.singletonManager.findOrCreateContentTypeSingleton( diff --git a/packages/snjs/mocha/storage.test.js b/packages/snjs/mocha/storage.test.js index 1a3a39bc6..9cd3b091d 100644 --- a/packages/snjs/mocha/storage.test.js +++ b/packages/snjs/mocha/storage.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from './lib/Applications.js' +import { BaseItemCounts } from './lib/BaseItemCounts.js' import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -279,7 +279,7 @@ describe('storage manager', function () { }) await Factory.createSyncedNote(this.application) - expect(await Factory.storagePayloadCount(this.application)).to.equal(BaseItemCounts.DefaultItems + 1) + expect(await Factory.storagePayloadCount(this.application)).to.equal(BaseItemCounts.DefaultItemsWithAccount + 1) this.application = await Factory.signOutApplicationAndReturnNew(this.application) await Factory.sleep(0.1, 'Allow all untrackable singleton syncs to complete') expect(await Factory.storagePayloadCount(this.application)).to.equal(BaseItemCounts.DefaultItems) diff --git a/packages/snjs/mocha/subscriptions.test.js b/packages/snjs/mocha/subscriptions.test.js index d787bafc2..bf3cf396d 100644 --- a/packages/snjs/mocha/subscriptions.test.js +++ b/packages/snjs/mocha/subscriptions.test.js @@ -1,4 +1,5 @@ import * as Factory from './lib/factory.js' +import * as Events from './lib/Events.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -31,7 +32,7 @@ describe('subscriptions', function () { password: context.password, }) - await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + await Events.publishMockedEvent('SUBSCRIPTION_PURCHASED', { userEmail: context.email, subscriptionId: subscriptionId++, subscriptionName: 'PRO_PLAN', diff --git a/packages/snjs/mocha/sync_tests/conflicting.test.js b/packages/snjs/mocha/sync_tests/conflicting.test.js index 1c5c3817a..202894423 100644 --- a/packages/snjs/mocha/sync_tests/conflicting.test.js +++ b/packages/snjs/mocha/sync_tests/conflicting.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' import { createSyncedNoteWithTag } from '../lib/Items.js' import * as Utils from '../lib/Utils.js' @@ -16,7 +16,7 @@ describe('online conflict handling', function () { beforeEach(async function () { localStorage.clear() - this.expectedItemCount = BaseItemCounts.DefaultItems + this.expectedItemCount = BaseItemCounts.DefaultItemsWithAccount this.context = await Factory.createAppContextWithFakeCrypto('AppA') await this.context.launch() @@ -64,7 +64,7 @@ describe('online conflict handling', function () { it('components should not be duplicated under any circumstances', async function () { const payload = createDirtyPayload(ContentType.Component) - const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) this.expectedItemCount++ @@ -91,7 +91,7 @@ describe('online conflict handling', function () { it('items keys should not be duplicated under any circumstances', async function () { const payload = createDirtyPayload(ContentType.ItemsKey) - const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const item = await this.application.mutator.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) this.expectedItemCount++ await this.application.syncService.sync(syncOptions) /** First modify the item without saving so that @@ -118,7 +118,7 @@ describe('online conflict handling', function () { // create an item and sync it const note = await Factory.createMappedNote(this.application) this.expectedItemCount++ - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() @@ -128,11 +128,11 @@ describe('online conflict handling', function () { const dirtyValue = `${Math.random()}` /** Modify nonsense first to get around strategyWhenConflictingWithItem with previousRevision check */ - await this.application.itemManager.changeNote(note, (mutator) => { + await this.application.mutator.changeNote(note, (mutator) => { mutator.title = 'any' }) - await this.application.itemManager.changeNote(note, (mutator) => { + await this.application.mutator.changeNote(note, (mutator) => { // modify this item locally to have differing contents from server mutator.title = dirtyValue // Intentionally don't change updated_at. We want to simulate a chaotic case where @@ -238,7 +238,7 @@ describe('online conflict handling', function () { it('should duplicate item if saving a modified item and clearing our sync token', async function () { let note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) this.expectedItemCount++ @@ -279,11 +279,11 @@ describe('online conflict handling', function () { it('should handle sync conflicts by not duplicating same data', async function () { const note = await Factory.createMappedNote(this.application) this.expectedItemCount++ - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) // keep item as is and set dirty - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) // clear sync token so that all items are retrieved on next sync this.application.syncService.clearSyncPositionTokens() @@ -295,10 +295,10 @@ describe('online conflict handling', function () { it('clearing conflict_of on two clients simultaneously should keep us in sync', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( note, (mutator) => { // client A @@ -311,7 +311,7 @@ describe('online conflict handling', function () { // client B await this.application.syncService.clearSyncPositionTokens() - await this.application.itemManager.changeItem( + await this.application.mutator.changeItem( note, (mutator) => { mutator.mutableContent.conflict_of = 'bar' @@ -329,10 +329,10 @@ describe('online conflict handling', function () { it('setting property on two clients simultaneously should create conflict', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( note, (mutator) => { // client A @@ -369,12 +369,12 @@ describe('online conflict handling', function () { const note = await Factory.createMappedNote(this.application) const originalPayload = note.payloadRepresentation() this.expectedItemCount++ - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) // client A - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) await this.application.syncService.sync(syncOptions) this.expectedItemCount-- expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) @@ -387,10 +387,10 @@ describe('online conflict handling', function () { deleted: false, updated_at: Factory.yesterday(), }) - await this.application.itemManager.emitItemsFromPayloads([mutatedPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([mutatedPayload], PayloadEmitSource.LocalChanged) const resultNote = this.application.itemManager.findItem(note.uuid) expect(resultNote.uuid).to.equal(note.uuid) - await this.application.itemManager.setItemDirty(resultNote) + await this.application.mutator.setItemDirty(resultNote) await this.application.syncService.sync(syncOptions) // We expect that this item is now gone for good, and a duplicate has not been created. @@ -400,7 +400,7 @@ describe('online conflict handling', function () { it('if server says not deleted but client says deleted, keep server state', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ // client A @@ -426,7 +426,7 @@ describe('online conflict handling', function () { it('should create conflict if syncing an item that is stale', async function () { let note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) note = this.application.items.findItem(note.uuid) expect(note.dirty).to.equal(false) @@ -462,7 +462,7 @@ describe('online conflict handling', function () { it('creating conflict with exactly equal content should keep us in sync', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ await this.application.syncService.sync(syncOptions) @@ -505,7 +505,7 @@ describe('online conflict handling', function () { for (const note of this.application.itemManager.getDisplayableNotes()) { /** First modify the item without saving so that * our local contents digress from the server's */ - await this.application.itemManager.changeItem(note, (mutator) => { + await this.application.mutator.changeItem(note, (mutator) => { mutator.text = '1' }) @@ -530,18 +530,18 @@ describe('online conflict handling', function () { const payload1 = Factory.createStorageItemPayload(ContentType.Tag) const payload2 = Factory.createStorageItemPayload(ContentType.UserPrefs) this.expectedItemCount -= 1 /** auto-created user preferences */ - await this.application.itemManager.emitItemsFromPayloads([payload1, payload2], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([payload1, payload2], PayloadEmitSource.LocalChanged) this.expectedItemCount += 2 let tag = this.application.itemManager.getItems(ContentType.Tag)[0] let userPrefs = this.application.itemManager.getItems(ContentType.UserPrefs)[0] expect(tag).to.be.ok expect(userPrefs).to.be.ok - tag = await this.application.itemManager.changeItem(tag, (mutator) => { + tag = await this.application.mutator.changeItem(tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(userPrefs) }) - await this.application.itemManager.setItemDirty(userPrefs) + await this.application.mutator.setItemDirty(userPrefs) userPrefs = this.application.items.findItem(userPrefs.uuid) expect(this.application.itemManager.itemsReferencingItem(userPrefs).length).to.equal(1) @@ -599,7 +599,7 @@ describe('online conflict handling', function () { */ let tag = await Factory.createMappedTag(this.application) let note = await Factory.createMappedNote(this.application) - tag = await this.application.mutator.changeAndSaveItem( + tag = await this.application.changeAndSaveItem( tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note) @@ -608,7 +608,7 @@ describe('online conflict handling', function () { undefined, syncOptions, ) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount += 2 await this.application.syncService.sync(syncOptions) @@ -663,18 +663,18 @@ describe('online conflict handling', function () { const baseTitle = 'base title' /** Change the note */ - const noteAfterChange = await this.application.itemManager.changeItem(note, (mutator) => { + const noteAfterChange = await this.application.mutator.changeItem(note, (mutator) => { mutator.title = baseTitle }) await this.application.sync.sync() /** Simulate a dropped response by reverting the note back its post-change, pre-sync state */ - const retroNote = await this.application.itemManager.emitItemFromPayload(noteAfterChange.payload) + const retroNote = await this.application.mutator.emitItemFromPayload(noteAfterChange.payload) expect(retroNote.serverUpdatedAt.getTime()).to.equal(noteAfterChange.serverUpdatedAt.getTime()) /** Change the item to its final title and sync */ const finalTitle = 'final title' - await this.application.itemManager.changeItem(note, (mutator) => { + await this.application.mutator.changeItem(note, (mutator) => { mutator.title = finalTitle }) await this.application.sync.sync() @@ -708,7 +708,7 @@ describe('online conflict handling', function () { errorDecrypting: true, dirty: true, }) - await this.application.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) /** * Retrieve this note from the server by clearing sync token @@ -758,7 +758,7 @@ describe('online conflict handling', function () { email: Utils.generateUuid(), password: Utils.generateUuid(), }) - await newApp.itemManager.emitItemsFromPayloads(priorData.map((i) => i.payload)) + await newApp.mutator.emitItemsFromPayloads(priorData.map((i) => i.payload)) await newApp.syncService.markAllItemsAsNeedingSyncAndPersist() await newApp.syncService.sync(syncOptions) expect(newApp.payloadManager.invalidPayloads.length).to.equal(0) @@ -786,7 +786,7 @@ describe('online conflict handling', function () { password: password, }) Factory.handlePasswordChallenges(newApp, password) - await newApp.mutator.importData(backupFile, true) + await newApp.importData(backupFile, true) expect(newApp.itemManager.getDisplayableTags().length).to.equal(1) expect(newApp.itemManager.getDisplayableNotes().length).to.equal(1) await Factory.safeDeinit(newApp) @@ -801,7 +801,7 @@ describe('online conflict handling', function () { await createSyncedNoteWithTag(this.application) const tag = this.application.itemManager.getDisplayableTags()[0] const note2 = await Factory.createMappedNote(this.application) - await this.application.mutator.changeAndSaveItem(tag, (mutator) => { + await this.application.changeAndSaveItem(tag, (mutator) => { mutator.e2ePendingRefactor_addItemAsRelationship(note2) }) let backupFile = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() @@ -821,7 +821,7 @@ describe('online conflict handling', function () { password: password, }) Factory.handlePasswordChallenges(newApp, password) - await newApp.mutator.importData(backupFile, true) + await newApp.importData(backupFile, true) const newTag = newApp.itemManager.getDisplayableTags()[0] const notes = newApp.items.referencesForItem(newTag) expect(notes.length).to.equal(2) @@ -855,7 +855,7 @@ describe('online conflict handling', function () { }, dirty: true, }) - await this.application.itemManager.emitItemFromPayload(modified) + await this.application.mutator.emitItemFromPayload(modified) await this.application.sync.sync() expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) await this.sharedFinalAssertions() @@ -879,7 +879,7 @@ describe('online conflict handling', function () { dirty: true, }) this.expectedItemCount++ - await this.application.itemManager.emitItemFromPayload(modified) + await this.application.mutator.emitItemFromPayload(modified) await this.application.sync.sync() expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) await this.sharedFinalAssertions() @@ -911,7 +911,7 @@ describe('online conflict handling', function () { dirty: true, }) this.expectedItemCount++ - await this.application.itemManager.emitItemFromPayload(modified) + await this.application.mutator.emitItemFromPayload(modified) await this.application.sync.sync() expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) await this.sharedFinalAssertions() diff --git a/packages/snjs/mocha/sync_tests/integrity.test.js b/packages/snjs/mocha/sync_tests/integrity.test.js index d91339f96..a81be23d2 100644 --- a/packages/snjs/mocha/sync_tests/integrity.test.js +++ b/packages/snjs/mocha/sync_tests/integrity.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -15,7 +15,7 @@ describe('sync integrity', () => { }) beforeEach(async function () { - this.expectedItemCount = BaseItemCounts.DefaultItems + this.expectedItemCount = BaseItemCounts.DefaultItemsWithAccount this.application = await Factory.createInitAppWithFakeCrypto() this.email = UuidGenerator.GenerateUuid() this.password = UuidGenerator.GenerateUuid() @@ -44,7 +44,7 @@ describe('sync integrity', () => { }) it('should detect when out of sync', async function () { - const item = await this.application.itemManager.emitItemFromPayload( + const item = await this.application.mutator.emitItemFromPayload( Factory.createNotePayload(), PayloadEmitSource.LocalChanged, ) @@ -60,7 +60,7 @@ describe('sync integrity', () => { }) it('should self heal after out of sync', async function () { - const item = await this.application.itemManager.emitItemFromPayload( + const item = await this.application.mutator.emitItemFromPayload( Factory.createNotePayload(), PayloadEmitSource.LocalChanged, ) diff --git a/packages/snjs/mocha/sync_tests/notes_tags.test.js b/packages/snjs/mocha/sync_tests/notes_tags.test.js index f1219a749..0dd7d584a 100644 --- a/packages/snjs/mocha/sync_tests/notes_tags.test.js +++ b/packages/snjs/mocha/sync_tests/notes_tags.test.js @@ -33,7 +33,7 @@ describe('notes + tags syncing', function () { it('syncing an item then downloading it should include items_key_id', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) await this.application.payloadManager.resetState() await this.application.itemManager.resetState() @@ -52,14 +52,14 @@ describe('notes + tags syncing', function () { const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) const note = this.application.itemManager.getItems([ContentType.Note])[0] const tag = this.application.itemManager.getItems([ContentType.Tag])[0] expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) for (let i = 0; i < 9; i++) { - await this.application.itemManager.setItemsDirty([note, tag]) + await this.application.mutator.setItemsDirty([note, tag]) await this.application.syncService.sync(syncOptions) this.application.syncService.clearSyncPositionTokens() expect(tag.content.references.length).to.equal(1) @@ -76,10 +76,10 @@ describe('notes + tags syncing', function () { const pair = createRelatedNoteTagPairPayload() const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) const originalNote = this.application.itemManager.getDisplayableNotes()[0] const originalTag = this.application.itemManager.getDisplayableTags()[0] - await this.application.itemManager.setItemsDirty([originalNote, originalTag]) + await this.application.mutator.setItemsDirty([originalNote, originalTag]) await this.application.syncService.sync(syncOptions) @@ -109,12 +109,12 @@ describe('notes + tags syncing', function () { const pair = createRelatedNoteTagPairPayload() const notePayload = pair[0] const tagPayload = pair[1] - await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) let note = this.application.itemManager.getDisplayableNotes()[0] let tag = this.application.itemManager.getDisplayableTags()[0] expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) - await this.application.itemManager.setItemsDirty([note, tag]) + await this.application.mutator.setItemsDirty([note, tag]) await this.application.syncService.sync(syncOptions) await this.application.syncService.clearSyncPositionTokens() diff --git a/packages/snjs/mocha/sync_tests/offline.test.js b/packages/snjs/mocha/sync_tests/offline.test.js index 25cf65028..c5ca32cea 100644 --- a/packages/snjs/mocha/sync_tests/offline.test.js +++ b/packages/snjs/mocha/sync_tests/offline.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect @@ -31,6 +31,21 @@ describe('offline syncing', () => { localStorage.clear() }) + it('uuid alternation should delete original payload', async function () { + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + + await Factory.alternateUuidForItem(this.application, note.uuid) + await this.application.sync.sync(syncOptions) + + const notes = this.application.itemManager.getDisplayableNotes() + expect(notes.length).to.equal(1) + expect(notes[0].uuid).to.not.equal(note.uuid) + + const items = this.application.itemManager.allTrackedItems() + expect(items.length).to.equal(this.expectedItemCount) + }) + it('should sync item with no passcode', async function () { let note = await Factory.createMappedNote(this.application) expect(Uuids(this.application.itemManager.getDirtyItems()).includes(note.uuid)) diff --git a/packages/snjs/mocha/sync_tests/online.test.js b/packages/snjs/mocha/sync_tests/online.test.js index 1ab803218..2d902f5cf 100644 --- a/packages/snjs/mocha/sync_tests/online.test.js +++ b/packages/snjs/mocha/sync_tests/online.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -import { BaseItemCounts } from '../lib/Applications.js' +import { BaseItemCounts } from '../lib/BaseItemCounts.js' import * as Factory from '../lib/factory.js' import * as Utils from '../lib/Utils.js' chai.use(chaiAsPromised) @@ -15,7 +15,7 @@ describe('online syncing', function () { beforeEach(async function () { localStorage.clear() - this.expectedItemCount = BaseItemCounts.DefaultItems + this.expectedItemCount = BaseItemCounts.DefaultItemsWithAccount this.context = await Factory.createAppContext() await this.context.launch() @@ -43,8 +43,10 @@ describe('online syncing', function () { afterEach(async function () { expect(this.application.syncService.isOutOfSync()).to.equal(false) + const items = this.application.itemManager.allTrackedItems() expect(items.length).to.equal(this.expectedItemCount) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() expect(rawPayloads.length).to.equal(this.expectedItemCount) await Factory.safeDeinit(this.application) @@ -119,18 +121,6 @@ describe('online syncing', function () { await Factory.sleep(0.5) }).timeout(20000) - it('uuid alternation should delete original payload', async function () { - this.application = await Factory.signOutApplicationAndReturnNew(this.application) - const note = await Factory.createMappedNote(this.application) - this.expectedItemCount++ - await Factory.alternateUuidForItem(this.application, note.uuid) - await this.application.sync.sync(syncOptions) - - const notes = this.application.itemManager.getDisplayableNotes() - expect(notes.length).to.equal(1) - expect(notes[0].uuid).to.not.equal(note.uuid) - }) - it('having offline data then signing in should not alternate uuid and merge with account', async function () { this.application = await Factory.signOutApplicationAndReturnNew(this.application) const note = await Factory.createMappedNote(this.application) @@ -222,7 +212,7 @@ describe('online syncing', function () { this.application = await Factory.signOutApplicationAndReturnNew(this.application) const promise = new Promise((resolve) => { this.application.syncService.addEventObserver(async (event) => { - if (event === SyncEvent.SingleRoundTripSyncCompleted) { + if (event === SyncEvent.PaginatedSyncRequestCompleted) { const note = this.application.items.findItem(originalNote.uuid) if (note) { expect(note.dirty).to.not.be.ok @@ -241,7 +231,7 @@ describe('online syncing', function () { expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) const note = await Factory.createMappedNote(this.application) this.expectedItemCount++ - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() const notePayload = noteObjectsFromObjects(rawPayloads) @@ -283,7 +273,7 @@ describe('online syncing', function () { const originalTitle = note.content.title - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) const encrypted = CreateEncryptedServerSyncPushPayload( @@ -299,7 +289,7 @@ describe('online syncing', function () { errorDecrypting: true, }) - const items = await this.application.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + const items = await this.application.mutator.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) const mappedItem = this.application.itemManager.findAnyItem(errorred.uuid) @@ -311,7 +301,7 @@ describe('online syncing', function () { }, }) - const mappedItems2 = await this.application.itemManager.emitItemsFromPayloads( + const mappedItems2 = await this.application.mutator.emitItemsFromPayloads( [decryptedPayload], PayloadEmitSource.LocalChanged, ) @@ -336,14 +326,14 @@ describe('online syncing', function () { let note = await Factory.createMappedNote(this.application) this.expectedItemCount++ - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) note = this.application.items.findItem(note.uuid) expect(note.dirty).to.equal(false) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) note = this.application.items.findAnyItem(note.uuid) expect(note.dirty).to.equal(true) this.expectedItemCount-- @@ -361,7 +351,7 @@ describe('online syncing', function () { it('retrieving item with no content should correctly map local state', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) const syncToken = await this.application.syncService.getLastSyncToken() @@ -370,7 +360,7 @@ describe('online syncing', function () { expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) // client A - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) await this.application.syncService.sync(syncOptions) // Subtract 1 @@ -399,7 +389,7 @@ describe('online syncing', function () { await Factory.sleep(0.1) - await this.application.itemManager.changeItem(note, (mutator) => { + await this.application.mutator.changeItem(note, (mutator) => { mutator.title = 'latest title' }) @@ -427,7 +417,7 @@ describe('online syncing', function () { await Factory.sleep(0.1) - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) this.expectedItemCount-- @@ -444,8 +434,8 @@ describe('online syncing', function () { it('items that are never synced and deleted should not be uploaded to server', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemDirty(note) + await this.application.mutator.setItemToBeDeleted(note) let success = true let didCompleteRelevantSync = false @@ -457,7 +447,7 @@ describe('online syncing', function () { if (!beginCheckingResponse) { return } - if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + if (!didCompleteRelevantSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) { didCompleteRelevantSync = true const response = data const matching = response.savedPayloads.find((p) => p.uuid === note.uuid) @@ -474,20 +464,20 @@ describe('online syncing', function () { it('items that are deleted after download first sync complete should not be uploaded to server', async function () { /** The singleton manager may delete items are download first. We dont want those uploaded to server. */ const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) let success = true let didCompleteRelevantSync = false let beginCheckingResponse = false this.application.syncService.addEventObserver(async (eventName, data) => { if (eventName === SyncEvent.DownloadFirstSyncCompleted) { - await this.application.itemManager.setItemToBeDeleted(note) + await this.application.mutator.setItemToBeDeleted(note) beginCheckingResponse = true } if (!beginCheckingResponse) { return } - if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + if (!didCompleteRelevantSync && eventName === SyncEvent.PaginatedSyncRequestCompleted) { didCompleteRelevantSync = true const response = data const matching = response.savedPayloads.find((p) => p.uuid === note.uuid) @@ -527,7 +517,7 @@ describe('online syncing', function () { const decryptionResults = await this.application.protocolService.decryptSplit(keyedSplit) - await this.application.itemManager.emitItemsFromPayloads(decryptionResults, PayloadEmitSource.LocalChanged) + await this.application.mutator.emitItemsFromPayloads(decryptionResults, PayloadEmitSource.LocalChanged) expect(this.application.itemManager.allTrackedItems().length).to.equal(this.expectedItemCount) @@ -543,7 +533,7 @@ describe('online syncing', function () { const largeItemCount = SyncUpDownLimit + 10 for (let i = 0; i < largeItemCount; i++) { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) } this.expectedItemCount += largeItemCount @@ -558,7 +548,7 @@ describe('online syncing', function () { const largeItemCount = SyncUpDownLimit + 10 for (let i = 0; i < largeItemCount; i++) { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) } /** Upload */ this.application.syncService.sync({ awaitAll: true, checkIntegrity: false }) @@ -583,7 +573,7 @@ describe('online syncing', function () { it('syncing an item should storage it encrypted', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) await this.application.syncService.sync(syncOptions) this.expectedItemCount++ const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() @@ -593,7 +583,7 @@ describe('online syncing', function () { it('syncing an item before data load should storage it encrypted', async function () { const note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ /** Simulate database not loaded */ @@ -610,7 +600,7 @@ describe('online syncing', function () { it('saving an item after sync should persist it with content property', async function () { const note = await Factory.createMappedNote(this.application) const text = Factory.randomString(10000) - await this.application.mutator.changeAndSaveItem( + await this.application.changeAndSaveItem( note, (mutator) => { mutator.text = text @@ -634,7 +624,7 @@ describe('online syncing', function () { expect(this.application.itemManager.getDirtyItems().length).to.equal(0) let note = await Factory.createMappedNote(this.application) - note = await this.application.itemManager.changeItem(note, (mutator) => { + note = await this.application.mutator.changeItem(note, (mutator) => { mutator.text = `${Math.random()}` }) /** This sync request should exit prematurely as we called ut_setDatabaseNotLoaded */ @@ -705,13 +695,13 @@ describe('online syncing', function () { it('valid sync date tracking', async function () { let note = await Factory.createMappedNote(this.application) - note = await this.application.itemManager.setItemDirty(note) + note = await this.application.mutator.setItemDirty(note) this.expectedItemCount++ expect(note.dirty).to.equal(true) expect(note.payload.dirtyIndex).to.be.at.most(getCurrentDirtyIndex()) - note = await this.application.itemManager.changeItem(note, (mutator) => { + note = await this.application.mutator.changeItem(note, (mutator) => { mutator.text = `${Math.random()}` }) const sync = this.application.sync.sync(syncOptions) @@ -748,7 +738,7 @@ describe('online syncing', function () { * It will do based on comparing whether item.dirtyIndex > item.globalDirtyIndexAtLastSync */ let note = await Factory.createMappedNote(this.application) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) this.expectedItemCount++ // client A. Don't await, we want to do other stuff. @@ -759,12 +749,12 @@ describe('online syncing', function () { // While that sync is going on, we want to modify this item many times. const text = `${Math.random()}` - note = await this.application.itemManager.changeItem(note, (mutator) => { + note = await this.application.mutator.changeItem(note, (mutator) => { mutator.text = text }) - await this.application.itemManager.setItemDirty(note) - await this.application.itemManager.setItemDirty(note) - await this.application.itemManager.setItemDirty(note) + await this.application.mutator.setItemDirty(note) + await this.application.mutator.setItemDirty(note) + await this.application.mutator.setItemDirty(note) expect(note.payload.dirtyIndex).to.be.above(note.payload.globalDirtyIndexAtLastSync) // Now do a regular sync with no latency. @@ -817,7 +807,7 @@ describe('online syncing', function () { setTimeout( async function () { - await this.application.itemManager.changeItem(note, (mutator) => { + await this.application.mutator.changeItem(note, (mutator) => { mutator.text = newText }) }.bind(this), @@ -862,9 +852,9 @@ describe('online syncing', function () { const newText = `${Math.random()}` this.application.syncService.addEventObserver(async (eventName) => { - if (eventName === SyncEvent.SyncWillBegin && !didPerformMutatation) { + if (eventName === SyncEvent.SyncDidBeginProcessing && !didPerformMutatation) { didPerformMutatation = true - await this.application.itemManager.changeItem(note, (mutator) => { + await this.application.mutator.changeItem(note, (mutator) => { mutator.text = newText }) } @@ -898,7 +888,7 @@ describe('online syncing', function () { dirtyIndex: changed[0].payload.globalDirtyIndexAtLastSync + 1, }) - await this.application.itemManager.emitItemFromPayload(mutated) + await this.application.mutator.emitItemFromPayload(mutated) } }) @@ -916,6 +906,7 @@ describe('online syncing', function () { const note = await Factory.createSyncedNote(this.application) const preDeleteSyncToken = await this.application.syncService.getLastSyncToken() await this.application.mutator.deleteItem(note) + await this.application.sync.sync() await this.application.syncService.setLastSyncToken(preDeleteSyncToken) await this.application.sync.sync(syncOptions) expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) @@ -938,7 +929,7 @@ describe('online syncing', function () { dirty: true, }) - await this.application.itemManager.emitItemFromPayload(errored) + await this.application.payloadManager.emitPayload(errored) await this.application.sync.sync(syncOptions) const updatedNote = this.application.items.findAnyItem(note.uuid) @@ -966,7 +957,7 @@ describe('online syncing', function () { }, }) - await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [], options: {} }, response) expect(this.application.payloadManager.findOne(invalidPayload.uuid)).to.not.be.ok expect(this.application.payloadManager.findOne(validPayload.uuid)).to.be.ok @@ -995,7 +986,7 @@ describe('online syncing', function () { content: {}, }) this.expectedItemCount++ - await this.application.itemManager.emitItemsFromPayloads([payload]) + await this.application.mutator.emitItemsFromPayloads([payload]) await this.application.sync.sync(syncOptions) /** Item should no longer be dirty, otherwise it would keep syncing */ @@ -1006,7 +997,7 @@ describe('online syncing', function () { it('should call onPresyncSave before sync begins', async function () { const events = [] this.application.syncService.addEventObserver((event) => { - if (event === SyncEvent.SyncWillBegin) { + if (event === SyncEvent.SyncDidBeginProcessing) { events.push('sync-will-begin') } }) @@ -1032,6 +1023,7 @@ describe('online syncing', function () { const note = await Factory.createSyncedNote(this.application) await this.application.mutator.deleteItem(note) + await this.application.sync.sync() expect(conditionMet).to.equal(true) }) diff --git a/packages/snjs/mocha/test.html b/packages/snjs/mocha/test.html index b67b53353..e722ea517 100644 --- a/packages/snjs/mocha/test.html +++ b/packages/snjs/mocha/test.html @@ -12,14 +12,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -103,4 +81,4 @@

- + \ No newline at end of file diff --git a/packages/snjs/mocha/upgrading.test.js b/packages/snjs/mocha/upgrading.test.js index cbe08696b..b7c6fc059 100644 --- a/packages/snjs/mocha/upgrading.test.js +++ b/packages/snjs/mocha/upgrading.test.js @@ -173,7 +173,7 @@ describe('upgrading', () => { it('protocol version should be upgraded on password change', async function () { /** Delete default items key that is created on launch */ const itemsKey = await this.application.protocolService.getSureDefaultItemsKey() - await this.application.itemManager.setItemToBeDeleted(itemsKey) + await this.application.mutator.setItemToBeDeleted(itemsKey) expect(Uuids(this.application.itemManager.getDisplayableItemsKeys()).includes(itemsKey.uuid)).to.equal(false) Factory.createMappedNote(this.application) diff --git a/packages/snjs/mocha/vaults/asymmetric-messages.test.js b/packages/snjs/mocha/vaults/asymmetric-messages.test.js new file mode 100644 index 000000000..449a675a2 --- /dev/null +++ b/packages/snjs/mocha/vaults/asymmetric-messages.test.js @@ -0,0 +1,277 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('asymmetric messages', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let service + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + service = context.asymmetric + }) + + it('should not trust message if the trusted payload data recipientUuid does not match the message user uuid', async () => { + console.error('TODO: implement') + }) + + it('should delete message after processing it', async () => { + const { contactContext, deinitContactContext } = await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const eventData = { + oldKeyPair: context.encryption.getKeyPair(), + oldSigningKeyPair: context.encryption.getSigningKeyPair(), + newKeyPair: context.encryption.getKeyPair(), + newSigningKeyPair: context.encryption.getSigningKeyPair(), + } + + await service.sendOwnContactChangeEventToAllContacts(eventData) + + const deleteFunction = sinon.spy(contactContext.asymmetric, 'deleteMessageAfterProcessing') + + const promise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + + await contactContext.sync() + + await promise + + expect(deleteFunction.callCount).to.equal(1) + + const messages = await contactContext.asymmetric.getInboundMessages() + expect(messages.length).to.equal(0) + + await deinitContactContext() + }) + + it('should send contact share message after trusted contact belonging to group changes', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const { thirdPartyContext, deinitThirdPartyContext } = await Collaboration.inviteNewPartyToSharedVault( + context, + sharedVault, + ) + + await Collaboration.acceptAllInvites(thirdPartyContext) + + const sendContactSharePromise = context.resolveWhenSharedVaultServiceSendsContactShareMessage() + + await context.contacts.createOrEditTrustedContact({ + contactUuid: thirdPartyContext.userUuid, + publicKey: thirdPartyContext.publicKey, + signingPublicKey: thirdPartyContext.signingPublicKey, + name: 'Changed 3rd Party Name', + }) + + await sendContactSharePromise + + const completedProcessingMessagesPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + + await contactContext.sync() + await completedProcessingMessagesPromise + + const updatedContact = contactContext.contacts.findTrustedContact(thirdPartyContext.userUuid) + expect(updatedContact.name).to.equal('Changed 3rd Party Name') + + await deinitContactContext() + await deinitThirdPartyContext() + }) + + it('should not send contact share message to self or to contact who is changed', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const { thirdPartyContext, deinitThirdPartyContext } = await Collaboration.inviteNewPartyToSharedVault( + context, + sharedVault, + ) + const handleInitialContactShareMessage = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + + await Collaboration.acceptAllInvites(thirdPartyContext) + + await contactContext.sync() + await handleInitialContactShareMessage + + const sendContactSharePromise = context.resolveWhenSharedVaultServiceSendsContactShareMessage() + + await context.contacts.createOrEditTrustedContact({ + contactUuid: thirdPartyContext.userUuid, + publicKey: thirdPartyContext.publicKey, + signingPublicKey: thirdPartyContext.signingPublicKey, + name: 'Changed 3rd Party Name', + }) + + await sendContactSharePromise + + const firstPartySpy = sinon.spy(context.asymmetric, 'handleTrustedContactShareMessage') + const secondPartySpy = sinon.spy(contactContext.asymmetric, 'handleTrustedContactShareMessage') + const thirdPartySpy = sinon.spy(thirdPartyContext.asymmetric, 'handleTrustedContactShareMessage') + + await context.sync() + await contactContext.sync() + await thirdPartyContext.sync() + + expect(firstPartySpy.callCount).to.equal(0) + expect(secondPartySpy.callCount).to.equal(1) + expect(thirdPartySpy.callCount).to.equal(0) + + await deinitThirdPartyContext() + await deinitContactContext() + }) + + it('should send shared vault root key change message after root key change', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await context.vaults.rotateVaultRootKey(sharedVault) + + const firstPartySpy = sinon.spy(context.asymmetric, 'handleTrustedSharedVaultRootKeyChangedMessage') + const secondPartySpy = sinon.spy(contactContext.asymmetric, 'handleTrustedSharedVaultRootKeyChangedMessage') + + await context.sync() + await contactContext.sync() + + expect(firstPartySpy.callCount).to.equal(0) + expect(secondPartySpy.callCount).to.equal(1) + + await deinitContactContext() + }) + + it('should send shared vault metadata change message after shared vault name change', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await context.vaults.changeVaultNameAndDescription(sharedVault, { + name: 'New Name', + description: 'New Description', + }) + + const firstPartySpy = sinon.spy(context.asymmetric, 'handleVaultMetadataChangedMessage') + const secondPartySpy = sinon.spy(contactContext.asymmetric, 'handleVaultMetadataChangedMessage') + + await context.sync() + await contactContext.sync() + + expect(firstPartySpy.callCount).to.equal(0) + expect(secondPartySpy.callCount).to.equal(1) + + const updatedVault = contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + expect(updatedVault.name).to.equal('New Name') + expect(updatedVault.description).to.equal('New Description') + + await deinitContactContext() + }) + + it('should send sender keypair changed message to trusted contacts', async () => { + const { contactContext, deinitContactContext } = await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await context.changePassword('new password') + + const firstPartySpy = sinon.spy(context.asymmetric, 'handleTrustedSenderKeypairChangedMessage') + const secondPartySpy = sinon.spy(contactContext.asymmetric, 'handleTrustedSenderKeypairChangedMessage') + + await context.sync() + + const completedProcessingMessagesPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + await contactContext.sync() + await completedProcessingMessagesPromise + + expect(firstPartySpy.callCount).to.equal(0) + expect(secondPartySpy.callCount).to.equal(1) + + const contact = contactContext.contacts.findTrustedContact(context.userUuid) + expect(contact.publicKeySet.encryption).to.equal(context.publicKey) + expect(contact.publicKeySet.signing).to.equal(context.signingPublicKey) + + await deinitContactContext() + }) + + it('should process sender keypair changed message', async () => { + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + await Collaboration.createTrustedContactForUserOfContext(contactContext, context) + const originalContact = contactContext.contacts.findTrustedContact(context.userUuid) + + await context.changePassword('new_password') + + const completedProcessingMessagesPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + await contactContext.sync() + await completedProcessingMessagesPromise + + const updatedContact = contactContext.contacts.findTrustedContact(context.userUuid) + + expect(updatedContact.publicKeySet.encryption).to.not.equal(originalContact.publicKeySet.encryption) + expect(updatedContact.publicKeySet.signing).to.not.equal(originalContact.publicKeySet.signing) + + expect(updatedContact.publicKeySet.encryption).to.equal(context.publicKey) + expect(updatedContact.publicKeySet.signing).to.equal(context.signingPublicKey) + + await deinitContactContext() + }) + + it('sender keypair changed message should be signed using old key pair', async () => { + const { contactContext, deinitContactContext } = await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const oldKeyPair = context.encryption.getKeyPair() + const oldSigningKeyPair = context.encryption.getSigningKeyPair() + + await context.changePassword('new password') + + const secondPartySpy = sinon.spy(contactContext.asymmetric, 'handleTrustedSenderKeypairChangedMessage') + + await context.sync() + const completedProcessingMessagesPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + await contactContext.sync() + await completedProcessingMessagesPromise + + const message = secondPartySpy.args[0][0] + const encryptedMessage = message.encrypted_message + + const publicKeySet = + contactContext.encryption.getSenderPublicKeySetFromAsymmetricallyEncryptedString(encryptedMessage) + + expect(publicKeySet.encryption).to.equal(oldKeyPair.publicKey) + expect(publicKeySet.signing).to.equal(oldSigningKeyPair.publicKey) + + await deinitContactContext() + }) + + it('sender keypair changed message should contain new keypair and be trusted', async () => { + const { contactContext, deinitContactContext } = await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await context.changePassword('new password') + + const newKeyPair = context.encryption.getKeyPair() + const newSigningKeyPair = context.encryption.getSigningKeyPair() + + const completedProcessingMessagesPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + await contactContext.sync() + await completedProcessingMessagesPromise + + const updatedContact = contactContext.contacts.findTrustedContact(context.userUuid) + expect(updatedContact.publicKeySet.encryption).to.equal(newKeyPair.publicKey) + expect(updatedContact.publicKeySet.signing).to.equal(newSigningKeyPair.publicKey) + + await deinitContactContext() + }) + + it('should delete all inbound messages after changing user password', async () => { + /** Messages to user are encrypted with old keypair and are no longer decryptable */ + console.error('TODO: implement test') + }) +}) diff --git a/packages/snjs/mocha/vaults/conflicts.test.js b/packages/snjs/mocha/vaults/conflicts.test.js new file mode 100644 index 000000000..5d52d3ca7 --- /dev/null +++ b/packages/snjs/mocha/vaults/conflicts.test.js @@ -0,0 +1,186 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault conflicts', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + }) + + it('after being removed from shared vault, attempting to sync previous vault item should result in SharedVaultNotMemberError. The item should be duplicated then removed.', async () => { + const { sharedVault, note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + contactContext.lockSyncing() + await context.sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + const promise = contactContext.resolveWithConflicts() + contactContext.unlockSyncing() + await contactContext.changeNoteTitleAndSync(note, 'new title') + const conflicts = await promise + await contactContext.sync() + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].type).to.equal(ConflictType.SharedVaultNotMemberError) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + + const collaboratorNotes = contactContext.items.getDisplayableNotes() + expect(collaboratorNotes.length).to.equal(1) + expect(collaboratorNotes[0].duplicate_of).to.not.be.undefined + expect(collaboratorNotes[0].title).to.equal('new title') + + await deinitContactContext() + }) + + it('conflicts created should be associated with the vault', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await context.changeNoteTitle(note, 'new title first client') + await contactContext.changeNoteTitle(note, 'new title second client') + + const doneAddingConflictToSharedVault = contactContext.resolveWhenSavedSyncPayloadsIncludesItemThatIsDuplicatedOf( + note.uuid, + ) + + await context.sync({ desc: 'First client sync' }) + await contactContext.sync({ + desc: 'Second client sync with conflicts to be created', + }) + await doneAddingConflictToSharedVault + await context.sync({ desc: 'First client sync with conflicts to be pulled in' }) + + expect(context.items.invalidItems.length).to.equal(0) + expect(contactContext.items.invalidItems.length).to.equal(0) + + const collaboratorNotes = contactContext.items.getDisplayableNotes() + expect(collaboratorNotes.length).to.equal(2) + expect(collaboratorNotes.find((note) => !!note.duplicate_of)).to.not.be.undefined + + const originatorNotes = context.items.getDisplayableNotes() + expect(originatorNotes.length).to.equal(2) + expect(originatorNotes.find((note) => !!note.duplicate_of)).to.not.be.undefined + + await deinitContactContext() + }) + + it('attempting to modify note as read user should result in SharedVaultInsufficientPermissionsError', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context, SharedVaultPermission.Read) + + const promise = contactContext.resolveWithConflicts() + await contactContext.changeNoteTitleAndSync(note, 'new title') + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].type).to.equal(ConflictType.SharedVaultInsufficientPermissionsError) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + + await deinitContactContext() + }) + + it('should handle SharedVaultNotMemberError by duplicating item to user non-vault', async () => { + const { sharedVault, note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await context.sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + await contactContext.changeNoteTitleAndSync(note, 'new title') + const notes = contactContext.notes + + expect(notes.length).to.equal(1) + expect(notes[0].title).to.equal('new title') + expect(notes[0].key_system_identifier).to.not.be.ok + expect(notes[0].duplicate_of).to.equal(note.uuid) + + await deinitContactContext() + }) + + it('attempting to save note to non-existent vault should result in SharedVaultNotMemberError conflict', async () => { + context.anticipateConsoleError( + 'Error decrypting contentKey from parameters', + 'An invalid shared vault uuid is being assigned to an item', + ) + const { note } = await Collaboration.createSharedVaultWithNote(context) + + const promise = context.resolveWithConflicts() + + const objectToSpy = context.application.sync + sinon.stub(objectToSpy, 'payloadsByPreparingForServer').callsFake(async (params) => { + objectToSpy.payloadsByPreparingForServer.restore() + const payloads = await objectToSpy.payloadsByPreparingForServer(params) + for (const payload of payloads) { + payload.shared_vault_uuid = 'non-existent-vault-uuid-123' + } + + return payloads + }) + + await context.changeNoteTitleAndSync(note, 'new-title') + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].type).to.equal(ConflictType.SharedVaultNotMemberError) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + }) + + it('should create a non-vaulted copy if attempting to move item from vault to user and item belongs to someone else', async () => { + const { note, sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const promise = contactContext.resolveWithConflicts() + await contactContext.vaults.removeItemFromVault(note) + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + + const duplicateNote = contactContext.findDuplicateNote(note.uuid) + expect(duplicateNote).to.not.be.undefined + expect(duplicateNote.key_system_identifier).to.not.be.ok + + const existingNote = contactContext.items.findItem(note.uuid) + expect(existingNote.key_system_identifier).to.equal(sharedVault.systemIdentifier) + + await deinitContactContext() + }) + + it('should created a non-vaulted copy if admin attempts to move item from vault to user if the item belongs to someone else', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const note = await contactContext.createSyncedNote('foo', 'bar') + await Collaboration.moveItemToVault(contactContext, sharedVault, note) + await context.sync() + + const promise = context.resolveWithConflicts() + await context.vaults.removeItemFromVault(note) + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + + const duplicateNote = context.findDuplicateNote(note.uuid) + expect(duplicateNote).to.not.be.undefined + expect(duplicateNote.key_system_identifier).to.not.be.ok + + const existingNote = context.items.findItem(note.uuid) + expect(existingNote.key_system_identifier).to.equal(sharedVault.systemIdentifier) + + await deinitContactContext() + }) +}) diff --git a/packages/snjs/mocha/vaults/contacts.test.js b/packages/snjs/mocha/vaults/contacts.test.js new file mode 100644 index 000000000..ef158cba5 --- /dev/null +++ b/packages/snjs/mocha/vaults/contacts.test.js @@ -0,0 +1,83 @@ +import * as Factory from '../lib/factory.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('contacts', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + }) + + it('should create contact', async () => { + const contact = await context.contacts.createOrEditTrustedContact({ + name: 'John Doe', + publicKey: 'my_public_key', + signingPublicKey: 'my_signing_public_key', + contactUuid: '123', + }) + + expect(contact).to.not.be.undefined + expect(contact.name).to.equal('John Doe') + expect(contact.publicKeySet.encryption).to.equal('my_public_key') + expect(contact.publicKeySet.signing).to.equal('my_signing_public_key') + expect(contact.contactUuid).to.equal('123') + }) + + it('should create self contact on registration', async () => { + const selfContact = context.contacts.getSelfContact() + + expect(selfContact).to.not.be.undefined + + expect(selfContact.publicKeySet.encryption).to.equal(context.publicKey) + expect(selfContact.publicKeySet.signing).to.equal(context.signingPublicKey) + }) + + it('should create self contact on sign in if it does not exist', async () => { + let selfContact = context.contacts.getSelfContact() + await context.mutator.setItemToBeDeleted(selfContact) + await context.sync() + await context.signout() + + await context.signIn() + selfContact = context.contacts.getSelfContact() + expect(selfContact).to.not.be.undefined + }) + + it('should update self contact on password change', async () => { + const selfContact = context.contacts.getSelfContact() + + await context.changePassword('new_password') + + const updatedSelfContact = context.contacts.getSelfContact() + + expect(updatedSelfContact.publicKeySet.encryption).to.not.equal(selfContact.publicKeySet.encryption) + expect(updatedSelfContact.publicKeySet.signing).to.not.equal(selfContact.publicKeySet.signing) + + expect(updatedSelfContact.publicKeySet.encryption).to.equal(context.publicKey) + expect(updatedSelfContact.publicKeySet.signing).to.equal(context.signingPublicKey) + }) + + it('should not be able to delete self contact', async () => { + const selfContact = context.contacts.getSelfContact() + + await Factory.expectThrowsAsync(() => context.contacts.deleteContact(selfContact), 'Cannot delete self') + }) + + it('should not be able to delete a trusted contact if it belongs to a vault I administer', async () => { + console.error('TODO: implement test') + }) +}) diff --git a/packages/snjs/mocha/vaults/crypto.test.js b/packages/snjs/mocha/vaults/crypto.test.js new file mode 100644 index 000000000..c328e30a3 --- /dev/null +++ b/packages/snjs/mocha/vaults/crypto.test.js @@ -0,0 +1,204 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault crypto', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + }) + + describe('root key', () => { + it('root key loaded from disk should have keypairs', async () => { + const appIdentifier = context.identifier + await context.deinit() + + let recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + expect(recreatedContext.encryption.getKeyPair()).to.not.be.undefined + expect(recreatedContext.encryption.getSigningKeyPair()).to.not.be.undefined + }) + + it('changing user password should re-encrypt all key system root keys', async () => { + console.error('TODO: implement') + }) + + it('changing user password should re-encrypt all trusted contacts', async () => { + console.error('TODO: implement') + }) + }) + + describe('persistent content signature', () => { + it('storage payloads should include signatureData', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await contactContext.changeNoteTitleAndSync(note, 'new title') + await context.sync() + + const rawPayloads = await context.application.diskStorageService.getAllRawPayloads() + const noteRawPayload = rawPayloads.find((payload) => payload.uuid === note.uuid) + + expect(noteRawPayload.signatureData).to.not.be.undefined + + await deinitContactContext() + }) + + it('changing item content should erase existing signatureData', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await contactContext.changeNoteTitleAndSync(note, 'new title') + await context.sync() + + let updatedNote = context.items.findItem(note.uuid) + await context.changeNoteTitleAndSync(updatedNote, 'new title 2') + + updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.signatureData).to.be.undefined + + await deinitContactContext() + }) + + it('encrypting an item into storage then loading it should verify authenticity of original content rather than most recent symmetric signature', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await contactContext.changeNoteTitleAndSync(note, 'new title') + + /** Override decrypt result to return failing signature */ + const objectToSpy = context.encryption + sinon.stub(objectToSpy, 'decryptSplit').callsFake(async (split) => { + objectToSpy.decryptSplit.restore() + + const decryptedPayloads = await objectToSpy.decryptSplit(split) + expect(decryptedPayloads.length).to.equal(1) + + const payload = decryptedPayloads[0] + const mutatedPayload = new DecryptedPayload({ + ...payload.ejected(), + signatureData: { + ...payload.signatureData, + result: { + ...payload.signatureData.result, + passes: false, + }, + }, + }) + + return [mutatedPayload] + }) + await context.sync() + + let updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.content.title).to.equal('new title') + expect(updatedNote.signatureData.result.passes).to.equal(false) + + const appIdentifier = context.identifier + await context.deinit() + + let recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.signatureData.result.passes).to.equal(false) + + /** Changing the content now should clear failing signature */ + await recreatedContext.changeNoteTitleAndSync(updatedNote, 'new title 2') + updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.signatureData).to.be.undefined + + await recreatedContext.deinit() + + recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + /** Decrypting from storage will now verify current user symmetric signature only */ + updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.signatureData.result.passes).to.equal(true) + + await recreatedContext.deinit() + await deinitContactContext() + }) + }) + + describe('symmetrically encrypted items', () => { + it('created items with a payload source of remote saved should not have signature data', async () => { + const note = await context.createSyncedNote() + + expect(note.payload.source).to.equal(PayloadSource.RemoteSaved) + + expect(note.signatureData).to.be.undefined + }) + + it('retrieved items that are then remote saved should have their signature data cleared', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await contactContext.changeNoteTitleAndSync(contactContext.items.findItem(note.uuid), 'new title') + + await context.sync() + expect(context.items.findItem(note.uuid).signatureData).to.not.be.undefined + + await context.changeNoteTitleAndSync(context.items.findItem(note.uuid), 'new title') + expect(context.items.findItem(note.uuid).signatureData).to.be.undefined + + await deinitContactContext() + }) + + it('should allow client verification of authenticity of shared item changes', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + expect(context.contacts.isItemAuthenticallySigned(note)).to.equal('not-applicable') + + const contactNote = contactContext.items.findItem(note.uuid) + + expect(contactContext.contacts.isItemAuthenticallySigned(contactNote)).to.equal('yes') + + await contactContext.changeNoteTitleAndSync(contactNote, 'new title') + + await context.sync() + + let updatedNote = context.items.findItem(note.uuid) + + expect(context.contacts.isItemAuthenticallySigned(updatedNote)).to.equal('yes') + + await deinitContactContext() + }) + }) + + describe('keypair revocation', () => { + it('should be able to revoke non-current keypair', async () => { + console.error('TODO') + }) + + it('revoking a keypair should send a keypair revocation event to trusted contacts', async () => { + console.error('TODO') + }) + + it('should not be able to revoke current key pair', async () => { + console.error('TODO') + }) + + it('should distrust revoked keypair as contact', async () => { + console.error('TODO') + }) + }) +}) diff --git a/packages/snjs/mocha/vaults/deletion.test.js b/packages/snjs/mocha/vaults/deletion.test.js new file mode 100644 index 000000000..df6f4d825 --- /dev/null +++ b/packages/snjs/mocha/vaults/deletion.test.js @@ -0,0 +1,159 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault deletion', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let sharedVaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + sharedVaults = context.sharedVaults + }) + + it('should remove item from all user devices when item is deleted permanently', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const promise = context.resolveWhenSavedSyncPayloadsIncludesItemUuid(note.uuid) + await context.mutator.setItemToBeDeleted(note) + await context.sync() + await contactContext.sync() + await promise + + const originatorNote = context.items.findItem(note.uuid) + expect(originatorNote).to.be.undefined + + const collaboratorNote = contactContext.items.findItem(note.uuid) + expect(collaboratorNote).to.be.undefined + + await deinitContactContext() + }) + + it('attempting to delete a note received by and already deleted by another person should not cause infinite conflicts', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const promise = context.resolveWhenSavedSyncPayloadsIncludesItemUuid(note.uuid) + + await context.mutator.setItemToBeDeleted(note) + await contactContext.mutator.setItemToBeDeleted(note) + + await context.sync() + await contactContext.sync() + await promise + + const originatorNote = context.items.findItem(note.uuid) + expect(originatorNote).to.be.undefined + + const collaboratorNote = contactContext.items.findItem(note.uuid) + expect(collaboratorNote).to.be.undefined + + await deinitContactContext() + }) + + it('deleting a shared vault should remove all vault items from collaborator devices', async () => { + const { sharedVault, note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await sharedVaults.deleteSharedVault(sharedVault) + await contactContext.sync() + + const originatorNote = context.items.findItem(note.uuid) + expect(originatorNote).to.be.undefined + + const contactNote = contactContext.items.findItem(note.uuid) + expect(contactNote).to.be.undefined + + await deinitContactContext() + }) + + it('being removed from shared vault should remove shared vault items locally', async () => { + const { sharedVault, note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const contactNote = contactContext.items.findItem(note.uuid) + expect(contactNote).to.not.be.undefined + + await context.sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + + await contactContext.sync() + + const updatedContactNote = contactContext.items.findItem(note.uuid) + expect(updatedContactNote).to.be.undefined + + await deinitContactContext() + }) + + it('leaving a shared vault should remove its items locally', async () => { + const { sharedVault, note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context, SharedVaultPermission.Admin) + + const originalNote = contactContext.items.findItem(note.uuid) + expect(originalNote).to.not.be.undefined + + const contactVault = contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + await contactContext.sharedVaults.leaveSharedVault(contactVault) + + const updatedContactNote = contactContext.items.findItem(note.uuid) + expect(updatedContactNote).to.be.undefined + + const vault = await contactContext.vaults.getVault({ keySystemIdentifier: contactVault.systemIdentifier }) + expect(vault).to.be.undefined + + await deinitContactContext() + }) + + it('removing an item from a vault should remove it from collaborator devices', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + await context.vaults.removeItemFromVault(note) + + await context.changeNoteTitleAndSync(note, 'new title') + + const receivedNote = contactContext.items.findItem(note.uuid) + + expect(receivedNote).to.not.be.undefined + expect(receivedNote.title).to.not.equal('new title') + expect(receivedNote.title).to.equal(note.title) + + await deinitContactContext() + }) + + it('should remove shared vault member', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const originalSharedVaultUsers = await sharedVaults.getSharedVaultUsers(sharedVault) + expect(originalSharedVaultUsers.length).to.equal(2) + + const result = await sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + + expect(isClientDisplayableError(result)).to.be.false + + const updatedSharedVaultUsers = await sharedVaults.getSharedVaultUsers(sharedVault) + expect(updatedSharedVaultUsers.length).to.equal(1) + + await deinitContactContext() + }) + + it('being removed from a shared vault should delete respective vault listing', async () => { + console.error('TODO: implement test') + }) +}) diff --git a/packages/snjs/mocha/vaults/files.test.js b/packages/snjs/mocha/vaults/files.test.js new file mode 100644 index 000000000..5ee677e57 --- /dev/null +++ b/packages/snjs/mocha/vaults/files.test.js @@ -0,0 +1,259 @@ +import * as Factory from '../lib/factory.js' +import * as Files from '../lib/Files.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault files', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let vaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + vaults = context.vaults + await context.publicMockSubscriptionPurchaseEvent() + }) + + describe('private vaults', () => { + it('should be able to upload and download file to vault as owner', async () => { + const vault = await Collaboration.createPrivateVault(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, vault) + + const file = context.items.findItem(uploadedFile.uuid) + expect(file).to.not.be.undefined + expect(file.remoteIdentifier).to.equal(file.remoteIdentifier) + expect(file.key_system_identifier).to.equal(vault.systemIdentifier) + + const downloadedBytes = await Files.downloadFile(context.files, file) + expect(downloadedBytes).to.eql(buffer) + }) + }) + + it('should be able to upload and download file to vault as owner', async () => { + const sharedVault = await Collaboration.createSharedVault(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + + const file = context.items.findItem(uploadedFile.uuid) + expect(file).to.not.be.undefined + expect(file.remoteIdentifier).to.equal(file.remoteIdentifier) + expect(file.key_system_identifier).to.equal(sharedVault.systemIdentifier) + + const downloadedBytes = await Files.downloadFile(context.files, file) + expect(downloadedBytes).to.eql(buffer) + }) + + it('should be able to move a user file to a vault', async () => { + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000) + + const sharedVault = await Collaboration.createSharedVault(context) + const addedFile = await vaults.moveItemToVault(sharedVault, uploadedFile) + + const downloadedBytes = await Files.downloadFile(context.files, addedFile) + expect(downloadedBytes).to.eql(buffer) + }) + + it('should be able to move a shared vault file to another shared vault', async () => { + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const firstVault = await Collaboration.createSharedVault(context) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, firstVault) + + const secondVault = await Collaboration.createSharedVault(context) + const movedFile = await vaults.moveItemToVault(secondVault, uploadedFile) + + const downloadedBytes = await Files.downloadFile(context.files, movedFile) + expect(downloadedBytes).to.eql(buffer) + }) + + it('should be able to move a shared vault file to a non-shared vault', async () => { + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const firstVault = await Collaboration.createSharedVault(context) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, firstVault) + const privateVault = await Collaboration.createPrivateVault(context) + + const addedFile = await vaults.moveItemToVault(privateVault, uploadedFile) + + const downloadedBytes = await Files.downloadFile(context.files, addedFile) + expect(downloadedBytes).to.eql(buffer) + }) + + it('moving a note to a vault should also moved linked files', async () => { + const note = await context.createSyncedNote() + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const file = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000) + + const updatedFile = await context.application.mutator.associateFileWithNote(file, note) + + const sharedVault = await Collaboration.createSharedVault(context) + + vaults.alerts.confirmV2 = () => Promise.resolve(true) + + await vaults.moveItemToVault(sharedVault, note) + + const latestFile = context.items.findItem(updatedFile.uuid) + + expect(vaults.getItemVault(latestFile).uuid).to.equal(sharedVault.uuid) + expect(vaults.getItemVault(context.items.findItem(note.uuid)).uuid).to.equal(sharedVault.uuid) + + const downloadedBytes = await Files.downloadFile(context.files, latestFile) + expect(downloadedBytes).to.eql(buffer) + }) + + it('should be able to move a file out of its vault', async () => { + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const sharedVault = await Collaboration.createSharedVault(context) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + + const removedFile = await vaults.removeItemFromVault(uploadedFile) + expect(removedFile.key_system_identifier).to.not.be.ok + + const downloadedBytes = await Files.downloadFile(context.files, removedFile) + expect(downloadedBytes).to.eql(buffer) + }) + + it('should be able to download vault file as collaborator', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + + await contactContext.sync() + + const sharedFile = contactContext.items.findItem(uploadedFile.uuid) + expect(sharedFile).to.not.be.undefined + expect(sharedFile.remoteIdentifier).to.equal(uploadedFile.remoteIdentifier) + + const downloadedBytes = await Files.downloadFile(contactContext.files, sharedFile) + expect(downloadedBytes).to.eql(buffer) + + await deinitContactContext() + }) + + it('should be able to upload vault file as collaborator', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const uploadedFile = await Files.uploadFile(contactContext.files, buffer, 'my-file', 'md', 1000, sharedVault) + + await context.sync() + + const file = context.items.findItem(uploadedFile.uuid) + expect(file).to.not.be.undefined + expect(file.remoteIdentifier).to.equal(file.remoteIdentifier) + + const downloadedBytes = await Files.downloadFile(context.files, file) + expect(downloadedBytes).to.eql(buffer) + + await deinitContactContext() + }) + + it('should be able to delete vault file as write user', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context, SharedVaultPermission.Write) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + + await contactContext.sync() + + const file = contactContext.items.findItem(uploadedFile.uuid) + const result = await contactContext.files.deleteFile(file) + expect(result).to.be.undefined + + const foundFile = contactContext.items.findItem(file.uuid) + expect(foundFile).to.be.undefined + + await deinitContactContext() + }) + + it('should not be able to delete vault file as read user', async () => { + context.anticipateConsoleError('Could not create valet token') + + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context, SharedVaultPermission.Read) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + + await contactContext.sync() + + const file = contactContext.items.findItem(uploadedFile.uuid) + const result = await contactContext.files.deleteFile(file) + expect(isClientDisplayableError(result)).to.be.true + + const foundFile = contactContext.items.findItem(file.uuid) + expect(foundFile).to.not.be.undefined + + await deinitContactContext() + }) + + it('should be able to download recently moved vault file as collaborator', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000) + const addedFile = await vaults.moveItemToVault(sharedVault, uploadedFile) + + await contactContext.sync() + + const sharedFile = contactContext.items.findItem(addedFile.uuid) + expect(sharedFile).to.not.be.undefined + expect(sharedFile.remoteIdentifier).to.equal(addedFile.remoteIdentifier) + + const downloadedBytes = await Files.downloadFile(contactContext.files, sharedFile) + expect(downloadedBytes).to.eql(buffer) + + await deinitContactContext() + }) + + it('should not be able to download file after being removed from vault', async () => { + context.anticipateConsoleError('Could not create valet token') + + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + const response = await fetch('/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + const uploadedFile = await Files.uploadFile(context.files, buffer, 'my-file', 'md', 1000, sharedVault) + await contactContext.sync() + + await context.sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + + const file = contactContext.items.findItem(uploadedFile.uuid) + await Factory.expectThrowsAsync(() => Files.downloadFile(contactContext.files, file), 'Could not download file') + + await deinitContactContext() + }) +}) diff --git a/packages/snjs/mocha/vaults/invites.test.js b/packages/snjs/mocha/vaults/invites.test.js new file mode 100644 index 000000000..4071af7b6 --- /dev/null +++ b/packages/snjs/mocha/vaults/invites.test.js @@ -0,0 +1,229 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault invites', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let sharedVaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + await context.launch() + await context.register() + + sharedVaults = context.sharedVaults + }) + + it('should invite contact to vault', async () => { + const sharedVault = await Collaboration.createSharedVault(context) + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + const contact = await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + + const vaultInvite = await sharedVaults.inviteContactToSharedVault(sharedVault, contact, SharedVaultPermission.Write) + + expect(vaultInvite).to.not.be.undefined + expect(vaultInvite.shared_vault_uuid).to.equal(sharedVault.sharing.sharedVaultUuid) + expect(vaultInvite.user_uuid).to.equal(contact.contactUuid) + expect(vaultInvite.encrypted_message).to.not.be.undefined + expect(vaultInvite.permissions).to.equal(SharedVaultPermission.Write) + expect(vaultInvite.updated_at_timestamp).to.not.be.undefined + expect(vaultInvite.created_at_timestamp).to.not.be.undefined + + await deinitContactContext() + }) + + it('invites from trusted contact should be pending as trusted', async () => { + const { contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithUnacceptedButTrustedInvite(context) + + const invites = contactContext.sharedVaults.getCachedPendingInviteRecords() + + expect(invites[0].trusted).to.be.true + + await deinitContactContext() + }) + + it('invites from untrusted contact should be pending as untrusted', async () => { + const { contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithUnacceptedAndUntrustedInvite(context) + + const invites = contactContext.sharedVaults.getCachedPendingInviteRecords() + + expect(invites[0].trusted).to.be.false + + await deinitContactContext() + }) + + it('invite should include delegated trusted contacts', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const { thirdPartyContext, deinitThirdPartyContext } = await Collaboration.inviteNewPartyToSharedVault( + context, + sharedVault, + ) + + const invites = thirdPartyContext.sharedVaults.getCachedPendingInviteRecords() + + const message = invites[0].message + const delegatedContacts = message.data.trustedContacts + expect(delegatedContacts.length).to.equal(1) + expect(delegatedContacts[0].contactUuid).to.equal(contactContext.userUuid) + + await deinitThirdPartyContext() + await deinitContactContext() + }) + + it('should sync a shared vault from scratch after accepting an invitation', async () => { + const sharedVault = await Collaboration.createSharedVault(context) + + const note = await context.createSyncedNote('foo', 'bar') + await Collaboration.moveItemToVault(context, sharedVault, note) + + /** Create a mutually trusted contact */ + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + const contact = await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + await Collaboration.createTrustedContactForUserOfContext(contactContext, context) + + /** Sync the contact context so that they wouldn't naturally receive changes made before this point */ + await contactContext.sync() + + await sharedVaults.inviteContactToSharedVault(sharedVault, contact, SharedVaultPermission.Write) + + /** Contact should now sync and expect to find note */ + const promise = contactContext.awaitNextSyncSharedVaultFromScratchEvent() + await contactContext.sync() + await Collaboration.acceptAllInvites(contactContext) + await promise + + const receivedNote = contactContext.items.findItem(note.uuid) + expect(receivedNote).to.not.be.undefined + expect(receivedNote.title).to.equal('foo') + expect(receivedNote.text).to.equal(note.text) + + await deinitContactContext() + }) + + it('received invites from untrusted contact should not be trusted', async () => { + await context.createSyncedNote('foo', 'bar') + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + const sharedVault = await Collaboration.createSharedVault(context) + + const currentContextContact = await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + await sharedVaults.inviteContactToSharedVault(sharedVault, currentContextContact, SharedVaultPermission.Write) + + await contactContext.sharedVaults.downloadInboundInvites() + expect(contactContext.sharedVaults.getCachedPendingInviteRecords()[0].trusted).to.be.false + + await deinitContactContext() + }) + + it('received invites from contact who becomes trusted after receipt of invite should be trusted', async () => { + await context.createSyncedNote('foo', 'bar') + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + const sharedVault = await Collaboration.createSharedVault(context) + + const currentContextContact = await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + await sharedVaults.inviteContactToSharedVault(sharedVault, currentContextContact, SharedVaultPermission.Write) + + await contactContext.sharedVaults.downloadInboundInvites() + expect(contactContext.sharedVaults.getCachedPendingInviteRecords()[0].trusted).to.be.false + + await Collaboration.createTrustedContactForUserOfContext(contactContext, context) + + expect(contactContext.sharedVaults.getCachedPendingInviteRecords()[0].trusted).to.be.true + + await deinitContactContext() + }) + + it('received items should contain the uuid of the contact who sent the item', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const receivedNote = contactContext.items.findItem(note.uuid) + expect(receivedNote).to.not.be.undefined + expect(receivedNote.user_uuid).to.equal(context.userUuid) + + await deinitContactContext() + }) + + it('items should contain the uuid of the last person who edited it', async () => { + const { note, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const receivedNote = contactContext.items.findItem(note.uuid) + expect(receivedNote.last_edited_by_uuid).to.not.be.undefined + expect(receivedNote.last_edited_by_uuid).to.equal(context.userUuid) + + await contactContext.changeNoteTitleAndSync(receivedNote, 'new title') + await context.sync() + + const updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.last_edited_by_uuid).to.not.be.undefined + expect(updatedNote.last_edited_by_uuid).to.equal(contactContext.userUuid) + + await deinitContactContext() + }) + + it('canceling an invite should remove it from recipient pending invites', async () => { + const { invite, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithUnacceptedButTrustedInvite(context) + + const preInvites = await contactContext.sharedVaults.downloadInboundInvites() + expect(preInvites.length).to.equal(1) + + await sharedVaults.deleteInvite(invite) + + const postInvites = await contactContext.sharedVaults.downloadInboundInvites() + expect(postInvites.length).to.equal(0) + + await deinitContactContext() + }) + + it('when inviter keypair changes, recipient should still be able to trust and decrypt previous invite', async () => { + console.error('TODO: implement test') + }) + + it('should delete all inbound invites after changing user password', async () => { + /** Invites to user are encrypted with old keypair and are no longer decryptable */ + console.error('TODO: implement test') + }) + + it('sharing a vault with user inputted and ephemeral password should share the key as synced for the recipient', async () => { + const privateVault = await context.vaults.createUserInputtedPasswordVault({ + name: 'My Private Vault', + userInputtedPassword: 'password', + storagePreference: KeySystemRootKeyStorageMode.Ephemeral, + }) + + const note = await context.createSyncedNote('foo', 'bar') + await context.vaults.moveItemToVault(privateVault, note) + + const sharedVault = await context.sharedVaults.convertVaultToSharedVault(privateVault) + + const { thirdPartyContext, deinitThirdPartyContext } = await Collaboration.inviteNewPartyToSharedVault( + context, + sharedVault, + ) + + await Collaboration.acceptAllInvites(thirdPartyContext) + + const contextNote = thirdPartyContext.items.findItem(note.uuid) + expect(contextNote).to.not.be.undefined + expect(contextNote.title).to.equal('foo') + expect(contextNote.text).to.equal(note.text) + + await deinitThirdPartyContext() + }) +}) diff --git a/packages/snjs/mocha/vaults/items.test.js b/packages/snjs/mocha/vaults/items.test.js new file mode 100644 index 000000000..6df81b384 --- /dev/null +++ b/packages/snjs/mocha/vaults/items.test.js @@ -0,0 +1,121 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault items', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let sharedVaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + sharedVaults = context.sharedVaults + }) + + it('should add item to shared vault with no other members', async () => { + const note = await context.createSyncedNote('foo', 'bar') + + const sharedVault = await Collaboration.createSharedVault(context) + + await Collaboration.moveItemToVault(context, sharedVault, note) + + const updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.key_system_identifier).to.equal(sharedVault.systemIdentifier) + expect(updatedNote.shared_vault_uuid).to.equal(sharedVault.sharing.sharedVaultUuid) + }) + + it('should add item to shared vault with contact', async () => { + const note = await context.createSyncedNote('foo', 'bar') + + const { sharedVault, deinitContactContext } = await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await Collaboration.moveItemToVault(context, sharedVault, note) + + const updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.key_system_identifier).to.equal(sharedVault.systemIdentifier) + + await deinitContactContext() + }) + + it('received items from previously trusted contact should be decrypted', async () => { + const note = await context.createSyncedNote('foo', 'bar') + const { contactContext, deinitContactContext } = await Collaboration.createContactContext() + const sharedVault = await Collaboration.createSharedVault(context) + + await Collaboration.createTrustedContactForUserOfContext(contactContext, context) + const currentContextContact = await Collaboration.createTrustedContactForUserOfContext(context, contactContext) + + contactContext.lockSyncing() + await sharedVaults.inviteContactToSharedVault(sharedVault, currentContextContact, SharedVaultPermission.Write) + await Collaboration.moveItemToVault(context, sharedVault, note) + + const promise = contactContext.awaitNextSyncSharedVaultFromScratchEvent() + contactContext.unlockSyncing() + await contactContext.sync() + await Collaboration.acceptAllInvites(contactContext) + await promise + + const receivedItemsKey = contactContext.keys.getPrimaryKeySystemItemsKey(sharedVault.systemIdentifier) + expect(receivedItemsKey).to.not.be.undefined + expect(receivedItemsKey.itemsKey).to.not.be.undefined + + const receivedNote = contactContext.items.findItem(note.uuid) + expect(receivedNote.title).to.equal('foo') + expect(receivedNote.text).to.equal(note.text) + + await deinitContactContext() + }) + + it('shared vault creator should receive changes from other members', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + const note = await context.createSyncedNote('foo', 'bar') + await Collaboration.moveItemToVault(context, sharedVault, note) + await contactContext.sync() + + await contactContext.mutator.changeItem({ uuid: note.uuid }, (mutator) => { + mutator.title = 'new title' + }) + await contactContext.sync() + await context.sync() + + const receivedNote = context.items.findItem(note.uuid) + expect(receivedNote.title).to.equal('new title') + + await deinitContactContext() + }) + + it('items added by collaborator should be received by shared vault owner', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const newNote = await contactContext.createSyncedNote('new note', 'new note text') + await Collaboration.moveItemToVault(contactContext, sharedVault, newNote) + + await context.sync() + + const receivedNote = context.items.findItem(newNote.uuid) + expect(receivedNote).to.not.be.undefined + expect(receivedNote.title).to.equal('new note') + + await deinitContactContext() + }) + + it('adding item to vault while belonging to other vault should move the item to new vault', async () => { + console.error('TODO: implement test') + }) +}) diff --git a/packages/snjs/mocha/vaults/key_rotation.test.js b/packages/snjs/mocha/vaults/key_rotation.test.js new file mode 100644 index 000000000..ff45df94c --- /dev/null +++ b/packages/snjs/mocha/vaults/key_rotation.test.js @@ -0,0 +1,269 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault key rotation', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let vaults + let sharedVaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + vaults = context.vaults + sharedVaults = context.sharedVaults + }) + + it('should reencrypt all items keys belonging to key system', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.lockSyncing() + + const spy = sinon.spy(context.encryption, 'reencryptKeySystemItemsKeysForVault') + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + expect(spy.callCount).to.equal(1) + + deinitContactContext() + }) + + it("rotating a vault's key should send an asymmetric message to all members", async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.lockSyncing() + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + const outboundMessages = await context.asymmetric.getOutboundMessages() + const expectedMessages = ['root key change', 'vault metadata change'] + expect(outboundMessages.length).to.equal(expectedMessages.length) + + const message = outboundMessages[0] + expect(message).to.not.be.undefined + expect(message.user_uuid).to.equal(contactContext.userUuid) + expect(message.encrypted_message).to.not.be.undefined + + await deinitContactContext() + }) + + it('should update recipient vault display listing with new key params', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.anticipateConsoleError( + '(2x) Error decrypting contentKey from parameters', + 'Items keys are encrypted with new root key and are later decrypted in the test', + ) + + contactContext.lockSyncing() + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + const rootKey = context.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier) + + contactContext.unlockSyncing() + await contactContext.sync() + + const vault = await contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + expect(vault.rootKeyParams).to.eql(rootKey.keyParams) + + await deinitContactContext() + }) + + it('should receive new key system items key', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.anticipateConsoleError( + '(2x) Error decrypting contentKey from parameters', + 'Items keys are encrypted with new root key and are later decrypted in the test', + ) + contactContext.lockSyncing() + + const previousPrimaryItemsKey = contactContext.keys.getPrimaryKeySystemItemsKey(sharedVault.systemIdentifier) + expect(previousPrimaryItemsKey).to.not.be.undefined + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + const contactPromise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + contactContext.unlockSyncing() + await contactContext.sync() + await contactPromise + + const newPrimaryItemsKey = contactContext.keys.getPrimaryKeySystemItemsKey(sharedVault.systemIdentifier) + expect(newPrimaryItemsKey).to.not.be.undefined + + expect(newPrimaryItemsKey.uuid).to.not.equal(previousPrimaryItemsKey.uuid) + expect(newPrimaryItemsKey.itemsKey).to.not.eql(previousPrimaryItemsKey.itemsKey) + + await deinitContactContext() + }) + + it("rotating a vault's key with a pending invite should create new invite and delete old", async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithUnacceptedButTrustedInvite(context) + contactContext.lockSyncing() + + const originalOutboundInvites = await sharedVaults.getOutboundInvites() + expect(originalOutboundInvites.length).to.equal(1) + const originalInviteMessage = originalOutboundInvites[0].encrypted_message + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + const updatedOutboundInvites = await sharedVaults.getOutboundInvites() + expect(updatedOutboundInvites.length).to.equal(1) + + const joinInvite = updatedOutboundInvites[0] + expect(joinInvite.encrypted_message).to.not.be.undefined + expect(joinInvite.encrypted_message).to.not.equal(originalInviteMessage) + + await deinitContactContext() + }) + + it('new key system items key in rotated shared vault should belong to shared vault', async () => { + const sharedVault = await Collaboration.createSharedVault(context) + + await vaults.rotateVaultRootKey(sharedVault) + + const keySystemItemsKeys = context.keys + .getAllKeySystemItemsKeys() + .filter((key) => key.key_system_identifier === sharedVault.systemIdentifier) + + expect(keySystemItemsKeys.length).to.equal(2) + + for (const key of keySystemItemsKeys) { + expect(key.shared_vault_uuid).to.equal(sharedVault.sharing.sharedVaultUuid) + } + }) + + it('should update existing key-change messages instead of creating new ones', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.lockSyncing() + + const firstPromise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await firstPromise + + const asymmetricMessageAfterFirstChange = await context.asymmetric.getOutboundMessages() + const expectedMessages = ['root key change', 'vault metadata change'] + expect(asymmetricMessageAfterFirstChange.length).to.equal(expectedMessages.length) + + const messageAfterFirstChange = asymmetricMessageAfterFirstChange[0] + + const secondPromise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await secondPromise + + const asymmetricMessageAfterSecondChange = await context.asymmetric.getOutboundMessages() + expect(asymmetricMessageAfterSecondChange.length).to.equal(expectedMessages.length) + + const messageAfterSecondChange = asymmetricMessageAfterSecondChange[0] + expect(messageAfterSecondChange.encrypted_message).to.not.equal(messageAfterFirstChange.encrypted_message) + expect(messageAfterSecondChange.uuid).to.not.equal(messageAfterFirstChange.uuid) + + await deinitContactContext() + }) + + it('key change messages should be automatically processed by trusted contacts', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + contactContext.anticipateConsoleError( + '(2x) Error decrypting contentKey from parameters', + 'Items keys are encrypted with new root key and are later decrypted in the test', + ) + contactContext.lockSyncing() + + const promise = context.resolveWhenSharedVaultKeyRotationInvitesGetSent(sharedVault) + await vaults.rotateVaultRootKey(sharedVault) + await promise + + const acceptMessage = sinon.spy(contactContext.asymmetric, 'handleTrustedSharedVaultRootKeyChangedMessage') + + contactContext.unlockSyncing() + await contactContext.sync() + + expect(acceptMessage.callCount).to.equal(1) + + await deinitContactContext() + }) + + it('should rotate key system root key after removing vault member', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const originalKeySystemRootKey = context.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier) + + await sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + + const newKeySystemRootKey = context.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier) + + expect(newKeySystemRootKey.keyParams.creationTimestamp).to.be.greaterThan( + originalKeySystemRootKey.keyParams.creationTimestamp, + ) + expect(newKeySystemRootKey.key).to.not.equal(originalKeySystemRootKey.key) + + await deinitContactContext() + }) + + it('should throw if attempting to change password of locked vault', async () => { + console.error('TODO: implement') + }) + + it('should respect storage preference when rotating key system root key', async () => { + console.error('TODO: implement') + }) + + it('should change storage preference from synced to local', async () => { + console.error('TODO: implement') + }) + + it('should change storage preference from local to synced', async () => { + console.error('TODO: implement') + }) + + it('should resync key system items key if it is encrypted with noncurrent key system root key', async () => { + console.error('TODO: implement') + }) + + it('should change password type from user inputted to randomized', async () => { + console.error('TODO: implement') + }) + + it('should change password type from randomized to user inputted', async () => { + console.error('TODO: implement') + }) + + it('should not be able to change storage mode of third party vault', async () => { + console.error('TODO: implement') + }) +}) diff --git a/packages/snjs/mocha/vaults/permissions.test.js b/packages/snjs/mocha/vaults/permissions.test.js new file mode 100644 index 000000000..81d475449 --- /dev/null +++ b/packages/snjs/mocha/vaults/permissions.test.js @@ -0,0 +1,133 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vault permissions', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let sharedVaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + sharedVaults = context.sharedVaults + }) + + it('non-admin user should not be able to invite user', async () => { + context.anticipateConsoleError('Could not create invite') + + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInviteAndNote(context) + + const thirdParty = await Collaboration.createContactContext() + const thirdPartyContact = await Collaboration.createTrustedContactForUserOfContext( + contactContext, + thirdParty.contactContext, + ) + const result = await contactContext.sharedVaults.inviteContactToSharedVault( + sharedVault, + thirdPartyContact, + SharedVaultPermission.Write, + ) + + expect(isClientDisplayableError(result)).to.be.true + + await deinitContactContext() + }) + + it('should not be able to leave shared vault as creator', async () => { + context.anticipateConsoleError('Could not delete user') + + const sharedVault = await Collaboration.createSharedVault(context) + + const result = await sharedVaults.removeUserFromSharedVault(sharedVault, context.userUuid) + + expect(isClientDisplayableError(result)).to.be.true + }) + + it('should be able to leave shared vault as added admin', async () => { + const { contactVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context, SharedVaultPermission.Admin) + + const result = await contactContext.sharedVaults.leaveSharedVault(contactVault) + + expect(isClientDisplayableError(result)).to.be.false + + await deinitContactContext() + }) + + it('non-admin user should not be able to create or update vault items keys with the server', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const keySystemItemsKey = contactContext.keys.getKeySystemItemsKeys(sharedVault.systemIdentifier)[0] + + await contactContext.mutator.changeItem(keySystemItemsKey, () => {}) + const promise = contactContext.resolveWithConflicts() + await contactContext.sync() + + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.KeySystemItemsKey) + + await deinitContactContext() + }) + + it('read user should not be able to make changes to items', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context, SharedVaultPermission.Read) + const note = await context.createSyncedNote('foo', 'bar') + await Collaboration.moveItemToVault(context, sharedVault, note) + await contactContext.sync() + + await contactContext.mutator.changeItem({ uuid: note.uuid }, (mutator) => { + mutator.title = 'new title' + }) + + const promise = contactContext.resolveWithConflicts() + await contactContext.sync() + const conflicts = await promise + + expect(conflicts.length).to.equal(1) + expect(conflicts[0].unsaved_item.content_type).to.equal(ContentType.Note) + + await deinitContactContext() + }) + + it('should be able to move item from vault to user as a write user if the item belongs to me', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const note = await contactContext.createSyncedNote('foo', 'bar') + await Collaboration.moveItemToVault(contactContext, sharedVault, note) + await contactContext.sync() + + const promise = contactContext.resolveWithConflicts() + await contactContext.vaults.removeItemFromVault(note) + const conflicts = await promise + + expect(conflicts.length).to.equal(0) + + const duplicateNote = contactContext.findDuplicateNote(note.uuid) + expect(duplicateNote).to.be.undefined + + const existingNote = contactContext.items.findItem(note.uuid) + expect(existingNote.key_system_identifier).to.not.be.ok + + await deinitContactContext() + }) +}) diff --git a/packages/snjs/mocha/vaults/pkc.test.js b/packages/snjs/mocha/vaults/pkc.test.js new file mode 100644 index 000000000..4a431d22d --- /dev/null +++ b/packages/snjs/mocha/vaults/pkc.test.js @@ -0,0 +1,98 @@ +import * as Factory from '../lib/factory.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('public key cryptography', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let sessions + let encryption + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + sessions = context.application.sessions + encryption = context.encryption + }) + + it('should create keypair during registration', () => { + expect(sessions.getPublicKey()).to.not.be.undefined + expect(encryption.getKeyPair().privateKey).to.not.be.undefined + + expect(sessions.getSigningPublicKey()).to.not.be.undefined + expect(encryption.getSigningKeyPair().privateKey).to.not.be.undefined + }) + + it('should populate keypair during sign in', async () => { + const email = context.email + const password = context.password + await context.signout() + + const recreatedContext = await Factory.createAppContextWithRealCrypto() + await recreatedContext.launch() + recreatedContext.email = email + recreatedContext.password = password + await recreatedContext.signIn() + + expect(recreatedContext.sessions.getPublicKey()).to.not.be.undefined + expect(recreatedContext.encryption.getKeyPair().privateKey).to.not.be.undefined + + expect(recreatedContext.sessions.getSigningPublicKey()).to.not.be.undefined + expect(recreatedContext.encryption.getSigningKeyPair().privateKey).to.not.be.undefined + }) + + it('should rotate keypair during password change', async () => { + const oldPublicKey = sessions.getPublicKey() + const oldPrivateKey = encryption.getKeyPair().privateKey + + const oldSigningPublicKey = sessions.getSigningPublicKey() + const oldSigningPrivateKey = encryption.getSigningKeyPair().privateKey + + await context.changePassword('new_password') + + expect(sessions.getPublicKey()).to.not.be.undefined + expect(encryption.getKeyPair().privateKey).to.not.be.undefined + expect(sessions.getPublicKey()).to.not.equal(oldPublicKey) + expect(encryption.getKeyPair().privateKey).to.not.equal(oldPrivateKey) + + expect(sessions.getSigningPublicKey()).to.not.be.undefined + expect(encryption.getSigningKeyPair().privateKey).to.not.be.undefined + expect(sessions.getSigningPublicKey()).to.not.equal(oldSigningPublicKey) + expect(encryption.getSigningKeyPair().privateKey).to.not.equal(oldSigningPrivateKey) + }) + + it('should allow option to enable collaboration for previously signed in accounts', async () => { + const newContext = await Factory.createAppContextWithRealCrypto() + await newContext.launch() + + await newContext.register() + + const rootKey = await newContext.encryption.getRootKey() + const mutatedRootKey = CreateNewRootKey({ + ...rootKey.content, + encryptionKeyPair: undefined, + signingKeyPair: undefined, + }) + + await newContext.encryption.setRootKey(mutatedRootKey) + + expect(newContext.application.sessions.isUserMissingKeyPair()).to.be.true + + const result = await newContext.application.user.updateAccountWithFirstTimeKeyPair() + expect(result.error).to.be.undefined + + expect(newContext.application.sessions.isUserMissingKeyPair()).to.be.false + }) +}) diff --git a/packages/snjs/mocha/vaults/shared_vaults.test.js b/packages/snjs/mocha/vaults/shared_vaults.test.js new file mode 100644 index 000000000..13bb987f7 --- /dev/null +++ b/packages/snjs/mocha/vaults/shared_vaults.test.js @@ -0,0 +1,114 @@ +import * as Factory from '../lib/factory.js' +import * as Collaboration from '../lib/Collaboration.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('shared vaults', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let vaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + await context.register() + + vaults = context.vaults + }) + + it('should update vault name and description', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + await vaults.changeVaultNameAndDescription(sharedVault, { + name: 'new vault name', + description: 'new vault description', + }) + + const updatedVault = vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + expect(updatedVault.name).to.equal('new vault name') + expect(updatedVault.description).to.equal('new vault description') + + const promise = contactContext.resolveWhenAsymmetricMessageProcessingCompletes() + await contactContext.sync() + await promise + + const contactVault = contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier }) + expect(contactVault.name).to.equal('new vault name') + expect(contactVault.description).to.equal('new vault description') + + await deinitContactContext() + }) + + it('being removed from a shared vault should remove the vault', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const result = await context.sharedVaults.removeUserFromSharedVault(sharedVault, contactContext.userUuid) + + expect(result).to.be.undefined + + const promise = contactContext.resolveWhenUserMessagesProcessingCompletes() + await contactContext.sync() + await promise + + expect(contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier })).to.be.undefined + expect(contactContext.encryption.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier)).to.be.undefined + expect(contactContext.encryption.keys.getKeySystemItemsKeys(sharedVault.systemIdentifier)).to.be.empty + + const recreatedContext = await Factory.createAppContextWithRealCrypto(contactContext.identifier) + await recreatedContext.launch() + + expect(recreatedContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier })).to.be.undefined + expect(recreatedContext.encryption.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier)).to.be.undefined + expect(recreatedContext.encryption.keys.getKeySystemItemsKeys(sharedVault.systemIdentifier)).to.be.empty + + await deinitContactContext() + await recreatedContext.deinit() + }) + + it('deleting a shared vault should remove vault from contact context', async () => { + const { sharedVault, contactContext, deinitContactContext } = + await Collaboration.createSharedVaultWithAcceptedInvite(context) + + const result = await context.sharedVaults.deleteSharedVault(sharedVault) + + expect(result).to.be.undefined + + const promise = contactContext.resolveWhenUserMessagesProcessingCompletes() + await contactContext.sync() + await promise + + expect(contactContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier })).to.be.undefined + expect(contactContext.encryption.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier)).to.be.undefined + expect(contactContext.encryption.keys.getKeySystemItemsKeys(sharedVault.systemIdentifier)).to.be.empty + + const recreatedContext = await Factory.createAppContextWithRealCrypto(contactContext.identifier) + await recreatedContext.launch() + + expect(recreatedContext.vaults.getVault({ keySystemIdentifier: sharedVault.systemIdentifier })).to.be.undefined + expect(recreatedContext.encryption.keys.getPrimaryKeySystemRootKey(sharedVault.systemIdentifier)).to.be.undefined + expect(recreatedContext.encryption.keys.getKeySystemItemsKeys(sharedVault.systemIdentifier)).to.be.empty + + await deinitContactContext() + await recreatedContext.deinit() + }) + + it('should convert a vault to a shared vault', async () => { + console.error('TODO') + }) + + it('should send metadata change message when changing name or description', async () => { + console.error('TODO') + }) +}) diff --git a/packages/snjs/mocha/vaults/vaults.test.js b/packages/snjs/mocha/vaults/vaults.test.js new file mode 100644 index 000000000..4f2fad311 --- /dev/null +++ b/packages/snjs/mocha/vaults/vaults.test.js @@ -0,0 +1,250 @@ +import * as Factory from '../lib/factory.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('vaults', function () { + this.timeout(Factory.TwentySecondTimeout) + + let context + let vaults + + afterEach(async function () { + await context.deinit() + localStorage.clear() + }) + + beforeEach(async function () { + localStorage.clear() + + context = await Factory.createAppContextWithRealCrypto() + + await context.launch() + + vaults = context.vaults + }) + + describe('locking', () => { + it('should throw if attempting to add item to locked vault', async () => { + console.error('TODO: implement') + }) + + it('should throw if attempting to remove item from locked vault', async () => { + console.error('TODO: implement') + }) + + it('locking vault should remove root key and items keys from memory', async () => { + console.error('TODO: implement') + }) + }) + + describe('offline', function () { + it('should be able to create an offline vault', async () => { + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + + expect(vault.systemIdentifier).to.not.be.undefined + expect(typeof vault.systemIdentifier).to.equal('string') + + const keySystemItemsKey = context.keys.getPrimaryKeySystemItemsKey(vault.systemIdentifier) + expect(keySystemItemsKey).to.not.be.undefined + expect(keySystemItemsKey.key_system_identifier).to.equal(vault.systemIdentifier) + expect(keySystemItemsKey.creationTimestamp).to.not.be.undefined + expect(keySystemItemsKey.keyVersion).to.not.be.undefined + }) + + it('should be able to create an offline vault with app passcode', async () => { + await context.application.addPasscode('123') + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + + expect(vault.systemIdentifier).to.not.be.undefined + expect(typeof vault.systemIdentifier).to.equal('string') + + const keySystemItemsKey = context.keys.getPrimaryKeySystemItemsKey(vault.systemIdentifier) + expect(keySystemItemsKey).to.not.be.undefined + expect(keySystemItemsKey.key_system_identifier).to.equal(vault.systemIdentifier) + expect(keySystemItemsKey.creationTimestamp).to.not.be.undefined + expect(keySystemItemsKey.keyVersion).to.not.be.undefined + }) + + it('should add item to offline vault', async () => { + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const item = await context.createSyncedNote() + + await vaults.moveItemToVault(vault, item) + + const updatedItem = context.items.findItem(item.uuid) + expect(updatedItem.key_system_identifier).to.equal(vault.systemIdentifier) + }) + + it('should load data in the correct order at startup to allow vault items and their keys to decrypt', async () => { + const appIdentifier = context.identifier + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const note = await context.createSyncedNote('foo', 'bar') + await vaults.moveItemToVault(vault, note) + await context.deinit() + + const recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + const updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.title).to.equal('foo') + expect(updatedNote.text).to.equal('bar') + + await recreatedContext.deinit() + }) + + describe('porting from offline to online', () => { + it('should maintain vault system identifiers across items after registration', async () => { + const appIdentifier = context.identifier + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const note = await context.createSyncedNote('foo', 'bar') + await vaults.moveItemToVault(vault, note) + + await context.register() + await context.sync() + + await context.deinit() + + const recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + const notes = recreatedContext.notes + expect(notes.length).to.equal(1) + + const updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.title).to.equal('foo') + expect(updatedNote.text).to.equal('bar') + expect(updatedNote.key_system_identifier).to.equal(vault.systemIdentifier) + + await recreatedContext.deinit() + }) + + it('should decrypt vault items', async () => { + const appIdentifier = context.identifier + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const note = await context.createSyncedNote('foo', 'bar') + await vaults.moveItemToVault(vault, note) + + await context.register() + await context.sync() + + await context.deinit() + + const recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + const updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.title).to.equal('foo') + expect(updatedNote.text).to.equal('bar') + + await recreatedContext.deinit() + }) + }) + }) + + describe('online', () => { + beforeEach(async () => { + await context.register() + }) + + it('should create a vault', async () => { + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + expect(vault).to.not.be.undefined + + const keySystemItemsKeys = context.keys.getKeySystemItemsKeys(vault.systemIdentifier) + expect(keySystemItemsKeys.length).to.equal(1) + + const keySystemItemsKey = keySystemItemsKeys[0] + expect(keySystemItemsKey instanceof KeySystemItemsKey).to.be.true + expect(keySystemItemsKey.key_system_identifier).to.equal(vault.systemIdentifier) + }) + + it('should add item to vault', async () => { + const note = await context.createSyncedNote('foo', 'bar') + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + + await vaults.moveItemToVault(vault, note) + + const updatedNote = context.items.findItem(note.uuid) + expect(updatedNote.key_system_identifier).to.equal(vault.systemIdentifier) + }) + + describe('client timing', () => { + it('should load data in the correct order at startup to allow vault items and their keys to decrypt', async () => { + const appIdentifier = context.identifier + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const note = await context.createSyncedNote('foo', 'bar') + await vaults.moveItemToVault(vault, note) + await context.deinit() + + const recreatedContext = await Factory.createAppContextWithRealCrypto(appIdentifier) + await recreatedContext.launch() + + const updatedNote = recreatedContext.items.findItem(note.uuid) + expect(updatedNote.title).to.equal('foo') + expect(updatedNote.text).to.equal('bar') + + await recreatedContext.deinit() + }) + }) + + describe('key system root key rotation', () => { + it('rotating a key system root key should create a new vault items key', async () => { + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + + const keySystemItemsKey = context.keys.getKeySystemItemsKeys(vault.systemIdentifier)[0] + + await vaults.rotateVaultRootKey(vault) + + const updatedKeySystemItemsKey = context.keys.getKeySystemItemsKeys(vault.systemIdentifier)[0] + + expect(updatedKeySystemItemsKey).to.not.be.undefined + expect(updatedKeySystemItemsKey.uuid).to.not.equal(keySystemItemsKey.uuid) + }) + + it('deleting a vault should delete all its items', async () => { + const vault = await vaults.createRandomizedVault({ + name: 'My Vault', + storagePreference: KeySystemRootKeyStorageMode.Synced, + }) + const note = await context.createSyncedNote('foo', 'bar') + await vaults.moveItemToVault(vault, note) + + await vaults.deleteVault(vault) + + const updatedNote = context.items.findItem(note.uuid) + expect(updatedNote).to.be.undefined + }) + }) + }) +}) diff --git a/packages/snjs/package.json b/packages/snjs/package.json index 6fcf8a032..7a9dc3667 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/snjs", - "version": "2.169.6", + "version": "2.200.0", "engines": { "node": ">=16.0.0 <17.0.0" }, @@ -36,7 +36,7 @@ "@babel/core": "*", "@babel/preset-env": "*", "@standardnotes/api": "workspace:*", - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/domain-core": "^1.12.0", "@standardnotes/domain-events": "^2.108.1", "@standardnotes/encryption": "workspace:*", diff --git a/packages/snjs/webpack.dev.js b/packages/snjs/webpack.dev.js index 94c481407..845157139 100644 --- a/packages/snjs/webpack.dev.js +++ b/packages/snjs/webpack.dev.js @@ -1,5 +1,6 @@ -const { merge } = require('webpack-merge'); -const config = require('./webpack.config.js'); +const { merge } = require('webpack-merge') +const config = require('./webpack.config.js') +const webpack = require('webpack') module.exports = merge(config, { mode: 'development', @@ -7,4 +8,9 @@ module.exports = merge(config, { stats: { colors: true, }, -}); + plugins: [ + new webpack.DefinePlugin({ + __IS_DEV__: true, + }), + ], +}) diff --git a/packages/snjs/webpack.prod.js b/packages/snjs/webpack.prod.js index 5d991fbaa..4db463c24 100644 --- a/packages/snjs/webpack.prod.js +++ b/packages/snjs/webpack.prod.js @@ -1,6 +1,12 @@ -const { merge } = require('webpack-merge'); -const config = require('./webpack.config.js'); +const { merge } = require('webpack-merge') +const config = require('./webpack.config.js') +const webpack = require('webpack') module.exports = merge(config, { mode: 'production', -}); + plugins: [ + new webpack.DefinePlugin({ + __IS_DEV__: false, + }), + ], +}) diff --git a/packages/ui-services/package.json b/packages/ui-services/package.json index 8da022518..acd78775f 100644 --- a/packages/ui-services/package.json +++ b/packages/ui-services/package.json @@ -15,13 +15,16 @@ "test": "jest spec" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "@standardnotes/features": "workspace:^", "@standardnotes/filepicker": "workspace:^", + "@standardnotes/models": "workspace:^", "@standardnotes/services": "workspace:^", "@standardnotes/styles": "workspace:^", "@standardnotes/toast": "workspace:^", - "@standardnotes/utils": "workspace:^" + "@standardnotes/utils": "workspace:^", + "mobx": "^6.8.0", + "mobx-react-lite": "^3.4.2" }, "devDependencies": { "@types/jest": "^29.2.3", diff --git a/packages/ui-services/src/Abstract/AbstractUIService.ts b/packages/ui-services/src/Abstract/AbstractUIService.ts new file mode 100644 index 000000000..4818bc8e7 --- /dev/null +++ b/packages/ui-services/src/Abstract/AbstractUIService.ts @@ -0,0 +1,52 @@ +import { AbstractService, InternalEventBusInterface, ApplicationEvent } from '@standardnotes/services' +import { AbstractUIServiceInterface } from './AbstractUIServiceInterface' +import { WebApplicationInterface } from '../WebApplication/WebApplicationInterface' + +export class AbstractUIServicee + extends AbstractService + implements AbstractUIServiceInterface +{ + private unsubApp!: () => void + + constructor( + protected application: WebApplicationInterface, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + this.addAppEventObserverAfterSubclassesFinishConstructing() + } + + async onAppStart() { + return + } + + async onAppEvent(_event: ApplicationEvent) { + return + } + + private addAppEventObserverAfterSubclassesFinishConstructing() { + setTimeout(() => { + this.addAppEventObserver() + }, 0) + } + + private addAppEventObserver() { + if (this.application.isStarted()) { + void this.onAppStart() + } + + this.unsubApp = this.application.addEventObserver(async (event: ApplicationEvent) => { + await this.onAppEvent(event) + if (event === ApplicationEvent.Started) { + void this.onAppStart() + } + }) + } + + override deinit() { + ;(this.application as unknown) = undefined + this.unsubApp() + ;(this.unsubApp as unknown) = undefined + super.deinit() + } +} diff --git a/packages/ui-services/src/Abstract/AbstractUIServiceInterface.ts b/packages/ui-services/src/Abstract/AbstractUIServiceInterface.ts new file mode 100644 index 000000000..4f97f56d7 --- /dev/null +++ b/packages/ui-services/src/Abstract/AbstractUIServiceInterface.ts @@ -0,0 +1,7 @@ +import { ApplicationEvent, ServiceInterface } from '@standardnotes/services' + +export interface AbstractUIServiceInterface + extends ServiceInterface { + onAppStart(): Promise + onAppEvent(event: ApplicationEvent): Promise +} diff --git a/packages/ui-services/src/Alert/WebAlertService.ts b/packages/ui-services/src/Alert/WebAlertService.ts index 0483efc1a..52ef93692 100644 --- a/packages/ui-services/src/Alert/WebAlertService.ts +++ b/packages/ui-services/src/Alert/WebAlertService.ts @@ -4,6 +4,30 @@ import { SKAlert } from '@standardnotes/styles' import { alertDialog, confirmDialog } from './Functions' export class WebAlertService extends AlertService { + override confirmV2(dto: { + text: string + title?: string | undefined + confirmButtonText?: string | undefined + confirmButtonType?: ButtonType | undefined + cancelButtonText?: string | undefined + }): Promise { + return confirmDialog({ + text: dto.text, + title: dto.title, + confirmButtonText: dto.confirmButtonText, + cancelButtonText: dto.cancelButtonText, + confirmButtonStyle: dto.confirmButtonType === ButtonType.Danger ? 'danger' : 'info', + }) + } + + override alertV2(dto: { + text: string + title?: string | undefined + closeButtonText?: string | undefined + }): Promise { + return alertDialog({ text: dto.text, title: dto.title, closeButtonText: dto.closeButtonText }) + } + alert(text: string, title?: string, closeButtonText?: string) { return alertDialog({ text, title, closeButtonText }) } diff --git a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts index 29b197819..7b6a033b0 100644 --- a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts +++ b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts @@ -1,5 +1,5 @@ +import { WebApplicationInterface } from './../../WebApplication/WebApplicationInterface' import { FeatureIdentifier, NoteType } from '@standardnotes/features' -import { WebApplicationInterface } from '@standardnotes/services' import { AegisToAuthenticatorConverter } from './AegisToAuthenticatorConverter' import data from './testData' diff --git a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts index 45c62f2e8..1c062621a 100644 --- a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts +++ b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts @@ -2,7 +2,7 @@ import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { ContentType } from '@standardnotes/common' import { readFileAsText } from '../Utils' import { FeatureIdentifier, NoteType } from '@standardnotes/features' -import { WebApplicationInterface } from '@standardnotes/services' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' type AegisData = { db: { diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index 6a3d7830d..4a6626c3a 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts @@ -3,10 +3,10 @@ */ import { ContentType } from '@standardnotes/common' -import { WebApplicationInterface } from '@standardnotes/services' import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models' import { EvernoteConverter } from './EvernoteConverter' import data from './testData' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' // Mock dayjs so dayjs.extend() doesn't throw an error in EvernoteConverter.ts jest.mock('dayjs', () => { diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts index 6d5309c91..da9a136cd 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -1,10 +1,10 @@ import { ContentType } from '@standardnotes/common' -import { WebApplicationInterface } from '@standardnotes/services' import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models' import { readFileAsText } from '../Utils' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import utc from 'dayjs/plugin/utc' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' dayjs.extend(customParseFormat) dayjs.extend(utc) diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts index 632b9c4df..cd973ef54 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts @@ -2,9 +2,9 @@ * @jest-environment jsdom */ -import { WebApplicationInterface } from '@standardnotes/snjs' import { jsonTestData, htmlTestData } from './testData' import { GoogleKeepConverter } from './GoogleKeepConverter' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' describe('GoogleKeepConverter', () => { let application: WebApplicationInterface diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts index 0be6cd0d4..fae0963f3 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts @@ -1,7 +1,7 @@ -import { WebApplicationInterface } from '@standardnotes/services' import { ContentType } from '@standardnotes/common' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { readFileAsText } from '../Utils' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' type GoogleKeepJsonNote = { color: string diff --git a/packages/ui-services/src/Import/Importer.ts b/packages/ui-services/src/Import/Importer.ts index cd0ab8006..d5ce20d8a 100644 --- a/packages/ui-services/src/Import/Importer.ts +++ b/packages/ui-services/src/Import/Importer.ts @@ -1,5 +1,5 @@ import { parseFileName } from '@standardnotes/filepicker' -import { FeatureStatus, WebApplicationInterface } from '@standardnotes/services' +import { FeatureStatus } from '@standardnotes/services' import { FeatureIdentifier } from '@standardnotes/features' import { AegisToAuthenticatorConverter } from './AegisConverter/AegisToAuthenticatorConverter' import { EvernoteConverter } from './EvernoteConverter/EvernoteConverter' @@ -8,6 +8,7 @@ import { PlaintextConverter } from './PlaintextConverter/PlaintextConverter' import { SimplenoteConverter } from './SimplenoteConverter/SimplenoteConverter' import { readFileAsText } from './Utils' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { WebApplicationInterface } from '../WebApplication/WebApplicationInterface' export type NoteImportType = 'plaintext' | 'evernote' | 'google-keep' | 'simplenote' | 'aegis' @@ -82,7 +83,7 @@ export class Importer { const insertedItems = await Promise.all( payloads.map(async (payload) => { const content = payload.content as NoteContent - const note = this.application.mutator.createTemplateItem( + const note = this.application.items.createTemplateItem( payload.content_type, { text: content.text, diff --git a/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts b/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts index c132dd23a..76220f3e4 100644 --- a/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts +++ b/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' import { parseFileName } from '@standardnotes/filepicker' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' -import { WebApplicationInterface } from '@standardnotes/services' import { readFileAsText } from '../Utils' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' export class PlaintextConverter { constructor(protected application: WebApplicationInterface) {} diff --git a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts index e540ae701..a5a622d09 100644 --- a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts +++ b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts @@ -1,4 +1,4 @@ -import { WebApplicationInterface } from '@standardnotes/services' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' import { SimplenoteConverter } from './SimplenoteConverter' import data from './testData' diff --git a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts index 8b3ffd4b5..6ce4076bc 100644 --- a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts +++ b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts @@ -1,7 +1,7 @@ import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { ContentType } from '@standardnotes/common' import { readFileAsText } from '../Utils' -import { WebApplicationInterface } from '@standardnotes/services' +import { WebApplicationInterface } from '../../WebApplication/WebApplicationInterface' type SimplenoteItem = { creationDate: string diff --git a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts index 684694d16..d1fc2699a 100644 --- a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts +++ b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { Environment, Platform } from '@standardnotes/snjs' +import { Environment, Platform } from '@standardnotes/models' import { isMacPlatform } from './platformCheck' import { CREATE_NEW_NOTE_KEYBOARD_COMMAND, diff --git a/packages/ui-services/src/Preferences/PreferenceId.ts b/packages/ui-services/src/Preferences/PreferenceId.ts index 923de784d..07933f60a 100644 --- a/packages/ui-services/src/Preferences/PreferenceId.ts +++ b/packages/ui-services/src/Preferences/PreferenceId.ts @@ -2,6 +2,7 @@ const PREFERENCE_IDS = [ 'general', 'account', 'security', + 'vaults', 'appearance', 'backups', 'listed', diff --git a/packages/ui-services/src/Theme/ThemeManager.ts b/packages/ui-services/src/Theme/ThemeManager.ts index 411582ee5..389f7d46c 100644 --- a/packages/ui-services/src/Theme/ThemeManager.ts +++ b/packages/ui-services/src/Theme/ThemeManager.ts @@ -8,41 +8,31 @@ import { SNTheme, } from '@standardnotes/models' import { removeFromArray } from '@standardnotes/utils' -import { - AbstractService, - WebApplicationInterface, - InternalEventBusInterface, - ApplicationEvent, - StorageValueModes, - FeatureStatus, -} from '@standardnotes/services' +import { InternalEventBusInterface, ApplicationEvent, StorageValueModes, FeatureStatus } from '@standardnotes/services' import { FeatureIdentifier } from '@standardnotes/features' +import { WebApplicationInterface } from '../WebApplication/WebApplicationInterface' +import { AbstractUIServicee } from '../Abstract/AbstractUIService' const CachedThemesKey = 'cachedThemes' const TimeBeforeApplyingColorScheme = 5 const DefaultThemeIdentifier = 'Default' -export class ThemeManager extends AbstractService { +export class ThemeManager extends AbstractUIServicee { private activeThemes: string[] = [] private unregisterDesktop?: () => void private unregisterStream!: () => void private lastUseDeviceThemeSettings = false - private unsubApp!: () => void - constructor( - protected application: WebApplicationInterface, - protected override internalEventBus: InternalEventBusInterface, - ) { - super(internalEventBus) - this.addAppEventObserverAfterSubclassesFinishConstructing() + constructor(application: WebApplicationInterface, internalEventBus: InternalEventBusInterface) { + super(application, internalEventBus) this.colorSchemeEventHandler = this.colorSchemeEventHandler.bind(this) } - async onAppStart() { + override async onAppStart() { this.registerObservers() } - async onAppEvent(event: ApplicationEvent) { + override async onAppEvent(event: ApplicationEvent) { switch (event) { case ApplicationEvent.SignedOut: { this.deactivateAllThemes() @@ -76,25 +66,6 @@ export class ThemeManager extends AbstractService { } } - addAppEventObserverAfterSubclassesFinishConstructing() { - setTimeout(() => { - this.addAppEventObserver() - }, 0) - } - - addAppEventObserver() { - if (this.application.isStarted()) { - void this.onAppStart() - } - - this.unsubApp = this.application.addEventObserver(async (event: ApplicationEvent) => { - await this.onAppEvent(event) - if (event === ApplicationEvent.Started) { - void this.onAppStart() - } - }) - } - async handleMobileColorSchemeChangeEvent() { const useDeviceThemeSettings = this.application.getPreference(PrefKey.UseSystemColorScheme, false) @@ -124,10 +95,6 @@ export class ThemeManager extends AbstractService { } } - get webApplication() { - return this.application as WebApplicationInterface - } - override deinit() { this.activeThemes.length = 0 @@ -143,11 +110,6 @@ export class ThemeManager extends AbstractService { mq.removeListener(this.colorSchemeEventHandler) } - ;(this.application as unknown) = undefined - - this.unsubApp() - ;(this.unsubApp as unknown) = undefined - super.deinit() } @@ -166,7 +128,7 @@ export class ThemeManager extends AbstractService { const status = this.application.features.getFeatureStatus(theme.identifier) if (status !== FeatureStatus.Entitled) { if (theme.active) { - this.application.mutator.toggleTheme(theme).catch(console.error) + this.application.componentManager.toggleTheme(theme.uuid).catch(console.error) } else { this.deactivateTheme(theme.uuid) } @@ -242,7 +204,7 @@ export class ThemeManager extends AbstractService { const toggleActiveTheme = () => { if (activeTheme) { - void this.application.mutator.toggleTheme(activeTheme) + void this.application.componentManager.toggleTheme(activeTheme.uuid) } } @@ -252,7 +214,7 @@ export class ThemeManager extends AbstractService { } else { const theme = themes.find((theme) => theme.package_info.identifier === themeIdentifier) if (theme && !theme.active) { - this.application.mutator.toggleTheme(theme).catch(console.error) + this.application.componentManager.toggleTheme(theme.uuid).catch(console.error) } } } @@ -272,7 +234,7 @@ export class ThemeManager extends AbstractService { } private registerObservers() { - this.unregisterDesktop = this.webApplication.getDesktopService()?.registerUpdateObserver((component) => { + this.unregisterDesktop = this.application.getDesktopService()?.registerUpdateObserver((component) => { if (component.active && component.isTheme()) { this.deactivateTheme(component.uuid) setTimeout(() => { diff --git a/packages/ui-services/src/Vaults/VaultDisplayService.ts b/packages/ui-services/src/Vaults/VaultDisplayService.ts new file mode 100644 index 000000000..d02a23911 --- /dev/null +++ b/packages/ui-services/src/Vaults/VaultDisplayService.ts @@ -0,0 +1,216 @@ +import { + ApplicationEvent, + ApplicationStage, + ApplicationStageChangedEventPayload, + Challenge, + ChallengePrompt, + ChallengeReason, + ChallengeStrings, + ChallengeValidation, + InternalEventBusInterface, + InternalEventHandlerInterface, + InternalEventInterface, + StorageKey, + VaultServiceEvent, +} from '@standardnotes/services' +import { VaultDisplayOptions, VaultDisplayOptionsPersistable, VaultListingInterface } from '@standardnotes/models' +import { VaultDisplayServiceEvent } from './VaultDisplayServiceEvent' +import { AbstractUIServicee } from '../Abstract/AbstractUIService' +import { WebApplicationInterface } from '../WebApplication/WebApplicationInterface' +import { VaultDisplayServiceInterface } from './VaultDisplayServiceInterface' +import { action, makeObservable, observable } from 'mobx' + +export class VaultDisplayService + extends AbstractUIServicee + implements VaultDisplayServiceInterface, InternalEventHandlerInterface +{ + options: VaultDisplayOptions + + public exclusivelyShownVault: VaultListingInterface | undefined = undefined + + constructor(application: WebApplicationInterface, internalEventBus: InternalEventBusInterface) { + super(application, internalEventBus) + + this.options = new VaultDisplayOptions({ exclude: [], locked: [] }) + + internalEventBus.addEventHandler(this, VaultServiceEvent.VaultLocked) + internalEventBus.addEventHandler(this, VaultServiceEvent.VaultUnlocked) + internalEventBus.addEventHandler(this, ApplicationEvent.ApplicationStageChanged) + + makeObservable(this, { + options: observable, + + isVaultExplicitelyExcluded: observable, + isVaultExclusivelyShown: observable, + exclusivelyShownVault: observable, + + hideVault: action, + unhideVault: action, + showOnlyVault: action, + }) + } + + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === VaultServiceEvent.VaultLocked || event.type === VaultServiceEvent.VaultUnlocked) { + this.handleVaultLockingStatusChanged() + } else if (event.type === ApplicationEvent.ApplicationStageChanged) { + const stage = (event.payload as ApplicationStageChangedEventPayload).stage + if (stage === ApplicationStage.StorageDecrypted_09) { + void this.loadVaultSelectionOptionsFromDisk() + } + } + } + + private handleVaultLockingStatusChanged(): void { + const lockedVaults = this.application.vaults.getLockedvaults() + + const options = this.options.newOptionsByIntakingLockedVaults(lockedVaults) + this.setVaultSelectionOptions(options) + } + + public getOptions(): VaultDisplayOptions { + return this.options + } + + isVaultExplicitelyExcluded = (vault: VaultListingInterface): boolean => { + return this.options.isVaultExplicitelyExcluded(vault) ?? false + } + + isVaultDisabledOrLocked(vault: VaultListingInterface): boolean { + return this.options.isVaultDisabledOrLocked(vault) + } + + isVaultExclusivelyShown = (vault: VaultListingInterface): boolean => { + return this.options.isVaultExclusivelyShown(vault) + } + + isInExclusiveDisplayMode(): boolean { + return this.options.isInExclusiveDisplayMode() + } + + changeToMultipleVaultDisplayMode(): void { + const vaults = this.application.vaults.getVaults() + const lockedVaults = this.application.vaults.getLockedvaults() + + const newOptions = new VaultDisplayOptions({ + exclude: vaults + .map((vault) => vault.systemIdentifier) + .filter((identifier) => identifier !== this.exclusivelyShownVault?.systemIdentifier), + locked: lockedVaults.map((vault) => vault.systemIdentifier), + }) + + this.setVaultSelectionOptions(newOptions) + } + + hideVault = (vault: VaultListingInterface) => { + const lockedVaults = this.application.vaults.getLockedvaults() + const newOptions = this.options.newOptionsByExcludingVault(vault, lockedVaults) + this.setVaultSelectionOptions(newOptions) + } + + unhideVault = async (vault: VaultListingInterface) => { + if (this.application.vaults.isVaultLocked(vault)) { + const unlocked = await this.unlockVault(vault) + if (!unlocked) { + return + } + } + + const lockedVaults = this.application.vaults.getLockedvaults() + const newOptions = this.options.newOptionsByUnexcludingVault(vault, lockedVaults) + this.setVaultSelectionOptions(newOptions) + } + + showOnlyVault = async (vault: VaultListingInterface) => { + if (this.application.vaults.isVaultLocked(vault)) { + const unlocked = await this.unlockVault(vault) + if (!unlocked) { + return + } + } + + const newOptions = new VaultDisplayOptions({ exclusive: vault.systemIdentifier }) + this.setVaultSelectionOptions(newOptions) + } + + async unlockVault(vault: VaultListingInterface): Promise { + if (!this.application.vaults.isVaultLocked(vault)) { + throw new Error('Attempting to unlock a vault that is not locked.') + } + + const challenge = new Challenge( + [new ChallengePrompt(ChallengeValidation.None, undefined, 'Password')], + ChallengeReason.Custom, + true, + ChallengeStrings.UnlockVault(vault.name), + ChallengeStrings.EnterVaultPassword, + ) + + return new Promise((resolve) => { + this.application.challenges.addChallengeObserver(challenge, { + onCancel() { + resolve(false) + }, + onNonvalidatedSubmit: async (challengeResponse) => { + const value = challengeResponse.getDefaultValue() + if (!value) { + this.application.challenges.completeChallenge(challenge) + resolve(false) + return + } + + const password = value.value as string + + const unlocked = await this.application.vaults.unlockNonPersistentVault(vault, password) + if (!unlocked) { + this.application.challenges.setValidationStatusForChallenge(challenge, value, false) + resolve(false) + return + } + + this.application.challenges.completeChallenge(challenge) + resolve(true) + }, + }) + + void this.application.challenges.promptForChallengeResponse(challenge) + }) + } + + private setVaultSelectionOptions = (options: VaultDisplayOptions) => { + this.options = options + + if (this.isInExclusiveDisplayMode()) { + this.exclusivelyShownVault = this.application.vaults.getVault({ + keySystemIdentifier: this.options.getExclusivelyShownVault(), + }) + } else { + this.exclusivelyShownVault = undefined + } + + this.application.items.setVaultDisplayOptions(options) + + void this.notifyEvent(VaultDisplayServiceEvent.VaultDisplayOptionsChanged, options) + + if (this.application.isLaunched()) { + this.application.setValue(StorageKey.VaultSelectionOptions, options.getPersistableValue()) + } + } + + private loadVaultSelectionOptionsFromDisk = (): void => { + const raw = this.application.getValue(StorageKey.VaultSelectionOptions) + if (!raw) { + return + } + + const options = VaultDisplayOptions.FromPersistableValue(raw) + + this.options = options + void this.notifyEvent(VaultDisplayServiceEvent.VaultDisplayOptionsChanged, options) + } + + override deinit(): void { + ;(this.options as unknown) = undefined + super.deinit() + } +} diff --git a/packages/ui-services/src/Vaults/VaultDisplayServiceEvent.ts b/packages/ui-services/src/Vaults/VaultDisplayServiceEvent.ts new file mode 100644 index 000000000..754dd0d79 --- /dev/null +++ b/packages/ui-services/src/Vaults/VaultDisplayServiceEvent.ts @@ -0,0 +1,3 @@ +export enum VaultDisplayServiceEvent { + VaultDisplayOptionsChanged = 'VaultDisplayOptionsChanged', +} diff --git a/packages/ui-services/src/Vaults/VaultDisplayServiceInterface.ts b/packages/ui-services/src/Vaults/VaultDisplayServiceInterface.ts new file mode 100644 index 000000000..00993679d --- /dev/null +++ b/packages/ui-services/src/Vaults/VaultDisplayServiceInterface.ts @@ -0,0 +1,20 @@ +import { VaultDisplayOptions, VaultListingInterface } from '@standardnotes/models' +import { AbstractUIServiceInterface } from '../Abstract/AbstractUIServiceInterface' + +export interface VaultDisplayServiceInterface extends AbstractUIServiceInterface { + exclusivelyShownVault?: VaultListingInterface + + getOptions(): VaultDisplayOptions + + isVaultDisabledOrLocked(vault: VaultListingInterface): boolean + isVaultExplicitelyExcluded: (vault: VaultListingInterface) => boolean + isVaultExclusivelyShown: (vault: VaultListingInterface) => boolean + isInExclusiveDisplayMode(): boolean + + changeToMultipleVaultDisplayMode(): void + + hideVault: (vault: VaultListingInterface) => void + unhideVault: (vault: VaultListingInterface) => void + showOnlyVault: (vault: VaultListingInterface) => void + unlockVault(vault: VaultListingInterface): Promise +} diff --git a/packages/services/src/Domain/Application/WebApplicationInterface.ts b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts similarity index 78% rename from packages/services/src/Domain/Application/WebApplicationInterface.ts rename to packages/ui-services/src/WebApplication/WebApplicationInterface.ts index 9395f59d4..79f7a8aac 100644 --- a/packages/services/src/Domain/Application/WebApplicationInterface.ts +++ b/packages/ui-services/src/WebApplication/WebApplicationInterface.ts @@ -1,7 +1,9 @@ -import { MobileDeviceInterface } from './../Device/MobileDeviceInterface' -import { DesktopManagerInterface } from '../Device/DesktopManagerInterface' -import { WebAppEvent } from '../Event/WebAppEvent' -import { ApplicationInterface } from './ApplicationInterface' +import { + ApplicationInterface, + DesktopManagerInterface, + MobileDeviceInterface, + WebAppEvent, +} from '@standardnotes/services' export interface WebApplicationInterface extends ApplicationInterface { notifyWebEvent(event: WebAppEvent, data?: unknown): void diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index 9e397f7df..b64941b7d 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -33,3 +33,9 @@ export * from './Toast/ToastService' export * from './Toast/ToastServiceInterface' export * from './StatePersistence/StatePersistence' export * from './Import' + +export * from './Vaults/VaultDisplayService' +export * from './Vaults/VaultDisplayServiceEvent' +export * from './Vaults/VaultDisplayServiceInterface' + +export * from './WebApplication/WebApplicationInterface' diff --git a/packages/utils/package.json b/packages/utils/package.json index ee98286ca..dfb1ea2ca 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -25,7 +25,7 @@ "test": "jest spec" }, "dependencies": { - "@standardnotes/common": "^1.46.6", + "@standardnotes/common": "^1.48.3", "dompurify": "^2.4.1", "lodash": "^4.17.21", "reflect-metadata": "^0.1.13" diff --git a/packages/web/package.json b/packages/web/package.json index f3c79021d..4ea9f98ae 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,7 +16,7 @@ "lint:fix": "eslint src/javascripts --fix", "start": "webpack-dev-server --config web.webpack.dev.js", "start-secure": "yarn start --server-type https", - "test": "jest --config jest.config.js --coverage", + "test": "jest --config jest.config.js", "tsc": "tsc --project tsconfig.json", "upgrade:snjs": "ncu -u '@standardnotes/*'", "watch": "webpack -w --config web.webpack.dev.js", diff --git a/packages/web/src/javascripts/Application/Device/DesktopManager.ts b/packages/web/src/javascripts/Application/Device/DesktopManager.ts index e1cb86cf3..d4027c099 100644 --- a/packages/web/src/javascripts/Application/Device/DesktopManager.ts +++ b/packages/web/src/javascripts/Application/Device/DesktopManager.ts @@ -13,11 +13,11 @@ import { assert, DesktopClientRequiresWebMethods, DesktopDeviceInterface, - WebApplicationInterface, WebAppEvent, BackupServiceInterface, DesktopWatchedDirectoriesChanges, } from '@standardnotes/snjs' +import { WebApplicationInterface } from '@standardnotes/ui-services' export class DesktopManager extends ApplicationService @@ -175,7 +175,7 @@ export class DesktopManager return } - const updatedComponent = await this.application.mutator.changeAndSaveItem( + const updatedComponent = await this.application.changeAndSaveItem( component, (m) => { const mutator = m as ComponentMutator diff --git a/packages/web/src/javascripts/Application/Device/WebOrDesktopDevice.ts b/packages/web/src/javascripts/Application/Device/WebOrDesktopDevice.ts index e4cc3b428..dc1699f88 100644 --- a/packages/web/src/javascripts/Application/Device/WebOrDesktopDevice.ts +++ b/packages/web/src/javascripts/Application/Device/WebOrDesktopDevice.ts @@ -108,29 +108,46 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface identifier: string, ): Promise { const entries = await this.getAllDatabaseEntries(identifier) - const sorted = GetSortedPayloadsByPriority(entries, options) + + const { + itemsKeyPayloads, + keySystemRootKeyPayloads, + keySystemItemsKeyPayloads, + contentTypePriorityPayloads, + remainingPayloads, + } = GetSortedPayloadsByPriority(entries, options) const itemsKeysChunk: DatabaseFullEntryLoadChunk = { - entries: sorted.itemsKeyPayloads, + entries: itemsKeyPayloads, + } + + const keySystemRootKeysChunk: DatabaseFullEntryLoadChunk = { + entries: keySystemRootKeyPayloads, + } + + const keySystemItemsKeysChunk: DatabaseFullEntryLoadChunk = { + entries: keySystemItemsKeyPayloads, } const contentTypePriorityChunk: DatabaseFullEntryLoadChunk = { - entries: sorted.contentTypePriorityPayloads, + entries: contentTypePriorityPayloads, } const remainingPayloadsChunks: DatabaseFullEntryLoadChunk[] = [] - for (let i = 0; i < sorted.remainingPayloads.length; i += options.batchSize) { + for (let i = 0; i < remainingPayloads.length; i += options.batchSize) { remainingPayloadsChunks.push({ - entries: sorted.remainingPayloads.slice(i, i + options.batchSize), + entries: remainingPayloads.slice(i, i + options.batchSize), }) } const result: DatabaseFullEntryLoadChunkResponse = { fullEntries: { itemsKeys: itemsKeysChunk, + keySystemRootKeys: keySystemRootKeysChunk, + keySystemItemsKeys: keySystemItemsKeysChunk, remainingChunks: [contentTypePriorityChunk, ...remainingPayloadsChunks], }, - remainingChunksItemCount: sorted.contentTypePriorityPayloads.length + sorted.remainingPayloads.length, + remainingChunksItemCount: contentTypePriorityPayloads.length + remainingPayloads.length, } return result diff --git a/packages/web/src/javascripts/Application/WebApplication.ts b/packages/web/src/javascripts/Application/WebApplication.ts index 0c3a4a155..6c9548bf8 100644 --- a/packages/web/src/javascripts/Application/WebApplication.ts +++ b/packages/web/src/javascripts/Application/WebApplication.ts @@ -14,7 +14,6 @@ import { ContentType, DecryptedItemInterface, WebAppEvent, - WebApplicationInterface, MobileDeviceInterface, MobileUnlockTiming, DecryptedItem, @@ -27,7 +26,7 @@ import { import { makeObservable, observable } from 'mobx' import { startAuthentication, startRegistration } from '@simplewebauthn/browser' import { PanelResizedData } from '@/Types/PanelResizedData' -import { isAndroid, isDesktopApplication, isIOS } from '@/Utils' +import { isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils' import { DesktopManager } from './Device/DesktopManager' import { ArchiveManager, @@ -38,7 +37,10 @@ import { RouteService, RouteServiceInterface, ThemeManager, + VaultDisplayService, + VaultDisplayServiceInterface, WebAlertService, + WebApplicationInterface, } from '@standardnotes/ui-services' import { MobileWebReceiver, NativeMobileEventListener } from '../NativeMobileWeb/MobileWebReceiver' import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler' @@ -49,6 +51,7 @@ import { FeatureName } from '@/Controllers/FeatureName' import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController' import { VisibilityObserver } from './VisibilityObserver' import { MomentsService } from '@/Controllers/Moments/MomentsService' +import { purchaseMockSubscription } from '@/Utils/Dev/PurchaseMockSubscription' export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void @@ -114,6 +117,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.webServices.viewControllerManager.filesController, this.internalEventBus, ) + this.webServices.vaultDisplayService = new VaultDisplayService(this, this.internalEventBus) if (this.isNativeMobileWeb()) { this.mobileWebReceiver = new MobileWebReceiver(this) @@ -194,6 +198,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.notifyWebEvent(WebAppEvent.PanelResized, data) } + public get vaultDisplayService(): VaultDisplayServiceInterface { + return this.webServices.vaultDisplayService + } + public getViewControllerManager(): ViewControllerManager { return this.webServices.viewControllerManager } @@ -450,4 +458,12 @@ export class WebApplication extends SNApplication implements WebApplicationInter generateUUID(): string { return this.options.crypto.generateUUID() } + + dev__purchaseMockSubscription() { + if (!isDev) { + throw new Error('This method is only available in dev mode') + } + + void purchaseMockSubscription(this.getUser()?.email as string, 2000) + } } diff --git a/packages/web/src/javascripts/Application/WebServices.ts b/packages/web/src/javascripts/Application/WebServices.ts index fc7ab6a6c..c47c3c0ba 100644 --- a/packages/web/src/javascripts/Application/WebServices.ts +++ b/packages/web/src/javascripts/Application/WebServices.ts @@ -6,6 +6,7 @@ import { ChangelogServiceInterface, KeyboardService, ThemeManager, + VaultDisplayServiceInterface, } from '@standardnotes/ui-services' import { MomentsService } from '@/Controllers/Moments/MomentsService' @@ -18,4 +19,5 @@ export type WebServices = { keyboardService: KeyboardService changelogService: ChangelogServiceInterface momentsService: MomentsService + vaultDisplayService: VaultDisplayServiceInterface } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 81d74fcc5..765face2f 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -77,14 +77,14 @@ const ChangeEditorMenu: FunctionComponent = ({ const selectComponent = useCallback( async (component: SNComponent, note: SNNote) => { if (component.conflictOf) { - void application.mutator.changeAndSaveItem(component, (mutator) => { + void application.changeAndSaveItem(component, (mutator) => { mutator.conflictOf = undefined }) } await application.getViewControllerManager().itemListController.insertCurrentIfTemplate() - await application.mutator.changeAndSaveItem(note, (mutator) => { + await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator noteMutator.noteType = component.noteType noteMutator.editorIdentifier = component.identifier @@ -101,7 +101,7 @@ const ChangeEditorMenu: FunctionComponent = ({ reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) - await application.mutator.changeAndSaveItem(note, (mutator) => { + await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator noteMutator.noteType = item.noteType noteMutator.editorIdentifier = undefined diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx index 903631dd0..8e76f5ada 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeMultipleMenu.tsx @@ -42,12 +42,12 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop const selectComponent = useCallback( async (component: SNComponent, note: SNNote) => { if (component.conflictOf) { - void application.mutator.changeAndSaveItem(component, (mutator) => { + void application.changeAndSaveItem(component, (mutator) => { mutator.conflictOf = undefined }) } - await application.mutator.changeAndSaveItem(note, (mutator) => { + await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator noteMutator.noteType = component.noteType noteMutator.editorIdentifier = component.identifier @@ -58,7 +58,7 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop const selectNonComponent = useCallback( async (item: EditorMenuItem, note: SNNote) => { - await application.mutator.changeAndSaveItem(note, (mutator) => { + await application.changeAndSaveItem(note, (mutator) => { const noteMutator = mutator as NoteMutator noteMutator.noteType = item.noteType noteMutator.editorIdentifier = undefined diff --git a/packages/web/src/javascripts/Components/ClipperView/ClippedNoteView.tsx b/packages/web/src/javascripts/Components/ClipperView/ClippedNoteView.tsx index 3236652fb..899859aba 100644 --- a/packages/web/src/javascripts/Components/ClipperView/ClippedNoteView.tsx +++ b/packages/web/src/javascripts/Components/ClipperView/ClippedNoteView.tsx @@ -64,6 +64,7 @@ const ClippedNoteView = ({ setIsDiscarding(true) application.mutator .deleteItem(note) + .then(() => application.sync.sync()) .then(() => { if (isFirefoxPopup) { window.close() @@ -73,7 +74,7 @@ const ClippedNoteView = ({ .catch(console.error) .finally(() => setIsDiscarding(false)) } - }, [application.mutator, clearClip, isFirefoxPopup, note]) + }, [application.mutator, application.sync, clearClip, isFirefoxPopup, note]) return (
diff --git a/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx b/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx index 2e9fdc7cb..6eac72f01 100644 --- a/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx +++ b/packages/web/src/javascripts/Components/ClipperView/ClipperView.tsx @@ -217,7 +217,7 @@ const ClipperView = ({ references: [], }) - const insertedNote = await application.items.insertItem(note) + const insertedNote = await application.mutator.insertItem(note) if (defaultTagRef.current) { await application.linkingController.linkItems(insertedNote, defaultTagRef.current) @@ -237,6 +237,7 @@ const ClipperView = ({ }, [ application.items, application.linkingController, + application.mutator, application.sync, clipPayload, defaultTagRef, diff --git a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx index 43bc68d7e..97fe843a6 100644 --- a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx @@ -13,6 +13,7 @@ import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType' import { useApplication } from '../ApplicationProvider' import { PaneLayout } from '@/Controllers/PaneController/PaneLayout' import ListItemFlagIcons from './ListItemFlagIcons' +import ListItemVaultInfo from './ListItemVaultInfo' const FileListItemCard: FunctionComponent> = ({ filesController, @@ -103,6 +104,7 @@ const FileListItemCard: FunctionComponent> = +
diff --git a/packages/web/src/javascripts/Components/ContentListView/FileListItemCard.tsx b/packages/web/src/javascripts/Components/ContentListView/FileListItemCard.tsx index c80bdcc0a..285730575 100644 --- a/packages/web/src/javascripts/Components/ContentListView/FileListItemCard.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/FileListItemCard.tsx @@ -14,6 +14,7 @@ import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType' import { useApplication } from '../ApplicationProvider' import Icon from '../Icon/Icon' import { PaneLayout } from '@/Controllers/PaneController/PaneLayout' +import ListItemVaultInfo from './ListItemVaultInfo' const FileListItemCard: FunctionComponent> = ({ filesController, @@ -106,6 +107,7 @@ const FileListItemCard: FunctionComponent> = +
)} -
+
{panelTitle}
{showSyncSubtitle &&
{syncSubtitle}
} {optionsSubtitle &&
{optionsSubtitle}
}
+
) - }, [optionsSubtitle, showSyncSubtitle, icon, panelTitle, syncSubtitle]) + }, [optionsSubtitle, selectedTag, showSyncSubtitle, icon, panelTitle, syncSubtitle]) const PhoneAndDesktopLayout = useMemo(() => { return ( diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx index 827f75e4c..83c2ccb25 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx @@ -181,7 +181,7 @@ const DisplayOptionsMenu: FunctionComponent = ({ } else if (isSystemTag) { await changeSystemViewPreferences(properties) } else { - await application.mutator.changeAndSaveItem(selectedTag, (mutator) => { + await application.changeAndSaveItem(selectedTag, (mutator) => { mutator.preferences = { ...mutator.preferences, ...properties, @@ -189,7 +189,7 @@ const DisplayOptionsMenu: FunctionComponent = ({ }) } }, - [currentMode, isSystemTag, changeGlobalPreferences, changeSystemViewPreferences, application.mutator, selectedTag], + [currentMode, isSystemTag, changeGlobalPreferences, changeSystemViewPreferences, application, selectedTag], ) const resetTagPreferences = useCallback(async () => { @@ -202,7 +202,7 @@ const DisplayOptionsMenu: FunctionComponent = ({ return } - void application.mutator.changeAndSaveItem(selectedTag, (mutator) => { + void application.changeAndSaveItem(selectedTag, (mutator) => { mutator.preferences = undefined }) }, [application, isSystemTag, reloadPreferences, selectedTag]) diff --git a/packages/web/src/javascripts/Components/ContentListView/ListItemVaultInfo.tsx b/packages/web/src/javascripts/Components/ContentListView/ListItemVaultInfo.tsx new file mode 100644 index 000000000..e3dc54768 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/ListItemVaultInfo.tsx @@ -0,0 +1,46 @@ +import { FunctionComponent } from 'react' +import { useApplication } from '../ApplicationProvider' +import Icon from '../Icon/Icon' +import { DecryptedItemInterface } from '@standardnotes/snjs' +import VaultNameBadge from '../Vaults/VaultNameBadge' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' + +type Props = { + item: DecryptedItemInterface +} + +const ListItemVaultInfo: FunctionComponent = ({ item }) => { + const application = useApplication() + + if (!featureTrunkVaultsEnabled()) { + return null + } + + if (application.items.isTemplateItem(item)) { + return null + } + + const vault = application.vaults.getItemVault(item) + if (!vault) { + return null + } + + const sharedByContact = application.sharedVaults.getItemSharedBy(item) + + return ( +
+ + + {sharedByContact && ( +
+ + +
{sharedByContact?.name}
+
+
+ )} +
+ ) +} + +export default ListItemVaultInfo diff --git a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx index b0a031f56..bfd46b48a 100644 --- a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx @@ -13,6 +13,7 @@ import { ListItemTitle } from './ListItemTitle' import { log, LoggingDomain } from '@/Logging' import { classNames } from '@standardnotes/utils' import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType' +import ListItemVaultInfo from './ListItemVaultInfo' import { NoteDragDataFormat } from '../Tags/DragNDrop' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' @@ -143,6 +144,7 @@ const NoteListItem: FunctionComponent> = ({ + diff --git a/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx b/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx index b764021e3..ed079da1d 100644 --- a/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx +++ b/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx @@ -39,6 +39,7 @@ import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalCo import { useItemLinks } from '@/Hooks/useItemLinks' import { ItemLink } from '@/Utils/Items/Search/ItemLink' import { ItemListController } from '@/Controllers/ItemList/ItemListController' +import ListItemVaultInfo from '../ContentListView/ListItemVaultInfo' const ContextMenuCell = ({ items, @@ -213,6 +214,7 @@ const ItemNameCell = ({ item, hideIcon }: { item: DecryptedItemInterface; hideIc )} {item.title} + {item.protected && ( @@ -245,7 +247,7 @@ const AttachedToCell = ({ item }: { item: DecryptedItemInterface }) => { link={allLinks[0]} key={allLinks[0].id} unlinkItem={async (itemToUnlink) => { - void application.items.unlinkItems(item, itemToUnlink) + void application.mutator.unlinkItems(item, itemToUnlink) }} isBidirectional={false} /> @@ -312,7 +314,7 @@ const ContentTableView = ({ return } - await application.mutator.changeAndSaveItem(selectedTag, (mutator) => { + await application.changeAndSaveItem(selectedTag, (mutator) => { mutator.preferences = { ...mutator.preferences, sortBy, diff --git a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx index 99dc34a96..4c69277c9 100644 --- a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx +++ b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx @@ -15,6 +15,9 @@ import AddTagOption from '../NotesOptions/AddTagOption' import { MenuItemIconSize } from '@/Constants/TailwindClassNames' import { LinkingController } from '@/Controllers/LinkingController' import { NavigationController } from '@/Controllers/Navigation/NavigationController' +import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption' +import { iconClass } from '../NotesOptions/ClassNames' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' type Props = { closeMenu: () => void @@ -90,6 +93,7 @@ const FileMenuOptions: FunctionComponent = ({ ) : null} )} + {featureTrunkVaultsEnabled() && } { - await application.items.renameFile(file, event.target.value) + await application.mutator.renameFile(file, event.target.value) void application.sync.sync() }, syncDebounceMs) }, diff --git a/packages/web/src/javascripts/Components/Footer/Footer.tsx b/packages/web/src/javascripts/Components/Footer/Footer.tsx index 15bb34f81..883d400c2 100644 --- a/packages/web/src/javascripts/Components/Footer/Footer.tsx +++ b/packages/web/src/javascripts/Components/Footer/Footer.tsx @@ -21,6 +21,7 @@ import AccountMenuButton from './AccountMenuButton' import StyledTooltip from '../StyledTooltip/StyledTooltip' import UpgradeNow from './UpgradeNow' import PreferencesButton from './PreferencesButton' +import VaultSelectionButton from './VaultSelectionButton' type Props = { application: WebApplication @@ -37,6 +38,7 @@ type State = { newUpdateAvailable: boolean showAccountMenu: boolean showQuickSettingsMenu: boolean + showVaultSelectionMenu: boolean offline: boolean hasError: boolean arbitraryStatusMessage?: string @@ -64,6 +66,7 @@ class Footer extends AbstractComponent { newUpdateAvailable: false, showAccountMenu: false, showQuickSettingsMenu: false, + showVaultSelectionMenu: false, } this.webEventListenerDestroyer = props.application.addWebEventObserver((event, data) => { @@ -125,6 +128,7 @@ class Footer extends AbstractComponent { showBetaWarning: showBetaWarning, showAccountMenu: this.viewControllerManager.accountMenuController.show, showQuickSettingsMenu: this.viewControllerManager.quickSettingsMenuController.open, + showVaultSelectionMenu: this.viewControllerManager.vaultSelectionController.open, }) }) } @@ -296,6 +300,10 @@ class Footer extends AbstractComponent { this.viewControllerManager.quickSettingsMenuController.toggle() } + vaultSelectionClickHandler = () => { + this.viewControllerManager.vaultSelectionController.toggle() + } + syncResolutionClickHandler = () => { this.setState({ showSyncResolution: !this.state.showSyncResolution, @@ -367,9 +375,11 @@ class Footer extends AbstractComponent { viewControllerManager={this.viewControllerManager} /> +
+
{ quickSettingsMenuController={this.viewControllerManager.quickSettingsMenuController} />
+ +
+ +
theme.package_info.identifier === FeatureIdentifier.DarkTheme) as SNTheme | undefined if (darkTheme) { - void application.mutator.toggleTheme(darkTheme) + void application.componentManager.toggleTheme(darkTheme.uuid) } }, }) diff --git a/packages/web/src/javascripts/Components/Footer/VaultSelectionButton.tsx b/packages/web/src/javascripts/Components/Footer/VaultSelectionButton.tsx new file mode 100644 index 000000000..ceebc9fe1 --- /dev/null +++ b/packages/web/src/javascripts/Components/Footer/VaultSelectionButton.tsx @@ -0,0 +1,62 @@ +import { classNames } from '@standardnotes/utils' +import { useRef } from 'react' +import Icon from '../Icon/Icon' +import Popover from '../Popover/Popover' +import StyledTooltip from '../StyledTooltip/StyledTooltip' +import { VaultSelectionMenuController } from '@/Controllers/VaultSelectionMenuController' +import VaultSelectionMenu from '../VaultSelectionMenu/VaultSelectionMenu' +import { useApplication } from '../ApplicationProvider' +import { observer } from 'mobx-react-lite' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' + +type Props = { + isOpen: boolean + toggleMenu: () => void + controller: VaultSelectionMenuController +} + +const VaultSelectionButton = ({ isOpen, toggleMenu, controller }: Props) => { + const application = useApplication() + const buttonRef = useRef(null) + const exclusivelyShownVault = application.vaultDisplayService.exclusivelyShownVault + + if (!featureTrunkVaultsEnabled()) { + return null + } + + return ( + <> + + + + + + + + ) +} + +export default observer(VaultSelectionButton) diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 2cb9a0356..32a8483c6 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -80,6 +80,7 @@ export const IconNameToSvgMapping = { check: icons.CheckIcon, close: icons.CloseIcon, code: icons.CodeIcon, + comment: icons.FeedbackIcon, copy: icons.CopyIcon, dashboard: icons.DashboardIcon, diamond: icons.DiamondIcon, @@ -92,6 +93,7 @@ export const IconNameToSvgMapping = { file: icons.FileIcon, folder: icons.FolderIcon, gkeep: icons.GoogleKeepIcon, + group: icons.GroupIcon, hashtag: icons.HashtagIcon, help: icons.HelpIcon, history: icons.HistoryIcon, diff --git a/packages/web/src/javascripts/Components/NoteView/CollaborationInfoHUD.tsx b/packages/web/src/javascripts/Components/NoteView/CollaborationInfoHUD.tsx new file mode 100644 index 000000000..5376f0162 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/CollaborationInfoHUD.tsx @@ -0,0 +1,46 @@ +import { FunctionComponent } from 'react' +import Icon from '../Icon/Icon' +import { useApplication } from '../ApplicationProvider' +import { DecryptedItemInterface } from '@standardnotes/snjs' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' + +type Props = { + item: DecryptedItemInterface +} + +const CollaborationInfoHUD: FunctionComponent = ({ item }) => { + const application = useApplication() + + if (!featureTrunkVaultsEnabled()) { + return null + } + + if (application.items.isTemplateItem(item)) { + return null + } + + const vault = application.vaults.getItemVault(item) + if (!vault) { + return null + } + + const lastEditedBy = application.sharedVaults.getItemLastEditedBy(item) + + return ( +
+
+ + {vault.name} +
+ + {lastEditedBy && ( +
+ + {lastEditedBy?.name} +
+ )} +
+ ) +} + +export default CollaborationInfoHUD diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts index e481028e2..64cefb691 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts @@ -5,10 +5,11 @@ import { SNComponentManager, SNComponent, SNTag, - ItemsClientInterface, SNNote, Deferred, SyncServiceInterface, + ItemManagerInterface, + MutatorClientInterface, } from '@standardnotes/snjs' import { FeatureIdentifier, NoteType } from '@standardnotes/features' import { NoteViewController } from './NoteViewController' @@ -24,7 +25,9 @@ describe('note view controller', () => { application.noAccount = jest.fn().mockReturnValue(false) application.isNativeMobileWeb = jest.fn().mockReturnValue(false) - Object.defineProperty(application, 'items', { value: {} as jest.Mocked }) + const items = {} as jest.Mocked + items.createTemplateItem = jest.fn().mockReturnValue({} as SNNote) + Object.defineProperty(application, 'items', { value: items }) Object.defineProperty(application, 'sync', { value: {} as jest.Mocked }) application.sync.sync = jest.fn().mockReturnValue(Promise.resolve()) @@ -33,8 +36,7 @@ describe('note view controller', () => { componentManager.legacyGetDefaultEditor = jest.fn() Object.defineProperty(application, 'componentManager', { value: componentManager }) - const mutator = {} as jest.Mocked - mutator.createTemplateItem = jest.fn().mockReturnValue({} as SNNote) + const mutator = {} as jest.Mocked Object.defineProperty(application, 'mutator', { value: mutator }) }) @@ -44,7 +46,7 @@ describe('note view controller', () => { const controller = new NoteViewController(application) await controller.initialize() - expect(application.mutator.createTemplateItem).toHaveBeenCalledWith( + expect(application.items.createTemplateItem).toHaveBeenCalledWith( ContentType.Note, expect.objectContaining({ noteType: NoteType.Plain }), expect.anything(), @@ -65,7 +67,7 @@ describe('note view controller', () => { const controller = new NoteViewController(application) await controller.initialize() - expect(application.mutator.createTemplateItem).toHaveBeenCalledWith( + expect(application.items.createTemplateItem).toHaveBeenCalledWith( ContentType.Note, expect.objectContaining({ noteType: NoteType.Markdown }), expect.anything(), @@ -80,13 +82,13 @@ describe('note view controller', () => { } as jest.Mocked application.items.findItem = jest.fn().mockReturnValue(tag) - application.items.addTagToNote = jest.fn() + application.mutator.addTagToNote = jest.fn() const controller = new NoteViewController(application, undefined, { tag: tag.uuid }) await controller.initialize() expect(controller['defaultTag']).toEqual(tag) - expect(application.items.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything()) + expect(application.mutator.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything()) }) it('should wait until item finishes saving locally before deiniting', async () => { diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts index 714a8d8be..1438ea313 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts @@ -1,6 +1,14 @@ import { WebApplication } from '@/Application/WebApplication' import { noteTypeForEditorIdentifier } from '@standardnotes/features' -import { SNNote, SNTag, NoteContent, DecryptedItemInterface, PayloadEmitSource, PrefKey } from '@standardnotes/models' +import { + SNNote, + SNTag, + NoteContent, + DecryptedItemInterface, + PayloadEmitSource, + PrefKey, + PayloadVaultOverrides, +} from '@standardnotes/models' import { UuidString } from '@standardnotes/snjs' import { removeFromArray } from '@standardnotes/utils' import { ContentType } from '@standardnotes/common' @@ -90,7 +98,7 @@ export class NoteViewController implements ItemViewControllerInterface { const noteType = noteTypeForEditorIdentifier(editorIdentifier) - const note = this.application.mutator.createTemplateItem( + const note = this.application.items.createTemplateItem( ContentType.Note, { text: '', @@ -101,6 +109,7 @@ export class NoteViewController implements ItemViewControllerInterface { }, { created_at: this.templateNoteOptions?.createdAt || new Date(), + ...PayloadVaultOverrides(this.templateNoteOptions?.vault), }, ) @@ -110,7 +119,7 @@ export class NoteViewController implements ItemViewControllerInterface { if (this.defaultTagUuid) { const tag = this.application.items.findItem(this.defaultTagUuid) as SNTag - await this.application.items.addTagToNote(note, tag, addTagHierarchy) + await this.application.mutator.addTagToNote(note, tag, addTagHierarchy) } this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush) diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/TemplateNoteViewControllerOptions.ts b/packages/web/src/javascripts/Components/NoteView/Controller/TemplateNoteViewControllerOptions.ts index 898dbe32f..513d981ff 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/TemplateNoteViewControllerOptions.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/TemplateNoteViewControllerOptions.ts @@ -1,8 +1,9 @@ -import { UuidString } from '@standardnotes/snjs' +import { UuidString, VaultListingInterface } from '@standardnotes/snjs' export type TemplateNoteViewControllerOptions = { title?: string tag?: UuidString + vault?: VaultListingInterface createdAt?: Date autofocusBehavior?: TemplateNoteViewAutofocusBehavior } diff --git a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx index ad5c0fa4a..a2a31818d 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx @@ -65,12 +65,13 @@ const NoteConflictResolutionModal = ({ async (note: SNNote) => { await application.mutator .deleteItem(note) + .then(() => application.sync.sync()) .catch(console.error) .then(() => { setSelectedVersions([allVersions[0].uuid]) }) }, - [allVersions, application.mutator], + [allVersions, application.mutator, application.sync], ) const [selectedAction, setSelectionAction] = useState('move-to-trash') diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index 8ade849a5..38f2fc2c2 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -45,6 +45,7 @@ import { SuperEditorContentId } from '../SuperEditor/Constants' import { NoteViewController } from './Controller/NoteViewController' import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor' import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths' +import CollaborationInfoHUD from './CollaborationInfoHUD' import Button from '../Button/Button' import ModalOverlay from '../Modal/ModalOverlay' import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal' @@ -74,7 +75,7 @@ type State = { monospaceFont?: boolean plainEditorFocused?: boolean paneGestureEnabled?: boolean - + noteLastEditedByUuid?: string updateSavingIndicator?: boolean editorFeatureIdentifier?: string noteType?: NoteType @@ -270,6 +271,12 @@ class NoteView extends AbstractComponent { }) } + if (note.last_edited_by_uuid !== this.state.noteLastEditedByUuid) { + this.setState({ + noteLastEditedByUuid: note.last_edited_by_uuid, + }) + } + if (note.locked !== this.state.noteLocked) { this.setState({ noteLocked: note.locked, @@ -651,7 +658,10 @@ class NoteView extends AbstractComponent { } performNoteDeletion(note: SNNote) { - this.application.mutator.deleteItem(note).catch(console.error) + this.application.mutator + .deleteItem(note) + .then(() => this.application.sync.sync()) + .catch(console.error) } onPanelResizeFinish = async (width: number, left: number, isMaxWidth: boolean) => { @@ -897,6 +907,7 @@ class NoteView extends AbstractComponent { )} {renderHeaderOptions && (
+ { await linkingController.linkItems(note, uploadedFile) - void application.mutator.changeAndSaveItem(uploadedFile, (mutator) => { + void application.changeAndSaveItem(uploadedFile, (mutator) => { mutator.protected = note.protected }) filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid) @@ -37,7 +37,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, file removeDragTarget(target) } } - }, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController, application.mutator]) + }, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController, application]) return isDraggingFiles ? ( // Required to block drag events to editor iframe diff --git a/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx b/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx index cbc788fda..b4e1d1261 100644 --- a/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx +++ b/packages/web/src/javascripts/Components/NoteView/ReadonlyNoteContent.tsx @@ -36,14 +36,14 @@ export const ReadonlyNoteContent = ({ return undefined } - const templateNoteForRevision = application.mutator.createTemplateItem(ContentType.Note, note.content) as SNNote + const templateNoteForRevision = application.items.createTemplateItem(ContentType.Note, note.content) as SNNote const componentViewer = application.componentManager.createComponentViewer(editorForCurrentNote) componentViewer.setReadonly(true) componentViewer.lockReadonly = true componentViewer.overrideContextItem = templateNoteForRevision return componentViewer - }, [application.componentManager, application.mutator, note]) + }, [application.componentManager, application.items, note]) useEffect(() => { return () => { diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index c0d030ea9..1d0bfe686 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -37,6 +37,8 @@ import ModalOverlay from '../Modal/ModalOverlay' import SuperExportModal from './SuperExportModal' import { useApplication } from '../ApplicationProvider' import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery' +import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' const iconSize = MenuItemIconSize const iconClassDanger = `text-danger mr-2 ${iconSize}` @@ -144,8 +146,9 @@ const NotesOptions = ({ const duplicateSelectedItems = useCallback(async () => { await Promise.all(notes.map((note) => application.mutator.duplicateItem(note).catch(console.error))) + void application.sync.sync() closeMenuAndToggleNotesList() - }, [application.mutator, closeMenuAndToggleNotesList, notes]) + }, [application.mutator, application.sync, closeMenuAndToggleNotesList, notes]) const openRevisionHistoryModal = useCallback(() => { historyModalController.openModal(notesController.firstSelectedNote) @@ -240,6 +243,9 @@ const NotesOptions = ({ )} + + {featureTrunkVaultsEnabled() && } + {navigationController.tagsCount > 0 && ( = ({ menu, @@ -40,6 +41,8 @@ const PaneSelector: FunctionComponent ) + case 'vaults': + return case 'backups': return case 'listed': diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx index b9309739a..806825a11 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx @@ -109,7 +109,7 @@ const DataBackups = ({ application, viewControllerManager }: Props) => { const performImport = async (data: BackupFile) => { setIsImportDataLoading(true) - const result = await application.mutator.importData(data) + const result = await application.importData(data) setIsImportDataLoading(false) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx index 777b9c0a1..97bb09d65 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/PackageEntry.tsx @@ -34,7 +34,7 @@ const PackageEntry: FunctionComponent = ({ application, exten const toggleOfflineOnly = () => { const newOfflineOnly = !offlineOnly setOfflineOnly(newOfflineOnly) - application.mutator + application .changeAndSaveItem(extension, (mutator) => { mutator.offlineOnly = newOfflineOnly }) @@ -49,7 +49,7 @@ const PackageEntry: FunctionComponent = ({ application, exten const changeExtensionName = (newName: string) => { setExtensionName(newName) - application.mutator + application .changeAndSaveItem(extension, (mutator) => { mutator.name = newName }) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx index 3321a7749..939543682 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/Packages/Section.tsx @@ -53,6 +53,7 @@ const PackagesPreferencesSection: FunctionComponent = ({ .then(async (shouldRemove: boolean) => { if (shouldRemove) { await application.mutator.deleteItem(extension) + void application.sync.sync() setExtensions(loadExtensions(application)) } }) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx index 3e46796f9..545b6006a 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx @@ -12,7 +12,9 @@ type Props = { export const ShouldPersistNoteStateKey = 'ShouldPersistNoteState' const Persistence = ({ application }: Props) => { - const [shouldPersistNoteState, setShouldPersistNoteState] = useState(application.getValue(ShouldPersistNoteStateKey)) + const [shouldPersistNoteState, setShouldPersistNoteState] = useState( + application.getValue(ShouldPersistNoteStateKey), + ) const toggleStatePersistence = (shouldPersist: boolean) => { application.setValue(ShouldPersistNoteStateKey, shouldPersist) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/EditSmartViewModalController.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/EditSmartViewModalController.tsx index 1f01da0b5..1b075bfc5 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/EditSmartViewModalController.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/EditSmartViewModalController.tsx @@ -88,7 +88,7 @@ export class EditSmartViewModalController { this.setIsSaving(true) - await this.application.mutator.changeAndSaveItem(this.view, (mutator) => { + await this.application.changeAndSaveItem(this.view, (mutator) => { mutator.title = this.title mutator.iconString = (this.icon as string) || SmartViewDefaultIconName mutator.predicate = JSON.parse(this.predicateJson) as PredicateJsonForm @@ -111,7 +111,10 @@ export class EditSmartViewModalController { confirmButtonStyle: 'danger', }) if (shouldDelete) { - this.application.mutator.deleteItem(view).catch(console.error) + this.application.mutator + .deleteItem(view) + .then(() => this.application.sync.sync()) + .catch(console.error) } } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx index 6a5b80f5a..47dd3859f 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx @@ -47,10 +47,13 @@ const SmartViews = ({ application, featuresController }: Props) => { confirmButtonStyle: 'danger', }) if (shouldDelete) { - application.mutator.deleteItem(view).catch(console.error) + application.mutator + .deleteItem(view) + .then(() => application.sync.sync()) + .catch(console.error) } }, - [application.mutator], + [application.mutator, application.sync], ) return ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/EncryptionEnabled.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/EncryptionEnabled.tsx index 0814199b6..35ae3d433 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/EncryptionEnabled.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/EncryptionEnabled.tsx @@ -1,6 +1,6 @@ import { useApplication } from '@/Components/ApplicationProvider' import Icon from '@/Components/Icon/Icon' -import { ContentType, ItemCounter } from '@standardnotes/snjs' +import { ContentType, StaticItemCounter } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' import { FunctionComponent } from 'react' import EncryptionStatusItem from './EncryptionStatusItem' @@ -8,7 +8,7 @@ import { formatCount } from './formatCount' const EncryptionEnabled: FunctionComponent = () => { const application = useApplication() - const itemCounter = new ItemCounter() + const itemCounter = new StaticItemCounter() const count = itemCounter.countNotesAndTags(application.items.getItems([ContentType.Note, ContentType.Tag])) const files = application.items.getItems([ContentType.File]) const notes = formatCount(count.notes, 'notes') diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx index 2170caf29..8fddeed72 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/ErroredItems.tsx @@ -18,7 +18,7 @@ type Props = { viewControllerManager: ViewControllerManager } const ErroredItems: FunctionComponent = ({ viewControllerManager }: Props) => { const app = viewControllerManager.application - const [erroredItems, setErroredItems] = useState(app.items.invalidItems) + const [erroredItems, setErroredItems] = useState(app.items.invalidNonVaultedItems) const getContentTypeDisplay = (item: EncryptedItemInterface): string => { const display = DisplayStringForContentType(item.content_type) @@ -44,7 +44,9 @@ const ErroredItems: FunctionComponent = ({ viewControllerManager }: Props return } - void app.mutator.deleteItems(items) + void app.mutator.deleteItems(items).then(() => { + void app.sync.sync() + }) setErroredItems(app.items.invalidItems) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx index 765d52559..a2033d0ec 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/Security.tsx @@ -30,7 +30,7 @@ const Security: FunctionComponent = (props) => { return ( - {props.application.items.invalidItems.length > 0 && ( + {props.application.items.invalidNonVaultedItems.length > 0 && ( )} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/securityPrefsHasBubble.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/securityPrefsHasBubble.tsx index 00d491368..8d3adcf27 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/securityPrefsHasBubble.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/securityPrefsHasBubble.tsx @@ -1,5 +1,5 @@ import { WebApplication } from '@/Application/WebApplication' export const securityPrefsHasBubble = (application: WebApplication): boolean => { - return application.items.invalidItems.length > 0 + return application.items.invalidNonVaultedItems.length > 0 } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/ContactItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/ContactItem.tsx new file mode 100644 index 000000000..948c0fc86 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/ContactItem.tsx @@ -0,0 +1,51 @@ +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import ModalOverlay from '@/Components/Modal/ModalOverlay' +import { TrustedContactInterface } from '@standardnotes/snjs' +import EditContactModal from './EditContactModal' +import { useCallback, useState } from 'react' +import { useApplication } from '@/Components/ApplicationProvider' + +type Props = { + contact: TrustedContactInterface +} + +const ContactItem = ({ contact }: Props) => { + const application = useApplication() + + const [isContactModalOpen, setIsContactModalOpen] = useState(false) + const closeContactModal = () => setIsContactModalOpen(false) + + const collaborationID = application.contacts.getCollaborationIDForTrustedContact(contact) + + const deleteContact = useCallback(async () => { + void application.contacts.deleteContact(contact) + }, [application.contacts, contact]) + + return ( + <> + + + + +
+ +
+ + {contact.name} + + {collaborationID} + +
+
+
+
+ + ) +} + +export default ContactItem diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/EditContactModal.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/EditContactModal.tsx new file mode 100644 index 000000000..9062c77d1 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Contacts/EditContactModal.tsx @@ -0,0 +1,127 @@ +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' +import Modal, { ModalAction } from '@/Components/Modal/Modal' +import DecoratedInput from '@/Components/Input/DecoratedInput' +import { useApplication } from '@/Components/ApplicationProvider' +import { PendingSharedVaultInviteRecord, TrustedContactInterface } from '@standardnotes/snjs' + +type Props = { + fromInvite?: PendingSharedVaultInviteRecord + editContactUuid?: string + onCloseDialog: () => void + onAddContact?: (contact: TrustedContactInterface) => void +} + +const EditContactModal: FunctionComponent = ({ onCloseDialog, fromInvite, onAddContact, editContactUuid }) => { + const application = useApplication() + + const [name, setName] = useState('') + const [collaborationID, setCollaborationID] = useState('') + const [editingContact, setEditingContact] = useState(undefined) + + const handleDialogClose = useCallback(() => { + onCloseDialog() + }, [onCloseDialog]) + + useEffect(() => { + if (fromInvite) { + setCollaborationID(application.contacts.getCollaborationIDFromInvite(fromInvite.invite)) + } + }, [application.contacts, fromInvite]) + + useEffect(() => { + if (editContactUuid) { + const contact = application.contacts.findTrustedContact(editContactUuid) + if (!contact) { + throw new Error(`Contact with uuid ${editContactUuid} not found`) + } + + setEditingContact(contact) + setName(contact.name) + setCollaborationID(application.contacts.getCollaborationIDForTrustedContact(contact)) + } + }, [application.contacts, application.vaults, editContactUuid]) + + const handleSubmit = useCallback(async () => { + if (editingContact) { + void application.contacts.editTrustedContactFromCollaborationID(editingContact, { name, collaborationID }) + handleDialogClose() + } else { + const contact = await application.contacts.addTrustedContactFromCollaborationID(collaborationID, name) + if (contact) { + onAddContact?.(contact) + handleDialogClose() + } else { + void application.alertService.alert('Unable to create contact. Please try again.') + } + } + }, [ + editingContact, + application.contacts, + application.alertService, + name, + collaborationID, + handleDialogClose, + onAddContact, + ]) + + const modalActions = useMemo( + (): ModalAction[] => [ + { + label: editContactUuid ? 'Save Contact' : 'Add Contact', + onClick: handleSubmit, + type: 'primary', + mobileSlot: 'right', + }, + { + label: 'Cancel', + onClick: handleDialogClose, + type: 'cancel', + mobileSlot: 'left', + }, + ], + [editContactUuid, handleDialogClose, handleSubmit], + ) + + return ( + +
+
+
+ { + setName(value) + }} + /> + + { + setCollaborationID(value) + }} + /> + + {!editContactUuid && ( +

+ Ask your contact for their Standard Notes CollaborationID via secure email or chat. Then, enter it here + to add them as a contact. +

+ )} +
+
+
+
+ ) +} + +export default EditContactModal diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx new file mode 100644 index 000000000..3804c6c1f --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx @@ -0,0 +1,97 @@ +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' +import Modal, { ModalAction } from '@/Components/Modal/Modal' +import { useApplication } from '@/Components/ApplicationProvider' +import { SharedVaultPermission, SharedVaultListingInterface, TrustedContactInterface } from '@standardnotes/snjs' + +type Props = { + vault: SharedVaultListingInterface + onCloseDialog: () => void +} + +const ContactInviteModal: FunctionComponent = ({ vault, onCloseDialog }) => { + const application = useApplication() + + const [selectedContacts, setSelectedContacts] = useState([]) + const [contacts, setContacts] = useState([]) + + useEffect(() => { + const loadContacts = async () => { + const contacts = await application.sharedVaults.getInvitableContactsForSharedVault(vault) + setContacts(contacts) + } + void loadContacts() + }, [application.sharedVaults, vault]) + + const handleDialogClose = useCallback(() => { + onCloseDialog() + }, [onCloseDialog]) + + const inviteSelectedContacts = useCallback(async () => { + for (const contact of selectedContacts) { + await application.sharedVaults.inviteContactToSharedVault(vault, contact, SharedVaultPermission.Write) + } + handleDialogClose() + }, [application.sharedVaults, vault, handleDialogClose, selectedContacts]) + + const toggleContact = useCallback( + (contact: TrustedContactInterface) => { + if (selectedContacts.includes(contact)) { + const index = selectedContacts.indexOf(contact) + const updatedContacts = [...selectedContacts] + updatedContacts.splice(index, 1) + setSelectedContacts(updatedContacts) + } else { + setSelectedContacts([...selectedContacts, contact]) + } + }, + [selectedContacts, setSelectedContacts], + ) + + const modalActions = useMemo( + (): ModalAction[] => [ + { + label: 'Invite Selected Contacts', + onClick: inviteSelectedContacts, + type: 'primary', + mobileSlot: 'right', + }, + { + label: 'Cancel', + onClick: handleDialogClose, + type: 'cancel', + mobileSlot: 'left', + }, + ], + [handleDialogClose, inviteSelectedContacts], + ) + + return ( + +
+
+
+ {contacts.map((contact) => { + return ( +
toggleContact(contact)}> +
+ toggleContact(contact)} + /> +
+
+ {contact.name} + {contact.contactUuid} +
+
+ ) + })} +
+
+
+
+ ) +} + +export default ContactInviteModal diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx new file mode 100644 index 000000000..4e77932b7 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx @@ -0,0 +1,67 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import ModalOverlay from '@/Components/Modal/ModalOverlay' +import { PendingSharedVaultInviteRecord } from '@standardnotes/snjs' +import { useCallback, useState } from 'react' +import EditContactModal from '../Contacts/EditContactModal' + +type Props = { + invite: PendingSharedVaultInviteRecord +} + +const InviteItem = ({ invite }: Props) => { + const application = useApplication() + const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false) + + const isTrusted = invite.trusted + const inviteData = invite.message.data + + const addAsTrustedContact = useCallback(() => { + setIsAddContactModalOpen(true) + }, []) + + const acceptInvite = useCallback(async () => { + await application.sharedVaults.acceptPendingSharedVaultInvite(invite) + }, [application.sharedVaults, invite]) + + const closeAddContactModal = () => setIsAddContactModalOpen(false) + const collaborationId = application.contacts.getCollaborationIDFromInvite(invite.invite) + + return ( + <> + + + + +
+ +
+ Vault Name: {inviteData.metadata.name} + + Vault Description: {inviteData.metadata.description} + + + Sender CollaborationID: {collaborationId} + + +
+ {isTrusted ? ( +
+ )} +
+
+
+ + ) +} + +export default InviteItem diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults.tsx new file mode 100644 index 000000000..bb0a9b671 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults.tsx @@ -0,0 +1,157 @@ +import { observer } from 'mobx-react-lite' +import { Subtitle, Title } from '@/Components/Preferences/PreferencesComponents/Content' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { useApplication } from '@/Components/ApplicationProvider' +import ContactItem from './Contacts/ContactItem' +import ModalOverlay from '@/Components/Modal/ModalOverlay' +import EditContactModal from './Contacts/EditContactModal' +import { useCallback, useEffect, useState } from 'react' +import { + VaultListingInterface, + TrustedContactInterface, + PendingSharedVaultInviteRecord, + ContentType, + SharedVaultServiceEvent, +} from '@standardnotes/snjs' +import VaultItem from './Vaults/VaultItem' +import Button from '@/Components/Button/Button' +import InviteItem from './Invites/InviteItem' +import EditVaultModal from './Vaults/VaultModal/EditVaultModal' + +const Vaults = () => { + const application = useApplication() + + const [vaults, setVaults] = useState([]) + const [invites, setInvites] = useState([]) + const [contacts, setContacts] = useState([]) + + const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false) + const closeAddContactModal = () => setIsAddContactModalOpen(false) + + const [isVaultModalOpen, setIsVaultModalOpen] = useState(false) + const closeVaultModal = () => setIsVaultModalOpen(false) + + const vaultService = application.vaults + const sharedVaultService = application.sharedVaults + const contactService = application.contacts + + const updateVaults = useCallback(async () => { + setVaults(vaultService.getVaults()) + }, [vaultService]) + + const fetchInvites = useCallback(async () => { + await sharedVaultService.downloadInboundInvites() + const invites = sharedVaultService.getCachedPendingInviteRecords() + setInvites(invites) + }, [sharedVaultService]) + + const updateContacts = useCallback(async () => { + setContacts(contactService.getAllContacts()) + }, [contactService]) + + useEffect(() => { + return application.sharedVaults.addEventObserver((event) => { + if (event === SharedVaultServiceEvent.SharedVaultStatusChanged) { + void fetchInvites() + } + }) + }) + + useEffect(() => { + return application.streamItems([ContentType.VaultListing, ContentType.TrustedContact], () => { + void updateVaults() + void fetchInvites() + void updateContacts() + }) + }, [application, updateVaults, fetchInvites, updateContacts]) + + const createNewVault = useCallback(async () => { + setIsVaultModalOpen(true) + }, []) + + const createNewContact = useCallback(() => { + setIsAddContactModalOpen(true) + }, []) + + useEffect(() => { + void updateVaults() + void fetchInvites() + void updateContacts() + }, [updateContacts, updateVaults, fetchInvites]) + + return ( + <> + + + + + + + + + + + Incoming Invites +
+ {invites.map((invite) => { + return + })} +
+
+
+ + + + Contacts +
+ {contacts.map((contact) => { + return + })} +
+
+
+
+
+ + + + Vaults +
+ {vaults.map((vault) => { + return + })} +
+
+
+
+
+ + + + CollaborationID + Share your CollaborationID with collaborators to join their vaults. + {contactService.isCollaborationEnabled() ? ( +
+ +
{contactService.getCollaborationID()}
+
+
+ ) : ( +
+
+ )} +
+
+ + ) +} + +export default observer(Vaults) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx new file mode 100644 index 000000000..e3a40a484 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx @@ -0,0 +1,146 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import ModalOverlay from '@/Components/Modal/ModalOverlay' +import { ButtonType, VaultListingInterface, isClientDisplayableError } from '@standardnotes/snjs' +import { useCallback, useState } from 'react' +import ContactInviteModal from '../Invites/ContactInviteModal' +import EditVaultModal from './VaultModal/EditVaultModal' + +type Props = { + vault: VaultListingInterface +} + +const VaultItem = ({ vault }: Props) => { + const application = useApplication() + + const [isInviteModalOpen, setIsAddContactModalOpen] = useState(false) + const closeInviteModal = () => setIsAddContactModalOpen(false) + + const [isVaultModalOpen, setIsVaultModalOpen] = useState(false) + const closeVaultModal = () => setIsVaultModalOpen(false) + + const isAdmin = !vault.isSharedVaultListing() ? true : application.sharedVaults.isCurrentUserSharedVaultAdmin(vault) + + const deleteVault = useCallback(async () => { + const confirm = await application.alerts.confirm( + 'Deleting a vault will permanently delete all its items and files.', + 'Are you sure you want to delete this vault?', + undefined, + ButtonType.Danger, + ) + if (!confirm) { + return + } + + if (vault.isSharedVaultListing()) { + const result = await application.sharedVaults.deleteSharedVault(vault) + if (isClientDisplayableError(result)) { + void application.alerts.showErrorAlert(result) + } + } else { + const success = await application.vaults.deleteVault(vault) + if (!success) { + void application.alerts.alert('Unable to delete vault. Please try again.') + } + } + }, [application.alerts, application.sharedVaults, application.vaults, vault]) + + const leaveVault = useCallback(async () => { + if (!vault.isSharedVaultListing()) { + return + } + + const confirm = await application.alerts.confirm( + 'All items and files in this vault will be removed from your account.', + 'Are you sure you want to leave this vault?', + undefined, + ButtonType.Danger, + ) + if (!confirm) { + return + } + + const success = await application.sharedVaults.leaveSharedVault(vault) + if (!success) { + void application.alerts.alert('Unable to leave vault. Please try again.') + } + }, [application.alerts, application.sharedVaults, vault]) + + const convertToSharedVault = useCallback(async () => { + await application.sharedVaults.convertVaultToSharedVault(vault) + }, [application.sharedVaults, vault]) + + const ensureVaultIsUnlocked = useCallback(async () => { + if (!application.vaults.isVaultLocked(vault)) { + return true + } + const unlocked = await application.vaultDisplayService.unlockVault(vault) + return unlocked + }, [application.vaultDisplayService, application.vaults, vault]) + + const openEditModal = useCallback(async () => { + if (!(await ensureVaultIsUnlocked())) { + return + } + + setIsVaultModalOpen(true) + }, [ensureVaultIsUnlocked]) + + const openInviteModal = useCallback(async () => { + if (!(await ensureVaultIsUnlocked())) { + return + } + + setIsAddContactModalOpen(true) + }, [ensureVaultIsUnlocked]) + + return ( + <> + {vault.isSharedVaultListing() && ( + + + + )} + + + + + +
+ +
+ {vault.name} + {vault.description} + Vault ID: {vault.systemIdentifier} + +
+
+
+
+ {vault.isSharedVaultListing() ? ( +
+
+
+
+ + ) +} + +export default VaultItem diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/EditVaultModal.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/EditVaultModal.tsx new file mode 100644 index 000000000..c1233f24d --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/EditVaultModal.tsx @@ -0,0 +1,224 @@ +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' +import Modal, { ModalAction } from '@/Components/Modal/Modal' +import DecoratedInput from '@/Components/Input/DecoratedInput' +import { useApplication } from '@/Components/ApplicationProvider' +import { + ChangeVaultOptionsDTO, + KeySystemRootKeyPasswordType, + KeySystemRootKeyStorageMode, + SharedVaultInviteServerHash, + SharedVaultUserServerHash, + VaultListingInterface, + isClientDisplayableError, +} from '@standardnotes/snjs' +import { VaultModalMembers } from './VaultModalMembers' +import { VaultModalInvites } from './VaultModalInvites' +import { PasswordTypePreference } from './PasswordTypePreference' +import { KeyStoragePreference } from './KeyStoragePreference' +import useItem from '@/Hooks/useItem' + +type Props = { + existingVaultUuid?: string + onCloseDialog: () => void +} + +const EditVaultModal: FunctionComponent = ({ onCloseDialog, existingVaultUuid }) => { + const application = useApplication() + + const existingVault = useItem(existingVaultUuid) + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [members, setMembers] = useState([]) + const [invites, setInvites] = useState([]) + const [isAdmin, setIsAdmin] = useState(true) + const [passwordType, setPasswordType] = useState( + KeySystemRootKeyPasswordType.Randomized, + ) + const [keyStorageMode, setKeyStorageMode] = useState(KeySystemRootKeyStorageMode.Synced) + const [customPassword, setCustomPassword] = useState(undefined) + + useEffect(() => { + if (existingVault) { + setName(existingVault.name ?? '') + setDescription(existingVault.description ?? '') + setPasswordType(existingVault.rootKeyParams.passwordType) + setKeyStorageMode(existingVault.keyStorageMode) + } + }, [application.vaults, existingVault]) + + const reloadVaultInfo = useCallback(async () => { + if (!existingVault) { + return + } + + if (existingVault.isSharedVaultListing()) { + setIsAdmin( + existingVault.isSharedVaultListing() && application.sharedVaults.isCurrentUserSharedVaultAdmin(existingVault), + ) + + const users = await application.sharedVaults.getSharedVaultUsers(existingVault) + if (users) { + setMembers(users) + } + + const invites = await application.sharedVaults.getOutboundInvites(existingVault) + if (!isClientDisplayableError(invites)) { + setInvites(invites) + } + } + }, [application.sharedVaults, existingVault]) + + useEffect(() => { + void reloadVaultInfo() + }, [application.vaults, reloadVaultInfo]) + + const handleDialogClose = useCallback(() => { + onCloseDialog() + }, [onCloseDialog]) + + const saveExistingVault = useCallback( + async (vault: VaultListingInterface) => { + if (vault.name !== name || vault.description !== description) { + await application.vaults.changeVaultNameAndDescription(vault, { + name: name, + description: description, + }) + } + + const isChangingPasswordType = vault.keyPasswordType !== passwordType + const isChangingKeyStorageMode = vault.keyStorageMode !== keyStorageMode + + const getPasswordTypeParams = (): ChangeVaultOptionsDTO['newPasswordType'] => { + if (!isChangingPasswordType) { + throw new Error('Password type is not changing') + } + + if (passwordType === KeySystemRootKeyPasswordType.UserInputted) { + if (!customPassword) { + throw new Error('Custom password is not set') + } + return { + passwordType, + userInputtedPassword: customPassword, + } + } else { + return { + passwordType, + } + } + } + + if (isChangingPasswordType || isChangingKeyStorageMode) { + await application.vaults.changeVaultOptions({ + vault, + newPasswordType: isChangingPasswordType ? getPasswordTypeParams() : undefined, + newKeyStorageMode: isChangingKeyStorageMode ? keyStorageMode : undefined, + }) + } + }, + [application.vaults, customPassword, description, keyStorageMode, name, passwordType], + ) + + const createNewVault = useCallback(async () => { + if (passwordType === KeySystemRootKeyPasswordType.UserInputted) { + if (!customPassword) { + throw new Error('Custom key is not set') + } + await application.vaults.createUserInputtedPasswordVault({ + name, + description, + storagePreference: keyStorageMode, + userInputtedPassword: customPassword, + }) + } else { + await application.vaults.createRandomizedVault({ + name, + description, + storagePreference: keyStorageMode, + }) + } + + handleDialogClose() + }, [application.vaults, customPassword, description, handleDialogClose, keyStorageMode, name, passwordType]) + + const handleSubmit = useCallback(async () => { + if (existingVault) { + await saveExistingVault(existingVault) + } else { + await createNewVault() + } + handleDialogClose() + }, [existingVault, handleDialogClose, saveExistingVault, createNewVault]) + + const modalActions = useMemo( + (): ModalAction[] => [ + { + label: existingVault ? 'Save Vault' : 'Create Vault', + onClick: handleSubmit, + type: 'primary', + mobileSlot: 'right', + }, + { + label: 'Cancel', + onClick: handleDialogClose, + type: 'cancel', + mobileSlot: 'left', + }, + ], + [existingVault, handleDialogClose, handleSubmit], + ) + + if (existingVault && application.vaults.isVaultLocked(existingVault)) { + return
Vault is locked.
+ } + + return ( + +
+
+
+
Vault Info
+
The vault name and description are end-to-end encrypted.
+ + { + setName(value) + }} + /> + + { + setDescription(value) + }} + /> +
+ + {existingVault && ( + + )} + + {existingVault && } + + + + +
+
+
+ ) +} + +export default EditVaultModal diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/KeyStoragePreference.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/KeyStoragePreference.tsx new file mode 100644 index 000000000..b0c191651 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/KeyStoragePreference.tsx @@ -0,0 +1,57 @@ +import { KeySystemRootKeyStorageMode } from '@standardnotes/snjs' +import StyledRadioInput from '@/Components/Radio/StyledRadioInput' + +type KeyStorageOption = { + value: KeySystemRootKeyStorageMode + label: string + description: string +} + +const options: KeyStorageOption[] = [ + { + value: KeySystemRootKeyStorageMode.Synced, + label: 'Synced (Recommended)', + description: + 'Your vault key will be encrypted and synced to your account and automatically available on your other devices.', + }, + { + value: KeySystemRootKeyStorageMode.Local, + label: 'Local', + description: + 'Your vault key will be encrypted and saved locally on this device. You will need to manually enter your vault key on your other devices.', + }, + { + value: KeySystemRootKeyStorageMode.Ephemeral, + label: 'Ephemeral', + description: + 'Your vault key will only be stored in memory and will be forgotten when you close the app. You will need to manually enter your vault key on your other devices.', + }, +] + +export const KeyStoragePreference = ({ + value, + onChange, +}: { + value: KeySystemRootKeyStorageMode + onChange: (value: KeySystemRootKeyStorageMode) => void +}) => { + return ( +
+
Vault Key Type
+ {options.map((option) => { + return ( + + ) + })} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/PasswordTypePreference.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/PasswordTypePreference.tsx new file mode 100644 index 000000000..0b6a7857c --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/PasswordTypePreference.tsx @@ -0,0 +1,75 @@ +import { KeySystemRootKeyPasswordType } from '@standardnotes/snjs' +import StyledRadioInput from '@/Components/Radio/StyledRadioInput' +import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput' +import { useState } from 'react' + +type PasswordTypePreference = { + value: KeySystemRootKeyPasswordType + label: string + description: string +} + +const options: PasswordTypePreference[] = [ + { + value: KeySystemRootKeyPasswordType.Randomized, + label: 'Randomized (Recommended)', + description: 'Your vault key will be randomly generated and synced to your account.', + }, + { + value: KeySystemRootKeyPasswordType.UserInputted, + label: 'Custom (Advanced)', + description: + 'Choose your own key for your vault. This is an advanced option and is not recommended for most users.', + }, +] + +export const PasswordTypePreference = ({ + value, + onChange, + onCustomKeyChange, +}: { + value: KeySystemRootKeyPasswordType + onChange: (value: KeySystemRootKeyPasswordType) => void + onCustomKeyChange: (value: string) => void +}) => { + const [customKey, setCustomKey] = useState('') + + const onKeyInputChange = (value: string) => { + setCustomKey(value) + onCustomKeyChange(value) + } + + return ( +
+
Vault Key Type
+ {options.map((option) => { + return ( + + ) + })} + + {value === KeySystemRootKeyPasswordType.UserInputted && ( +
+
{options[1].description}
+ + +
+ )} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalInvites.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalInvites.tsx new file mode 100644 index 000000000..d0df96dda --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalInvites.tsx @@ -0,0 +1,57 @@ +import { useCallback } from 'react' +import { useApplication } from '@/Components/ApplicationProvider' +import { SharedVaultInviteServerHash } from '@standardnotes/snjs' +import Icon from '@/Components/Icon/Icon' +import Button from '@/Components/Button/Button' + +export const VaultModalInvites = ({ + invites, + onChange, + isAdmin, +}: { + invites: SharedVaultInviteServerHash[] + onChange: () => void + isAdmin: boolean +}) => { + const application = useApplication() + + const deleteInvite = useCallback( + async (invite: SharedVaultInviteServerHash) => { + await application.sharedVaults.deleteInvite(invite) + onChange() + }, + [application.sharedVaults, onChange], + ) + + return ( +
+
Pending Invites
+ {invites.map((invite) => { + const contact = application.contacts.findTrustedContactForInvite(invite) + + return ( +
+ +
+ + {contact?.name || invite.user_uuid} + + {contact && Trusted} + {!contact && ( +
+ Untrusted +
+ )} + + {isAdmin && ( +
+
+ )} +
+
+ ) + })} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalMembers.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalMembers.tsx new file mode 100644 index 000000000..f6a007fe6 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultModal/VaultModalMembers.tsx @@ -0,0 +1,71 @@ +import { useCallback } from 'react' +import { useApplication } from '@/Components/ApplicationProvider' +import { SharedVaultUserServerHash, VaultListingInterface } from '@standardnotes/snjs' +import Icon from '@/Components/Icon/Icon' +import Button from '@/Components/Button/Button' + +export const VaultModalMembers = ({ + members, + isAdmin, + vault, + onChange, +}: { + members: SharedVaultUserServerHash[] + vault: VaultListingInterface + isAdmin: boolean + onChange: () => void +}) => { + const application = useApplication() + + const removeMemberFromVault = useCallback( + async (memberItem: SharedVaultUserServerHash) => { + if (vault.isSharedVaultListing()) { + await application.sharedVaults.removeUserFromSharedVault(vault, memberItem.user_uuid) + onChange() + } + }, + [application.sharedVaults, vault, onChange], + ) + + return ( +
+
Vault Members
+ {members.map((member) => { + if (application.sharedVaults.isSharedVaultUserSharedVaultOwner(member)) { + return null + } + + const contact = application.contacts.findTrustedContactForServerUser(member) + return ( +
+ +
+ + {contact?.name || member.user_uuid} + + {contact && Trusted} + {!contact && ( +
+ Untrusted +
+ )} + + {isAdmin && ( +
+
+ )} +
+
+ ) + })} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/Preferences/PreferencesMenu.ts b/packages/web/src/javascripts/Components/Preferences/PreferencesMenu.ts index e86cd0adb..4e121a4a6 100644 --- a/packages/web/src/javascripts/Components/Preferences/PreferencesMenu.ts +++ b/packages/web/src/javascripts/Components/Preferences/PreferencesMenu.ts @@ -4,6 +4,7 @@ import { WebApplication } from '@/Application/WebApplication' import { PackageProvider } from './Panes/General/Advanced/Packages/Provider/PackageProvider' import { securityPrefsHasBubble } from './Panes/Security/securityPrefsHasBubble' import { PreferenceId } from '@standardnotes/ui-services' +import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' interface PreferencesMenuItem { readonly id: PreferenceId @@ -44,6 +45,11 @@ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ { id: 'help-feedback', label: 'Help & feedback', icon: 'help' }, ] +if (featureTrunkVaultsEnabled()) { + PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' }) + READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' }) +} + export class PreferencesMenu { private _selectedPane: PreferenceId = 'account' private _menu: PreferencesMenuItem[] diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx index deca7ea20..f561d2858 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -102,9 +102,9 @@ const QuickSettingsMenu: FunctionComponent = ({ application, quickSet const toggleComponent = useCallback( (component: SNComponent) => { if (component.isTheme()) { - application.mutator.toggleTheme(component).catch(console.error) + application.componentManager.toggleTheme(component.uuid).catch(console.error) } else { - application.mutator.toggleComponent(component).catch(console.error) + application.componentManager.toggleComponent(component.uuid).catch(console.error) } }, [application], @@ -113,7 +113,7 @@ const QuickSettingsMenu: FunctionComponent = ({ application, quickSet const deactivateAnyNonLayerableTheme = useCallback(() => { const activeTheme = themes.map((item) => item.component).find((theme) => theme?.active && !theme.isLayerable()) if (activeTheme) { - application.mutator.toggleTheme(activeTheme).catch(console.error) + application.componentManager.toggleTheme(activeTheme.uuid).catch(console.error) } }, [application, themes]) diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx index 130d95d99..a5cdcd85f 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx @@ -38,7 +38,7 @@ const ThemesMenuButton: FunctionComponent = ({ application, item }) => { const themeIsLayerableOrNotActive = isThemeLayerable || !item.component.active if (themeIsLayerableOrNotActive) { - application.mutator.toggleTheme(item.component).catch(console.error) + application.componentManager.toggleTheme(item.component.uuid).catch(console.error) } } else { premiumModal.activate(`${item.name} theme`) diff --git a/packages/web/src/javascripts/Components/RadioButtonGroup/RadioButtonGroup.tsx b/packages/web/src/javascripts/Components/RadioButtonGroup/RadioButtonGroup.tsx index cac192103..c79904c75 100644 --- a/packages/web/src/javascripts/Components/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/web/src/javascripts/Components/RadioButtonGroup/RadioButtonGroup.tsx @@ -5,9 +5,10 @@ type Props = { items: { label: string; value: string }[] value: string onChange: (value: string) => void + className?: string } -const RadioButtonGroup = ({ value, items, onChange }: Props) => { +const RadioButtonGroup = ({ value, items, onChange, className }: Props) => { const radio = useRadioStore({ value, orientation: 'horizontal', @@ -17,7 +18,7 @@ const RadioButtonGroup = ({ value, items, onChange }: Props) => { }) return ( - + {items.map(({ label, value: itemValue }) => (