feat: add @standardnotes/encryption package (#1199)

* feat: add @standardnotes/encryption package

* fix: mobile dependency on encryption package

* fix: order of build & lint in pr workflows

* fix: web dependency on encryption package

* fix: remove encryption package composite configuration

* fix: import order
This commit is contained in:
Karol Sójko
2022-07-05 10:06:03 +02:00
committed by GitHub
parent 60273785c2
commit e5771fcbde
70 changed files with 4682 additions and 27 deletions

View File

@@ -0,0 +1,215 @@
import { ContentType, KeyParamsOrigination, ProtocolVersion, ProtocolVersionLength } from '@standardnotes/common'
import { Create001KeyParams } from '../../Keys/RootKey/KeyParamsFunctions'
import { firstHalfOfString, secondHalfOfString, splitString, UuidGenerator } from '@standardnotes/utils'
import { AsynchronousOperator } from '../Operator'
import {
CreateDecryptedItemFromPayload,
ItemsKeyContent,
ItemsKeyInterface,
FillItemContent,
ItemContent,
DecryptedPayload,
DecryptedPayloadInterface,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SNRootKey } from '../../Keys/RootKey/RootKey'
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
import { V001Algorithm } from '../../Algorithm'
import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters'
import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData'
import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData'
import { LegacyAttachedData } from '../../Types/LegacyAttachedData'
import { isItemsKey } from '../../Keys/ItemsKey'
import { CreateNewRootKey } from '../../Keys/RootKey/Functions'
const NO_IV = '00000000000000000000000000000000'
/**
* @deprecated
* A legacy operator no longer used to generate new accounts
*/
export class SNProtocolOperator001 implements AsynchronousOperator {
protected readonly crypto: PureCryptoInterface
constructor(crypto: PureCryptoInterface) {
this.crypto = crypto
}
public getEncryptionDisplayName(): string {
return 'AES-256'
}
get version(): ProtocolVersion {
return ProtocolVersion.V001
}
protected generateNewItemsKeyContent(): ItemsKeyContent {
const keyLength = V001Algorithm.EncryptionKeyLength
const itemsKey = this.crypto.generateRandomKey(keyLength)
const response = FillItemContent<ItemsKeyContent>({
itemsKey: itemsKey,
version: ProtocolVersion.V001,
})
return response
}
/**
* Creates a new random items key to use for item encryption.
* The consumer must save/sync this item.
*/
public createItemsKey(): ItemsKeyInterface {
const payload = new DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.ItemsKey,
content: this.generateNewItemsKeyContent(),
...PayloadTimestampDefaults(),
})
return CreateDecryptedItemFromPayload(payload)
}
public async createRootKey(
identifier: string,
password: string,
origination: KeyParamsOrigination,
): Promise<SNRootKey> {
const pwCost = V001Algorithm.PbkdfMinCost as number
const pwNonce = this.crypto.generateRandomKey(V001Algorithm.SaltSeedLength)
const pwSalt = await this.crypto.unsafeSha1(identifier + 'SN' + pwNonce)
const keyParams = Create001KeyParams({
email: identifier,
pw_cost: pwCost,
pw_nonce: pwNonce,
pw_salt: pwSalt,
version: ProtocolVersion.V001,
origination,
created: `${Date.now()}`,
})
return this.deriveKey(password, keyParams)
}
public getPayloadAuthenticatedData(
_encrypted: EncryptedParameters,
): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined {
return undefined
}
public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
return this.deriveKey(password, keyParams)
}
private async decryptString(ciphertext: string, key: string) {
return this.crypto.aes256CbcDecrypt(ciphertext, NO_IV, key)
}
private async encryptString(text: string, key: string) {
return this.crypto.aes256CbcEncrypt(text, NO_IV, key)
}
public async generateEncryptedParametersAsync(
payload: DecryptedPayloadInterface,
key: ItemsKeyInterface | SNRootKey,
): Promise<EncryptedParameters> {
/**
* Generate new item key that is double the key size.
* Will be split to create encryption key and authentication key.
*/
const itemKey = this.crypto.generateRandomKey(V001Algorithm.EncryptionKeyLength * 2)
const encItemKey = await this.encryptString(itemKey, key.itemsKey)
/** Encrypt content */
const ek = firstHalfOfString(itemKey)
const ak = secondHalfOfString(itemKey)
const contentCiphertext = await this.encryptString(JSON.stringify(payload.content), ek)
const ciphertext = key.keyVersion + contentCiphertext
const authHash = await this.crypto.hmac256(ciphertext, ak)
if (!authHash) {
throw Error('Error generating hmac256 authHash')
}
return {
uuid: payload.uuid,
items_key_id: isItemsKey(key) ? key.uuid : undefined,
content: ciphertext,
enc_item_key: encItemKey,
auth_hash: authHash,
version: this.version,
}
}
public async generateDecryptedParametersAsync<C extends ItemContent = ItemContent>(
encrypted: EncryptedParameters,
key: ItemsKeyInterface | SNRootKey,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
if (!encrypted.enc_item_key) {
console.error(Error('Missing item encryption key, skipping decryption.'))
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
}
let encryptedItemKey = encrypted.enc_item_key
encryptedItemKey = this.version + encryptedItemKey
const itemKeyComponents = this.encryptionComponentsFromString(encryptedItemKey, key.itemsKey)
const itemKey = await this.decryptString(itemKeyComponents.ciphertext, itemKeyComponents.key)
if (!itemKey) {
console.error('Error decrypting parameters', encrypted)
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
}
const ek = firstHalfOfString(itemKey)
const itemParams = this.encryptionComponentsFromString(encrypted.content, ek)
const content = await this.decryptString(itemParams.ciphertext, itemParams.key)
if (!content) {
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
} else {
return {
uuid: encrypted.uuid,
content: JSON.parse(content),
}
}
}
private encryptionComponentsFromString(string: string, encryptionKey: string) {
const encryptionVersion = string.substring(0, ProtocolVersionLength)
return {
ciphertext: string.substring(ProtocolVersionLength, string.length),
version: encryptionVersion,
key: encryptionKey,
}
}
protected async deriveKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
const derivedKey = await this.crypto.pbkdf2(
password,
keyParams.content001.pw_salt,
keyParams.content001.pw_cost,
V001Algorithm.PbkdfOutputLength,
)
if (!derivedKey) {
throw Error('Error deriving PBKDF2 key')
}
const partitions = splitString(derivedKey, 2)
return CreateNewRootKey({
serverPassword: partitions[0],
masterKey: partitions[1],
version: ProtocolVersion.V001,
keyParams: keyParams.getPortableValue(),
})
}
}

