@relaycorp/webcrypto-kms
Version:
WebCrypto-compatible client for Key Management Services like GCP KMS
176 lines • 7.91 kB
JavaScript
import { calculate as calculateCRC32C } from 'fast-crc32c';
import uuid4 from 'uuid4';
import { bufferToArrayBuffer } from '../utils/buffer';
import { KmsError } from '../KmsError';
import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey';
import { wrapGCPCallError } from './kmsUtils';
import { sleep } from '../utils/timing';
import { derDeserialisePublicKey } from '../utils/crypto';
import { KmsRsaPssProvider } from '../KmsRsaPssProvider';
// See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms
const SUPPORTED_MODULUS_LENGTHS = [2048, 3072, 4096];
// See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms
const SUPPORTED_SALT_LENGTHS = [
256 / 8, // SHA-256
512 / 8, // SHA-512
];
const DEFAULT_DESTROY_SCHEDULED_DURATION_SECONDS = 86_400; // One day; the minimum allowed by GCP
/**
* The official KMS library will often try to make API requests before the authentication with the
* Application Default Credentials is complete, which will result in errors like "Exceeded
* maximum number of retries before any response was received". We're working around that by
* retrying a few times.
*/
const REQUEST_OPTIONS = { timeout: 3_000, maxRetries: 10 };
export class GcpKmsRsaPssProvider extends KmsRsaPssProvider {
client;
config;
constructor(client, config) {
super();
this.client = client;
this.config = config;
// See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms
this.hashAlgorithms = ['SHA-256', 'SHA-512'];
}
async onGenerateKey(algorithm) {
if (!SUPPORTED_MODULUS_LENGTHS.includes(algorithm.modulusLength)) {
throw new KmsError(`Unsupported RSA modulus (${algorithm.modulusLength})`);
}
const projectId = await this.getGCPProjectId();
const cryptoKeyId = uuid4();
await this.createCryptoKey(algorithm, projectId, cryptoKeyId);
const kmsKeyVersionPath = this.client.cryptoKeyVersionPath(projectId, this.config.location, this.config.keyRing, cryptoKeyId, '1');
const privateKey = new GcpKmsRsaPssPrivateKey(kmsKeyVersionPath, algorithm.hash.name, this);
const publicKey = await this.getPublicKeyFromPrivate(privateKey);
return { privateKey, publicKey };
}
async onImportKey(format, keyData, algorithm) {
if (format !== 'raw') {
throw new KmsError('Private key can only be exported to raw format');
}
const kmsKeyVersionPath = Buffer.from(keyData).toString();
return new GcpKmsRsaPssPrivateKey(kmsKeyVersionPath, algorithm.hash.name, this);
}
async onExportKey(format, key) {
requireGcpKmsKey(key);
let keySerialised;
if (format === 'spki') {
keySerialised = await retrieveKMSPublicKey(key.kmsKeyVersionPath, this.client);
}
else if (format === 'raw') {
const pathEncoded = Buffer.from(key.kmsKeyVersionPath);
keySerialised = bufferToArrayBuffer(pathEncoded);
}
else {
throw new KmsError(`Private key cannot be exported as ${format}`);
}
return keySerialised;
}
async onSign(algorithm, key, data) {
requireGcpKmsKey(key);
if (!SUPPORTED_SALT_LENGTHS.includes(algorithm.saltLength)) {
throw new KmsError(`Unsupported salt length of ${algorithm.saltLength} octets`);
}
return this.kmsSign(Buffer.from(data), key);
}
async onVerify() {
throw new KmsError('Signature verification is unsupported');
}
async destroyKey(key) {
requireGcpKmsKey(key);
await wrapGCPCallError(this.client.destroyCryptoKeyVersion({ name: key.kmsKeyVersionPath }, REQUEST_OPTIONS), 'Key destruction failed');
}
async close() {
await this.client.close();
}
async getGCPProjectId() {
// GCP client library already caches the project id.
return this.client.getProjectId();
}
async createCryptoKey(algorithm, projectId, cryptoKeyId) {
const kmsAlgorithm = getKmsAlgorithm(algorithm);
const keyRingName = this.client.keyRingPath(projectId, this.config.location, this.config.keyRing);
const destroyScheduledDuration = {
seconds: this.config.destroyScheduledDurationSeconds ?? DEFAULT_DESTROY_SCHEDULED_DURATION_SECONDS,
};
const creationOptions = {
cryptoKey: {
destroyScheduledDuration,
purpose: 'ASYMMETRIC_SIGN',
versionTemplate: {
algorithm: kmsAlgorithm,
protectionLevel: this.config.protectionLevel,
},
},
cryptoKeyId,
parent: keyRingName,
skipInitialVersionCreation: false,
};
await wrapGCPCallError(this.client.createCryptoKey(creationOptions, REQUEST_OPTIONS), 'Failed to create key');
}
async getPublicKeyFromPrivate(privateKey) {
const publicKeySerialized = (await this.exportKey('spki', privateKey));
return derDeserialisePublicKey(publicKeySerialized, privateKey.algorithm);
}
async kmsSign(plaintext, key) {
const plaintextChecksum = calculateCRC32C(plaintext);
const [response] = await wrapGCPCallError(this.client.asymmetricSign({ data: plaintext, dataCrc32c: { value: plaintextChecksum }, name: key.kmsKeyVersionPath }, REQUEST_OPTIONS), 'KMS signature request failed');
if (response.name !== key.kmsKeyVersionPath) {
throw new KmsError(`KMS used the wrong key version (${response.name})`);
}
if (!response.verifiedDataCrc32c) {
throw new KmsError('KMS failed to verify plaintext CRC32C checksum');
}
const signature = response.signature;
if (calculateCRC32C(signature) !== Number(response.signatureCrc32c.value)) {
throw new KmsError('Signature CRC32C checksum does not match one received from KMS');
}
return bufferToArrayBuffer(signature);
}
}
function requireGcpKmsKey(key) {
if (!(key instanceof GcpKmsRsaPssPrivateKey)) {
throw new KmsError(`Only GCP KMS keys are supported (got ${key.constructor.name})`);
}
}
function getKmsAlgorithm(algorithm) {
const hash = algorithm.hash.name === 'SHA-256' ? 'SHA256' : 'SHA512';
return `RSA_SIGN_PSS_${algorithm.modulusLength}_${hash}`;
}
export async function retrieveKMSPublicKey(kmsKeyVersionName, kmsClient) {
const retrieveWhenReady = async () => {
let key;
try {
key = await _retrieveKMSPublicKey(kmsKeyVersionName, kmsClient);
}
catch (err) {
if (!isKeyPendingCreation(err)) {
throw err;
}
// Let's give KMS a bit more time to generate the key
await sleep(500);
key = await _retrieveKMSPublicKey(kmsKeyVersionName, kmsClient);
}
return key;
};
const publicKeyPEM = await wrapGCPCallError(retrieveWhenReady(), 'Failed to retrieve public key');
const publicKeyDer = pemToDer(publicKeyPEM);
return bufferToArrayBuffer(publicKeyDer);
}
async function _retrieveKMSPublicKey(kmsKeyVersionName, kmsClient) {
const [response] = await kmsClient.getPublicKey({ name: kmsKeyVersionName }, {
maxRetries: 3,
timeout: 300,
});
return response.pem;
}
function isKeyPendingCreation(err) {
const statusDetails = err.statusDetails ?? [];
const pendingCreationViolations = statusDetails.filter((d) => 0 < d.violations.filter((v) => v.type === 'KEY_PENDING_GENERATION').length);
return !!pendingCreationViolations.length;
}
function pemToDer(pemBuffer) {
const oneliner = pemBuffer.toString().replace(/(-----[\w ]*-----|\n)/g, '');
return Buffer.from(oneliner, 'base64');
}
//# sourceMappingURL=GcpKmsRsaPssProvider.js.map