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,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(),
})
}
}