View File

@@ -0,0 +1,296 @@
import { Create002KeyParams } from '../../Keys/RootKey/KeyParamsFunctions'
import { SNProtocolOperator001 } from '../001/Operator001'
import { SNRootKey } from '../../Keys/RootKey/RootKey'
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
import { UuidGenerator } from '@standardnotes/utils'
import { V002Algorithm } from '../../Algorithm'
import * as Common from '@standardnotes/common'
import * as Models from '@standardnotes/models'
import * as Utils from '@standardnotes/utils'
import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters'
import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData'
import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData'
import { LegacyAttachedData } from '../../Types/LegacyAttachedData'
import { isItemsKey } from '../../Keys/ItemsKey'
import { CreateNewRootKey } from '../../Keys/RootKey/Functions'
import { ItemContent, PayloadTimestampDefaults } from '@standardnotes/models'
/**
* @deprecated
* A legacy operator no longer used to generate new accounts.
*/
export class SNProtocolOperator002 extends SNProtocolOperator001 {
override get version(): Common.ProtocolVersion {
return Common.ProtocolVersion.V002
}
protected override generateNewItemsKeyContent(): Models.ItemsKeyContent {
const keyLength = V002Algorithm.EncryptionKeyLength
const itemsKey = this.crypto.generateRandomKey(keyLength)
const authKey = this.crypto.generateRandomKey(keyLength)
const response = Models.FillItemContent<Models.ItemsKeyContent>({
itemsKey: itemsKey,
dataAuthenticationKey: authKey,
version: Common.ProtocolVersion.V002,
})
return response
}
/**
* Creates a new random items key to use for item encryption.
* The consumer must save/sync this item.
*/
public override createItemsKey(): Models.ItemsKeyInterface {
const payload = new Models.DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content_type: Common.ContentType.ItemsKey,
content: this.generateNewItemsKeyContent(),
...PayloadTimestampDefaults(),
})
return Models.CreateDecryptedItemFromPayload(payload)
}
public override async createRootKey(
identifier: string,
password: string,
origination: Common.KeyParamsOrigination,
): Promise<SNRootKey> {
const pwCost = Utils.lastElement(V002Algorithm.PbkdfCostsUsed) as number
const pwNonce = this.crypto.generateRandomKey(V002Algorithm.SaltSeedLength)
const pwSalt = await this.crypto.unsafeSha1(identifier + ':' + pwNonce)
const keyParams = Create002KeyParams({
email: identifier,
pw_nonce: pwNonce,
pw_cost: pwCost,
pw_salt: pwSalt,
version: Common.ProtocolVersion.V002,
origination,
created: `${Date.now()}`,
})
return this.deriveKey(password, keyParams)
}
/**
* Note that version 002 supported "dynamic" iteration counts. Some accounts
* 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<SNRootKey> {
return this.deriveKey(password, keyParams)
}
private async decryptString002(text: string, key: string, iv: string) {
return this.crypto.aes256CbcDecrypt(text, iv, key)
}
private async encryptString002(text: string, key: string, iv: string) {
return this.crypto.aes256CbcEncrypt(text, iv, key)
}
/**
* @param keyParams Supplied only when encrypting an items key
*/
private async encryptTextParams(
string: string,
encryptionKey: string,
authKey: string,
uuid: string,
version: Common.ProtocolVersion,
keyParams?: SNRootKeyParams,
) {
const iv = this.crypto.generateRandomKey(V002Algorithm.EncryptionIvLength)
const contentCiphertext = await this.encryptString002(string, encryptionKey, iv)
const ciphertextToAuth = [version, uuid, iv, contentCiphertext].join(':')
const authHash = await this.crypto.hmac256(ciphertextToAuth, authKey)
if (!authHash) {
throw Error('Error generating hmac256 authHash')
}
const components: string[] = [version as string, authHash, uuid, iv, contentCiphertext]
if (keyParams) {
const keyParamsString = this.crypto.base64Encode(JSON.stringify(keyParams.content))
components.push(keyParamsString)
}
const fullCiphertext = components.join(':')
return fullCiphertext
}
private async decryptTextParams(
ciphertextToAuth: string,
contentCiphertext: string,
encryptionKey: string,
iv: string,
authHash: string,
authKey: string,
) {
if (!encryptionKey) {
throw 'Attempting to decryptTextParams with null encryptionKey'
}
const localAuthHash = await this.crypto.hmac256(ciphertextToAuth, authKey)
if (!localAuthHash) {
throw Error('Error generating hmac256 localAuthHash')
}
if (this.crypto.timingSafeEqual(authHash, localAuthHash) === false) {
console.error(Error('Auth hash does not match.'))
return null
}
return this.decryptString002(contentCiphertext, encryptionKey, iv)
}
public override getPayloadAuthenticatedData(
encrypted: EncryptedParameters,
): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined {
const itemKeyComponents = this.encryptionComponentsFromString002(encrypted.enc_item_key)
const authenticatedData = itemKeyComponents.keyParams
if (!authenticatedData) {
return undefined
}
const decoded = JSON.parse(this.crypto.base64Decode(authenticatedData))
const data: LegacyAttachedData = {
...(decoded as Common.AnyKeyParamsContent),
}
return data
}
public override async generateEncryptedParametersAsync(
payload: Models.DecryptedPayloadInterface,
key: Models.ItemsKeyInterface | SNRootKey,
): Promise<EncryptedParameters> {
/**
* Generate new item key that is double the key size.
* Will be split to create encryption key and authentication key.
*/
const itemKey = this.crypto.generateRandomKey(V002Algorithm.EncryptionKeyLength * 2)
const encItemKey = await this.encryptTextParams(
itemKey,
key.itemsKey,
key.dataAuthenticationKey as string,
payload.uuid,
key.keyVersion,
key instanceof SNRootKey ? (key as SNRootKey).keyParams : undefined,
)
const ek = Utils.firstHalfOfString(itemKey)
const ak = Utils.secondHalfOfString(itemKey)
const ciphertext = await this.encryptTextParams(
JSON.stringify(payload.content),
ek,
ak,
payload.uuid,
key.keyVersion,
key instanceof SNRootKey ? (key as SNRootKey).keyParams : undefined,
)
return {
uuid: payload.uuid,
items_key_id: isItemsKey(key) ? key.uuid : undefined,
content: ciphertext,
enc_item_key: encItemKey,
version: this.version,
}
}
public override async generateDecryptedParametersAsync<C extends ItemContent = ItemContent>(
encrypted: EncryptedParameters,
key: Models.ItemsKeyInterface | SNRootKey,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
if (!encrypted.enc_item_key) {
console.error(Error('Missing item encryption key, skipping decryption.'))
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
}
const encryptedItemKey = encrypted.enc_item_key
const itemKeyComponents = this.encryptionComponentsFromString002(
encryptedItemKey,
key.itemsKey,
key.dataAuthenticationKey,
)
const itemKey = await this.decryptTextParams(
itemKeyComponents.ciphertextToAuth,
itemKeyComponents.contentCiphertext,
itemKeyComponents.encryptionKey as string,
itemKeyComponents.iv,
itemKeyComponents.authHash,
itemKeyComponents.authKey as string,
)
if (!itemKey) {
console.error('Error decrypting item_key parameters', encrypted)
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
}
const ek = Utils.firstHalfOfString(itemKey)
const ak = Utils.secondHalfOfString(itemKey)
const itemParams = this.encryptionComponentsFromString002(encrypted.content, ek, ak)
const content = await this.decryptTextParams(
itemParams.ciphertextToAuth,
itemParams.contentCiphertext,
itemParams.encryptionKey as string,
itemParams.iv,
itemParams.authHash,
itemParams.authKey as string,
)
if (!content) {
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
} else {
return {
uuid: encrypted.uuid,
content: JSON.parse(content),
}
}
}
protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
const derivedKey = await this.crypto.pbkdf2(
password,
keyParams.content002.pw_salt,
keyParams.content002.pw_cost,
V002Algorithm.PbkdfOutputLength,
)
if (!derivedKey) {
throw Error('Error deriving PBKDF2 key')
}
const partitions = Utils.splitString(derivedKey, 3)
return CreateNewRootKey({
serverPassword: partitions[0],
masterKey: partitions[1],
dataAuthenticationKey: partitions[2],
version: Common.ProtocolVersion.V002,
keyParams: keyParams.getPortableValue(),
})
}
private encryptionComponentsFromString002(string: string, encryptionKey?: string, authKey?: string) {
const components = string.split(':')
return {
encryptionVersion: components[0],
authHash: components[1],
uuid: components[2],
iv: components[3],
contentCiphertext: components[4],
keyParams: components[5],
ciphertextToAuth: [components[0], components[2], components[3], components[4]].join(':'),
encryptionKey: encryptionKey,
authKey: authKey,
}
}
}

