modules/material-management-node/src/material_helpers.ts (262 lines of code) (raw):

// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { needs, NodeEncryptionMaterial, NodeDecryptionMaterial, unwrapDataKey, wrapWithKeyObjectIfSupported, AwsEsdkKeyObject, NodeHash, } from '@aws-crypto/material-management' import { createCipheriv, createDecipheriv, createSign, createVerify, timingSafeEqual, } from 'crypto' import { HKDF } from '@aws-crypto/hkdf-node' import { kdfInfo, kdfCommitKeyInfo } from '@aws-crypto/serialize' import { AwsESDKSigner, AwsESDKVerify } from './types' export interface AwsEsdkJsCipherGCM { update(data: Buffer): Buffer final(): Buffer getAuthTag(): Buffer setAAD(aad: Buffer): this } export interface AwsEsdkJsDecipherGCM { update(data: Buffer): Buffer final(): Buffer setAuthTag(buffer: Buffer): this setAAD(aad: Buffer): this } type KDFIndex = Readonly<{ [K in NodeHash]: ReturnType<typeof HKDF> }> const kdfIndex: KDFIndex = Object.freeze({ sha256: HKDF('sha256' as NodeHash), sha384: HKDF('sha384' as NodeHash), sha512: HKDF('sha512' as NodeHash), }) export interface GetCipher { (iv: Uint8Array): AwsEsdkJsCipherGCM } export interface GetCipherInfo { (messageId: Uint8Array): { getCipher: GetCipher keyCommitment?: Uint8Array } } export interface GetSigner { (): AwsESDKSigner & { awsCryptoSign: () => Buffer } } export interface NodeEncryptionMaterialHelper { getCipherInfo: GetCipherInfo getSigner?: GetSigner dispose: () => void } export interface GetEncryptHelper { (material: NodeEncryptionMaterial): NodeEncryptionMaterialHelper } export const getEncryptHelper: GetEncryptHelper = ( material: NodeEncryptionMaterial ) => { /* Precondition: NodeEncryptionMaterial must have a valid data key. */ needs(material.hasValidKey(), 'Material has no unencrypted data key.') const { signatureHash } = material.suite /* Conditional types can not narrow the return type :( * Function overloads "works" but then I can not export * the function and have eslint be happy (Multiple exports of name) */ const getCipherInfo = curryCryptoStream(material, createCipheriv) return Object.freeze({ getCipherInfo, getSigner: signatureHash ? getSigner : undefined, dispose, }) function getSigner() { /* Precondition: The NodeEncryptionMaterial must have not been zeroed. * hasUnencryptedDataKey will check that the unencrypted data key has been set * *and* that it has not been zeroed. At this point it must have been set * because the KDF function operated on it. So at this point * we are protecting that someone has zeroed out the material * because the Encrypt process has been complete. */ needs( material.hasUnencryptedDataKey, 'Unencrypted data key has been zeroed.' ) if (!signatureHash) throw new Error('Material does not support signature.') const { signatureKey } = material if (!signatureKey) throw new Error('Material does not support signature.') const { privateKey } = signatureKey if (typeof privateKey !== 'string') throw new Error('Material does not support signature.') const signer = Object.assign( createSign(signatureHash), // don't export the private key if we don't have to { awsCryptoSign: () => signer.sign(privateKey) } ) return signer } function dispose() { material.zeroUnencryptedDataKey() } } export interface GetDecipher { (iv: Uint8Array): AwsEsdkJsDecipherGCM } export interface GetDecipherInfo { (messageId: Uint8Array, commitKey?: Uint8Array): GetDecipher } export interface GetVerify { (): AwsESDKVerify & { awsCryptoVerify: (signature: Buffer) => boolean } } export interface NodeDecryptionMaterialHelper { getDecipherInfo: GetDecipherInfo getVerify?: GetVerify dispose: () => void } export interface GetDecryptionHelper { (material: NodeDecryptionMaterial): NodeDecryptionMaterialHelper } export const getDecryptionHelper: GetDecryptionHelper = ( material: NodeDecryptionMaterial ) => { /* Precondition: NodeDecryptionMaterial must have a valid data key. */ needs(material.hasValidKey(), 'Material has no unencrypted data key.') const { signatureHash } = material.suite /* Conditional types can not narrow the return type :( * Function overloads "works" but then I can not export * the function and have eslint be happy (Multiple exports of name) */ const getDecipherInfo = curryCryptoStream(material, createDecipheriv) return Object.freeze({ getDecipherInfo, getVerify: signatureHash ? getVerify : undefined, dispose, }) function getVerify() { if (!signatureHash) throw new Error('Material does not support signature.') const { verificationKey } = material if (!verificationKey) throw new Error('Material does not support signature.') const verify = Object.assign( createVerify(signatureHash), // explicitly bind the public key for this material { awsCryptoVerify: (signature: Buffer) => // As typescript gets better typing // We should consider either generics or // 2 different verificationKeys for Node and WebCrypto verify.verify(verificationKey.publicKey as string, signature), } ) return verify } function dispose() { material.zeroUnencryptedDataKey() } } type CreateCryptoIvStream< Material extends NodeEncryptionMaterial | NodeDecryptionMaterial > = Material extends NodeEncryptionMaterial ? typeof createCipheriv : typeof createDecipheriv type CryptoStream< Material extends NodeEncryptionMaterial | NodeDecryptionMaterial > = Material extends NodeEncryptionMaterial ? AwsEsdkJsCipherGCM : AwsEsdkJsDecipherGCM type CreateCryptoStream< Material extends NodeEncryptionMaterial | NodeDecryptionMaterial > = (iv: Uint8Array) => CryptoStream<Material> type CurryHelper< Material extends NodeEncryptionMaterial | NodeDecryptionMaterial > = Material extends NodeEncryptionMaterial ? { getCipher: CreateCryptoStream<Material> keyCommitment: Uint8Array } : Material extends NodeDecryptionMaterial ? CreateCryptoStream<Material> : never export function curryCryptoStream< Material extends NodeEncryptionMaterial | NodeDecryptionMaterial >(material: Material, createCryptoIvStream: CreateCryptoIvStream<Material>) { const { encryption: cipherName, ivLength } = material.suite const isEncrypt = material instanceof NodeEncryptionMaterial /* Precondition: material must be either NodeEncryptionMaterial or NodeDecryptionMaterial. * */ needs( isEncrypt ? createCipheriv === createCryptoIvStream : material instanceof NodeDecryptionMaterial ? createDecipheriv === createCryptoIvStream : false, 'Unsupported cryptographic material.' ) return (messageId: Uint8Array, commitKey?: Uint8Array) => { const { derivedKey, keyCommitment } = nodeKdf( material, messageId, commitKey ) return ( isEncrypt ? { getCipher: createCryptoStream, keyCommitment } : createCryptoStream ) as CurryHelper<Material> function createCryptoStream(iv: Uint8Array): CryptoStream<Material> { /* Precondition: The length of the IV must match the NodeAlgorithmSuite specification. */ needs( iv.byteLength === ivLength, 'Iv length does not match algorithm suite specification' ) /* Precondition: The material must have not been zeroed. * hasUnencryptedDataKey will check that the unencrypted data key has been set * *and* that it has not been zeroed. At this point it must have been set * because the KDF function operated on it. So at this point * we are protecting that someone has zeroed out the material * because the Encrypt process has been complete. */ needs( material.hasUnencryptedDataKey, 'Unencrypted data key has been zeroed.' ) /* createDecipheriv is incorrectly typed in @types/node. It should take key: CipherKey, not key: BinaryLike. * Also, the check above ensures * that _createCryptoStream is not false. * But TypeScript does not believe me. * For any complicated code, * you should defer to the checker, * but here I'm going to assert * it is simple enough. */ return createCryptoIvStream( cipherName, derivedKey as any, iv ) as unknown as CryptoStream<Material> } } } export function nodeKdf( material: NodeEncryptionMaterial | NodeDecryptionMaterial, nonce: Uint8Array, commitKey?: Uint8Array ): { derivedKey: Uint8Array | AwsEsdkKeyObject keyCommitment?: Uint8Array } { const dataKey = material.getUnencryptedDataKey() const { kdf, kdfHash, keyLengthBytes, commitmentLength, saltLengthBytes, commitment, id: suiteId, } = material.suite /* Check for early return (Postcondition): No Node.js KDF, just return the unencrypted data key. */ if (!kdf && !kdfHash) { /* Postcondition: Non-KDF algorithm suites *must* not have a commitment. */ needs(!commitKey, 'Commitment not supported.') return { derivedKey: dataKey } } /* Precondition: Valid HKDF values must exist for Node.js. */ needs( kdf === 'HKDF' && kdfHash && kdfIndex[kdfHash] && nonce instanceof Uint8Array, 'Invalid HKDF values.' ) /* The unwrap is done once we *know* that a KDF is required. * If we unwrapped before everything will work, * but we may be creating new copies of the unencrypted data key (export). */ const { buffer: dkBuffer, byteOffset: dkByteOffset, byteLength: dkByteLength, } = unwrapDataKey(dataKey) if (commitment === 'NONE') { /* Postcondition: Non-committing Node algorithm suites *must* not have a commitment. */ needs(!commitKey, 'Commitment not supported.') const toExtract = Buffer.from(dkBuffer, dkByteOffset, dkByteLength) const { buffer, byteOffset, byteLength } = kdfInfo(suiteId, nonce) const infoBuff = Buffer.from(buffer, byteOffset, byteLength) const derivedBytes = kdfIndex[kdfHash as NodeHash](toExtract)( keyLengthBytes, infoBuff ) const derivedKey = wrapWithKeyObjectIfSupported(derivedBytes) return { derivedKey } } /* Precondition UNTESTED: Committing suites must define expected values. */ needs( commitment === 'KEY' && commitmentLength && saltLengthBytes, 'Malformed suite data.' ) /* Precondition: For committing algorithms, the nonce *must* be 256 bit. * i.e. It must target a V2 message format. */ needs( nonce.byteLength === saltLengthBytes, 'Nonce is not the correct length for committed algorithm suite.' ) const toExtract = Buffer.from(dkBuffer, dkByteOffset, dkByteLength) const expand = kdfIndex[kdfHash as NodeHash](toExtract, nonce) const { keyLabel, commitLabel } = kdfCommitKeyInfo(material.suite) const keyCommitment = expand(commitmentLength / 8, commitLabel) const isDecrypt = material instanceof NodeDecryptionMaterial /* Precondition: If material is NodeDecryptionMaterial the key commitments *must* match. * This is also the preferred location to check, * because then the decryption key is never even derived. */ needs( (isDecrypt && commitKey && timingSafeEqual(keyCommitment, commitKey)) || (!isDecrypt && !commitKey), isDecrypt ? 'Commitment does not match.' : 'Invalid arguments.' ) const derivedBytes = expand(keyLengthBytes, keyLabel) const derivedKey = wrapWithKeyObjectIfSupported(derivedBytes) return { derivedKey, keyCommitment } }