@vechain/sdk-aws-kms-adapter
Version: 
This module implements the VeChain abstract signer so it is integrated with AWS KMS
357 lines (326 loc) • 14.1 kB
text/typescript
import { bytesToHex, concatBytes } from '@noble/curves/abstract/utils';
import { type SignatureType } from '@noble/curves/abstract/weierstrass';
import { secp256k1 } from '@noble/curves/secp256k1';
import { Address, Hex, Transaction } from '@vechain/sdk-core';
import { JSONRPCInvalidParams, SignerMethodError } from '@vechain/sdk-errors';
import {
    type AvailableVeChainProviders,
    DelegationHandler,
    RPC_METHODS,
    type TransactionRequestInput,
    VeChainAbstractSigner
} from '@vechain/sdk-network';
import { BitString, ObjectIdentifier, Sequence, verifySchema } from 'asn1js';
import { recoverPublicKey, toHex } from 'viem';
import { KMSVeChainProvider } from './KMSVeChainProvider';
class KMSVeChainSigner extends VeChainAbstractSigner {
    private readonly kmsVeChainProvider?: KMSVeChainProvider;
    private readonly kmsVeChainGasPayerProvider?: KMSVeChainProvider;
    private readonly kmsVeChainGasPayerServiceUrl?: string;
    public constructor(
        provider?: AvailableVeChainProviders,
        gasPayer?: {
            provider?: AvailableVeChainProviders;
            url?: string;
        }
    ) {
        // Origin provider
        super(provider);
        if (this.provider !== undefined) {
            if (!(this.provider instanceof KMSVeChainProvider)) {
                throw new JSONRPCInvalidParams(
                    'KMSVeChainSigner.constructor',
                    'The provider must be an instance of KMSVeChainProvider.',
                    { provider }
                );
            }
            this.kmsVeChainProvider = this.provider;
        }
        // Gas-payer provider, if any
        if (gasPayer !== undefined) {
            if (
                gasPayer.provider !== undefined &&
                gasPayer.provider instanceof KMSVeChainProvider
            ) {
                this.kmsVeChainGasPayerProvider = gasPayer.provider;
            } else if (gasPayer.url !== undefined) {
                this.kmsVeChainGasPayerServiceUrl = gasPayer.url;
            } else {
                throw new JSONRPCInvalidParams(
                    'KMSVeChainSigner.constructor',
                    'The gasPayer object is not well formed, either provider or url should be provided.',
                    { gasPayer }
                );
            }
        }
    }
    /**
     * Connects the signer to a provider.
     * @param provider The provider to connect to.
     * @returns {this} The signer instance.
     * @override VeChainAbstractSigner.connect
     **/
    public connect(provider: AvailableVeChainProviders): this {
        try {
            return new KMSVeChainSigner(provider) as this;
        } catch (error) {
            throw new SignerMethodError(
                'KMSVeChainSigner.connect',
                'The signer could not be connected to the provider.',
                { provider },
                error
            );
        }
    }
    /**
     * Decodes the public key from the DER-encoded public key.
     * @param {Uint8Array} encodedPublicKey DER-encoded public key
     * @returns {Uint8Array} The decoded public key.
     */
    private decodePublicKey(encodedPublicKey: Uint8Array): Uint8Array {
        const schema = new Sequence({
            value: [
                new Sequence({ value: [new ObjectIdentifier()] }),
                new BitString({ name: 'objectIdentifier' })
            ]
        });
        const parsed = verifySchema(encodedPublicKey, schema);
        if (!parsed.verified) {
            throw new SignerMethodError(
                'KMSVeChainSigner.decodePublicKey',
                `Failed to parse the encoded public key: ${parsed.result.error}`,
                { parsed }
            );
        }
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const objectIdentifier: ArrayBuffer =
            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
            parsed.result.objectIdentifier.valueBlock.valueHex;
        return new Uint8Array(objectIdentifier);
    }
    /**
     * Gets the DER-encoded public key from KMS and decodes it.
     * @param {KMSVeChainProvider} kmsProvider (Optional) The provider to get the public key from.
     * @returns {Uint8Array} The decoded public key.
     */
    private async getDecodedPublicKey(
        kmsProvider: KMSVeChainProvider | undefined = this.kmsVeChainProvider
    ): Promise<Uint8Array> {
        if (kmsProvider === undefined) {
            throw new JSONRPCInvalidParams(
                'KMSVeChainSigner.getDecodedPublicKey',
                'Thor provider is not found into the signer. Please attach a Provider to your signer instance.',
                {}
            );
        }
        const publicKey = await kmsProvider.getPublicKey();
        return this.decodePublicKey(publicKey);
    }
    /**
     * It returns the address associated with the signer.
     * @param {boolean} fromGasPayerProvider (Optional) If true, the provider will be the gasPayer.
     * @returns The address associated with the signer.
     */
    public async getAddress(
        fromGasPayerProvider: boolean | undefined = false
    ): Promise<string> {
        try {
            const kmsProvider = fromGasPayerProvider
                ? this.kmsVeChainGasPayerProvider
                : this.kmsVeChainProvider;
            const publicKeyDecoded =
                await this.getDecodedPublicKey(kmsProvider);
            return Address.ofPublicKey(publicKeyDecoded).toString();
        } catch (error) {
            throw new SignerMethodError(
                'KMSVeChainSigner.getAddress',
                'The address could not be retrieved.',
                { fromGasPayerProvider },
                error
            );
        }
    }
    /**
     * It builds a VeChain signature from a bytes' payload.
     * @param {Uint8Array} payload to sign.
     * @param {KMSVeChainProvider} kmsProvider The provider to sign the payload.
     * @returns {Uint8Array} The signature following the VeChain format.
     * @throws JSONRPCInvalidParams if `kmsProvider` is undefined.
     */
    private async buildVeChainSignatureFromPayload(
        payload: Uint8Array,
        kmsProvider: KMSVeChainProvider | undefined = this.kmsVeChainProvider
    ): Promise<Uint8Array> {
        if (kmsProvider === undefined) {
            throw new JSONRPCInvalidParams(
                'KMSVeChainSigner.buildVeChainSignatureFromPayload',
                'Thor provider is not found into the signer. Please attach a Provider to your signer instance.',
                { payload }
            );
        }
        // Sign the transaction hash
        const signature = await kmsProvider.sign(payload);
        // Build the VeChain signature using the r, s and v components
        const hexSignature = bytesToHex(signature);
        const decodedSignatureWithoutRecoveryBit =
            secp256k1.Signature.fromDER(hexSignature).normalizeS();
        const recoveryBit = await this.getRecoveryBit(
            decodedSignatureWithoutRecoveryBit,
            payload,
            kmsProvider
        );
        return concatBytes(
            decodedSignatureWithoutRecoveryBit.toCompactRawBytes(),
            new Uint8Array([recoveryBit])
        );
    }
    /**
     * Returns the recovery bit of a signature.
     * @param {SignatureType} decodedSignatureWithoutRecoveryBit Signature with the R and S components only.
     * @param {Uint8Array} transactionHash Raw transaction hash.
     * @param {KMSVeChainProvider} kmsProvider The provider to sign the payload.
     * @returns {number} The V component of the signature (either 0 or 1).
     */
    private async getRecoveryBit(
        decodedSignatureWithoutRecoveryBit: SignatureType,
        transactionHash: Uint8Array,
        kmsProvider: KMSVeChainProvider
    ): Promise<number> {
        const publicKey = await this.getDecodedPublicKey(kmsProvider);
        const publicKeyHex = toHex(publicKey);
        for (let i = 0n; i < 2n; i++) {
            const publicKeyRecovered = await recoverPublicKey({
                hash: transactionHash,
                signature: {
                    r: toHex(decodedSignatureWithoutRecoveryBit.r),
                    s: toHex(decodedSignatureWithoutRecoveryBit.s),
                    v: i
                }
            });
            if (publicKeyRecovered === publicKeyHex) {
                return Number(i);
            }
        }
        throw new SignerMethodError(
            'KMSVeChainSigner.getRecoveryBit',
            'The recovery bit could not be found.',
            { decodedSignatureWithoutRecoveryBit, transactionHash }
        );
    }
    /**
     * Processes a transaction by signing its hash with the origin key and, if delegation is available,
     * appends a gas payer's signature to the original signature.
     *
     * @param {Transaction} transaction - The transaction to be processed, provides the transaction hash and necessary details.
     * @return {Promise<Uint8Array>} A Promise that resolves to a byte array containing the combined origin and gas payer signatures,
     * or just the origin signature if no gas payer provider or service URL is available.
     * @throws JSONRPCInvalidParams if {@link this.provider} is undefined.
     */
    private async concatSignatureIfDelegation(
        transaction: Transaction
    ): Promise<Uint8Array> {
        // Get the transaction hash
        const transactionHash = transaction.getTransactionHash().bytes;
        // Sign the transaction hash using origin key
        const originSignature =
            await this.buildVeChainSignatureFromPayload(transactionHash);
        // We try first in case there is a gasPayer provider
        if (this.kmsVeChainGasPayerProvider !== undefined) {
            const publicKeyDecoded = await this.getDecodedPublicKey();
            const originAddress = Address.ofPublicKey(publicKeyDecoded);
            const delegatedHash =
                transaction.getTransactionHash(originAddress).bytes;
            const gasPayerSignature =
                await this.buildVeChainSignatureFromPayload(
                    delegatedHash,
                    this.kmsVeChainGasPayerProvider
                );
            return concatBytes(originSignature, gasPayerSignature);
        } else if (
            // If not, we try with the gasPayer URL
            this.kmsVeChainGasPayerServiceUrl !== undefined
        ) {
            const originAddress = await this.getAddress();
            const gasPayerSignature = await DelegationHandler({
                gasPayerServiceUrl: this.kmsVeChainGasPayerServiceUrl
            }).getDelegationSignatureUsingUrl(
                transaction,
                originAddress,
                // Calling `buildVeChainSignatureFromPayload(transactionHash)` above throws error is `this.provider` is undefined.
                // eslint-disable-next-line  @typescript-eslint/no-non-null-assertion
                this.provider!.thorClient.httpClient // Never undefined.
            );
            return concatBytes(originSignature, gasPayerSignature);
        }
        return originSignature;
    }
    /**
     * It signs a transaction.
     * @param transactionToSign Transaction body to sign in plain format.
     * @returns {string} The signed transaction in hexadecimal format.
     * @throws JSONRPCInvalidParams if {@link this.provider} is undefined.
     */
    public async signTransaction(
        transactionToSign: TransactionRequestInput
    ): Promise<string> {
        try {
            // Populate the call, to get proper from and to address (compatible with multi-clause transactions)
            const transactionBody =
                await this.populateTransaction(transactionToSign);
            // Get the transaction object
            const transaction = Transaction.of(transactionBody);
            // Sign the transaction hash using delegation if needed
            const signature =
                await this.concatSignatureIfDelegation(transaction);
            return Hex.of(
                Transaction.of(transactionBody, signature).encoded
            ).toString();
        } catch (error) {
            throw new SignerMethodError(
                'KMSVeChainSigner.signTransaction',
                'The transaction could not be signed.',
                { transactionToSign },
                error
            );
        }
    }
    /**
     * Submits a signed transaction to the network.
     * @param transactionToSend Transaction to be signed and sent to the network.
     * @returns {string} The transaction ID.
     */
    public async sendTransaction(
        transactionToSend: TransactionRequestInput
    ): Promise<string> {
        try {
            // Sign the transaction
            const signedTransaction =
                await this.signTransaction(transactionToSend);
            // Send the signed transaction (the provider will always exist if it gets to this point)
            return (await this.kmsVeChainProvider?.request({
                method: RPC_METHODS.eth_sendRawTransaction,
                params: [signedTransaction]
            })) as string;
        } catch (error) {
            throw new SignerMethodError(
                'KMSVeChainSigner.sendTransaction',
                'The transaction could not be sent.',
                { transactionToSend },
                error
            );
        }
    }
    /**
     * Signs a bytes payload returning the VeChain signature in hexadecimal format.
     * @param {Uint8Array} payload in bytes to sign.
     * @returns {string} The VeChain signature in hexadecimal format.
     */
    public async signPayload(payload: Uint8Array): Promise<string> {
        const veChainSignature =
            await this.buildVeChainSignatureFromPayload(payload);
        // SCP256K1 encodes the recovery flag in the last byte. EIP-191 adds 27 to it.
        veChainSignature[veChainSignature.length - 1] += 27;
        return Hex.of(veChainSignature).toString();
    }
}
export { KMSVeChainSigner };