View File

@@ -0,0 +1,111 @@
import { splitString, UuidGenerator } from '@standardnotes/utils'
import {
CreateDecryptedItemFromPayload,
DecryptedPayload,
ItemsKeyContent,
ItemsKeyInterface,
FillItemContent,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { SNRootKey } from '../../Keys/RootKey/RootKey'
import { V003Algorithm } from '../../Algorithm'
import { Create003KeyParams } from '../../Keys/RootKey/KeyParamsFunctions'
import { SNProtocolOperator002 } from '../002/Operator002'
import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common'
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
import { CreateNewRootKey } from '../../Keys/RootKey/Functions'
/**
* @legacy
* Non-expired operator but no longer used for generating new accounts.
* This operator subclasses the 002 operator to share functionality that has not
* changed, and overrides functions where behavior may differ.
*/
export class SNProtocolOperator003 extends SNProtocolOperator002 {
override get version(): ProtocolVersion {
return ProtocolVersion.V003
}
protected override generateNewItemsKeyContent(): ItemsKeyContent {
const keyLength = V003Algorithm.EncryptionKeyLength
const itemsKey = this.crypto.generateRandomKey(keyLength)
const authKey = this.crypto.generateRandomKey(keyLength)
const response = FillItemContent<ItemsKeyContent>({
itemsKey: itemsKey,
dataAuthenticationKey: authKey,
version: ProtocolVersion.V003,
})
return response
}
/**
* Creates a new random items key to use for item encryption.
* The consumer must save/sync this item.
*/
public override createItemsKey(): ItemsKeyInterface {
const content = this.generateNewItemsKeyContent()
const payload = new DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.ItemsKey,
content: FillItemContent(content),
...PayloadTimestampDefaults(),
})
return CreateDecryptedItemFromPayload(payload)
}
public override async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
return this.deriveKey(password, keyParams)
}
protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
const salt = await this.generateSalt(
keyParams.content003.identifier,
ProtocolVersion.V003,
V003Algorithm.PbkdfCost,
keyParams.content003.pw_nonce,
)
const derivedKey = await this.crypto.pbkdf2(
password,
salt,
V003Algorithm.PbkdfCost,
V003Algorithm.PbkdfOutputLength,
)
if (!derivedKey) {
throw Error('Error deriving PBKDF2 key')
}
const partitions = splitString(derivedKey, 3)
return CreateNewRootKey({
serverPassword: partitions[0],
masterKey: partitions[1],
dataAuthenticationKey: partitions[2],
version: ProtocolVersion.V003,
keyParams: keyParams.getPortableValue(),
})
}
public override async createRootKey(
identifier: string,
password: string,
origination: KeyParamsOrigination,
): Promise<SNRootKey> {
const version = ProtocolVersion.V003
const pwNonce = this.crypto.generateRandomKey(V003Algorithm.SaltSeedLength)
const keyParams = Create003KeyParams({
identifier: identifier,
pw_nonce: pwNonce,
version: version,
origination: origination,
created: `${Date.now()}`,
})
return this.deriveKey(password, keyParams)
}
private async generateSalt(identifier: string, version: ProtocolVersion, cost: number, nonce: string) {
const result = await this.crypto.sha256([identifier, 'SF', version, cost, nonce].join(':'))
return result
}
}

View File

@@ -0,0 +1,96 @@
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'
import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData'
import { SNProtocolOperator004 } from './Operator004'
const b64 = (text: string): string => {
return Buffer.from(text).toString('base64')
}
describe('operator 004', () => {
let crypto: PureCryptoInterface
let operator: SNProtocolOperator004
beforeEach(() => {
crypto = {} as jest.Mocked<PureCryptoInterface>
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 `<e>${text}<e>`
})
crypto.xchacha20Decrypt = jest.fn().mockImplementation((text: string) => {
return text.split('<e>')[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}:<e>${plaintext}<e>:${b64(JSON.stringify(aad))}`)
})
it('should deconstructEncryptedPayloadString', () => {
const string = '004:noncy:<e>foo<e>:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9'
const result = operator.deconstructEncryptedPayloadString(string)
expect(result).toEqual({
version: '004',
nonce: 'noncy',
ciphertext: '<e>foo<e>',
authenticatedData: 'eyJ1IjoiMTIzIiwidiI6IjAwNCJ9',
})
})
it('should generateEncryptedParametersSync', () => {
const payload = {
uuid: '123',
content_type: ContentType.Note,
content: { foo: 'bar' } as unknown as jest.Mocked<ItemContent>,
...PayloadTimestampDefaults(),
} as jest.Mocked<DecryptedPayload>
const key = new SNItemsKey(
new DecryptedPayload<ItemsKeyContent>({
uuid: 'key-456',
content_type: ContentType.ItemsKey,
content: {
itemsKey: 'secret',
version: ProtocolVersion.V004,
} as jest.Mocked<ItemsKeyContent>,
...PayloadTimestampDefaults(),
}),
)
const result = operator.generateEncryptedParametersSync(payload, key)
expect(result).toEqual({
uuid: '123',
items_key_id: 'key-456',
content: '004:random-string:<e>{"foo":"bar"}<e>:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9',
enc_item_key: '004:random-string:<e>random-string<e>:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9',
version: '004',
})
})
})

View File

@@ -0,0 +1,319 @@
import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common'
import { Create004KeyParams } from '../../Keys/RootKey/KeyParamsFunctions'
import { SynchronousOperator } from '../Operator'
import {
CreateDecryptedItemFromPayload,
FillItemContent,
ItemContent,
ItemsKeyContent,
ItemsKeyInterface,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SNRootKey } from '../../Keys/RootKey/RootKey'
import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams'
import { V004Algorithm } from '../../Algorithm'
import * as Models from '@standardnotes/models'
import * as Utils from '@standardnotes/utils'
import { ContentTypeUsesRootKeyEncryption, CreateNewRootKey } from '../../Keys/RootKey/Functions'
import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters'
import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData'
import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData'
import { LegacyAttachedData } from '../../Types/LegacyAttachedData'
import { isItemsKey } from '../../Keys/ItemsKey'
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
}
public getEncryptionDisplayName(): string {
return 'XChaCha20-Poly1305'
}
get version(): ProtocolVersion {
return ProtocolVersion.V004
}
private generateNewItemsKeyContent() {
const itemsKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength)
const response = FillItemContent<ItemsKeyContent>({
itemsKey: itemsKey,
version: ProtocolVersion.V004,
})
return response
}
/**
* Creates a new random items key to use for item encryption.
* The consumer must save/sync this item.
*/
public createItemsKey(): ItemsKeyInterface {
const payload = new Models.DecryptedPayload({
uuid: Utils.UuidGenerator.GenerateUuid(),
content_type: ContentType.ItemsKey,
content: this.generateNewItemsKeyContent(),
...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)
}
/**
* 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<SNRootKey> {
return this.deriveKey(password, keyParams)
}
/**
* 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(
identifier: string,
password: string,
origination: KeyParamsOrigination,
): Promise<SNRootKey> {
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)
}
/**
* @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,
): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined {
const itemKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key)
const authenticatedDataString = itemKeyComponents.authenticatedData
const result = this.stringToAuthenticatedData(authenticatedDataString)
return result
}
/**
* 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
}
}
private authenticatedDataToString(attachedData: ItemAuthenticatedData) {
return this.crypto.base64Encode(JSON.stringify(Utils.sortedCopy(Utils.omitUndefinedCopy(attachedData))))
}
private stringToAuthenticatedData(
rawAuthenticatedData: string,
override?: Partial<ItemAuthenticatedData>,
): 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<C extends ItemContent = ItemContent>(
encrypted: EncryptedParameters,
key: ItemsKeyInterface | SNRootKey,
): DecryptedParameters<C> | ErrorDecryptingParameters {
const itemKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key)
const authenticatedData = this.stringToAuthenticatedData(itemKeyComponents.authenticatedData, {
u: encrypted.uuid,
v: encrypted.version,
})
const useAuthenticatedString = this.authenticatedDataToString(authenticatedData)
const itemKey = this.decryptString004(
itemKeyComponents.ciphertext,
key.itemsKey,
itemKeyComponents.nonce,
useAuthenticatedString,
)
if (!itemKey) {
console.error('Error decrypting itemKey parameters', encrypted)
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
}
const contentComponents = this.deconstructEncryptedPayloadString(encrypted.content)
const content = this.decryptString004(
contentComponents.ciphertext,
itemKey,
contentComponents.nonce,
useAuthenticatedString,
)
if (!content) {
return {
uuid: encrypted.uuid,
errorDecrypting: true,
}
} else {
return {
uuid: encrypted.uuid,
content: JSON.parse(content),
}
}
}
private async deriveKey(password: string, keyParams: SNRootKeyParams): Promise<SNRootKey> {
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(),
})
}
}

View File

@@ -0,0 +1,30 @@
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
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 { ProtocolVersion } from '@standardnotes/common'
export function createOperatorForVersion(
version: ProtocolVersion,
crypto: PureCryptoInterface,
): AsynchronousOperator | SynchronousOperator {
if (version === ProtocolVersion.V001) {
return new SNProtocolOperator001(crypto)
} else if (version === ProtocolVersion.V002) {
return new SNProtocolOperator002(crypto)
} else if (version === ProtocolVersion.V003) {
return new SNProtocolOperator003(crypto)
} else if (version === ProtocolVersion.V004) {
return new SNProtocolOperator004(crypto)
} else {
throw Error(`Unable to find operator for version ${version}`)
}
}
export function isAsyncOperator(
operator: AsynchronousOperator | SynchronousOperator,
): operator is AsynchronousOperator {
return (operator as AsynchronousOperator).generateDecryptedParametersAsync !== undefined
}

View File

@@ -0,0 +1,86 @@
import { ItemsKeyInterface, RootKeyInterface } from '@standardnotes/models'
import * as Models from '@standardnotes/models'
import { SNRootKey } from '../Keys/RootKey/RootKey'
import { SNRootKeyParams } from '../Keys/RootKey/RootKeyParams'
import { KeyParamsOrigination } from '@standardnotes/common'
import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../Types/EncryptedParameters'
import { RootKeyEncryptedAuthenticatedData } from '../Types/RootKeyEncryptedAuthenticatedData'
import { ItemAuthenticatedData } from '../Types/ItemAuthenticatedData'
import { LegacyAttachedData } from '../Types/LegacyAttachedData'
/**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<SNRootKey>
/**
* 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<SNRootKey>
}
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<C extends Models.ItemContent = Models.ItemContent>(
encrypted: EncryptedParameters,
key: ItemsKeyInterface | RootKeyInterface,
): DecryptedParameters<C> | 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<EncryptedParameters>
generateDecryptedParametersAsync<C extends Models.ItemContent = Models.ItemContent>(
encrypted: EncryptedParameters,
key: ItemsKeyInterface | RootKeyInterface,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters>
}

View File

@@ -0,0 +1,34 @@
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { ProtocolVersion, ProtocolVersionLatest } from '@standardnotes/common'
import { createOperatorForVersion } from './Functions'
import { AsynchronousOperator, SynchronousOperator } from './Operator'
export class OperatorManager {
private operators: Record<string, AsynchronousOperator | SynchronousOperator> = {}
constructor(private crypto: PureCryptoInterface) {
this.crypto = crypto
}
public deinit(): void {
;(this.crypto as unknown) = undefined
this.operators = {}
}
public operatorForVersion(version: ProtocolVersion): SynchronousOperator | AsynchronousOperator {
const operatorKey = version
let operator = this.operators[operatorKey]
if (!operator) {
operator = createOperatorForVersion(version, this.crypto)
this.operators[operatorKey] = operator
}
return operator
}
/**
* Returns the operator corresponding to the latest protocol version
*/
public defaultOperator(): SynchronousOperator | AsynchronousOperator {
return this.operatorForVersion(ProtocolVersionLatest)
}
}

View File

@@ -0,0 +1,52 @@
import { isAsyncOperator } from './Functions'
import { OperatorManager } from './OperatorManager'
import * as Models from '@standardnotes/models'
import {
DecryptedParameters,
EncryptedParameters,
encryptedParametersFromPayload,
ErrorDecryptingParameters,
} from '../Types/EncryptedParameters'
export async function encryptPayload(
payload: Models.DecryptedPayloadInterface,
key: Models.ItemsKeyInterface | Models.RootKeyInterface,
operatorManager: OperatorManager,
): Promise<EncryptedParameters> {
const operator = operatorManager.operatorForVersion(key.keyVersion)
let encryptionParameters
if (isAsyncOperator(operator)) {
encryptionParameters = await operator.generateEncryptedParametersAsync(payload, key)
} else {
encryptionParameters = operator.generateEncryptedParametersSync(payload, key)
}
if (!encryptionParameters) {
throw 'Unable to generate encryption parameters'
}
return encryptionParameters
}
export async function decryptPayload<C extends Models.ItemContent = Models.ItemContent>(
payload: Models.EncryptedPayloadInterface,
key: Models.ItemsKeyInterface | Models.RootKeyInterface,
operatorManager: OperatorManager,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
const operator = operatorManager.operatorForVersion(payload.version)
try {
if (isAsyncOperator(operator)) {
return await operator.generateDecryptedParametersAsync(encryptedParametersFromPayload(payload), key)
} else {
return operator.generateDecryptedParametersSync(encryptedParametersFromPayload(payload), key)
}
} catch (e) {
console.error('Error decrypting payload', payload, e)
return {
uuid: payload.uuid,
errorDecrypting: true,
}
}
}

View File

@@ -0,0 +1,9 @@
export * from './001/Operator001'
export * from './002/Operator002'
export * from './003/Operator003'
export * from './004/Operator004'
export * from './Operator'
export * from '../Types/EncryptedParameters'
export * from '../Types/ItemAuthenticatedData'
export * from '../Types/LegacyAttachedData'
export * from '../Types/RootKeyEncryptedAuthenticatedData'