RSA

In addition to threshold ECDSA and Schnorr signing, Builder Vault also supports RSA encryption and signing.

📘

Threshold RSA Key Generation

While RSA key using MPC is possible, the current state-of-the-art protocols for doing this are not very efficient. This is the reason why Builder Vault does not currently provide methods for RSA key generation in MPC. Instead, you will have to import an RSA key generated outside the Builder Vault, for example in a secure hardware module (HSM), and then migrate the RSA key to Builder Vault using the import method described below.

Key Import

To import an RSA key into Builder Vault you must first secret share the key. If for example you want the key to be shared among the MPC nodes with player IDs 2, 3 and 5, and with a security threshold of one, you can do this as follows:

players := []int{2, 3, 5}
threshold := 1
sharing, err := tsmutils.RSASecretShare(threshold, players, privateKey)

Each key share must then be wrapped under the corresponding MPC nodes' public wrapping key. The wrapping key must be provided as an ASN.1 DER encoding of a SubjectPublicKeyInfo (see RFC 5280, Section 4.1), for example:

wrappingKey, err := client.WrappingKey().WrappingKey(ctx)
publicKey, err := x509.ParsePKIXPublicKey(wrappingKey)
publicRSAKey, ok := publicKey.(*rsa.PublicKey)
wrappedKeyShare, err = tsmutils.EnvelopeWrap(publicRSAKey, sharing[playerID])

Finally, the key can be imported by running a Builder Vault MPC session, where each wrapped share is input by the corresponding SDK.

keyID, err := client.RSA().ImportKeyShares(ctx, sessionConfig, wrappedKeyShare, desiredKeyID)

If all MPC nodes in the session make this call to their SDK, providing the same desired key ID, an MPC session will start, that imports the RSA key.

The desiredKeyIDlet's you suggest a key ID for the imported key. This can be useful, for example if you want to migrate a key sharing from one Builder Vault instance to another, without changing the key ID. If you provide an empty string as the desired key ID, the MPC nodes will choose a random key ID.

📘

When to Use the Key

If an honest MPC node succeeds with the key import MPC session, the protocol ensures that the MPC node will hold a correct key share. But the protocol does guarantee that all honest players will succeed, and some may have aborted and do not hold a key share. This may unintentionally reduce the availability of the key. In the worst case, so few players will end up holding a key share, that no operation such as decryption can be performed with the key. To avoid this, you should make sure that all MPC nodes successfully completed the key import MPC session before using the imported key.

Once the key is imported, you can obtain the public key as follows:

publicKey, err := client.RSA().PublicKey(ctx, keyID)

This will return a byte array with an ASN.1 DER encoding of SubjectPublicKeyInfo.

📘

Trusting the Public Key

If you trust a specific MPC node, you can use the public key returned from that node. But as an external user of the Builder Vault you generally don't know which of the MPC nodes may or may not be corrupted. In that case you should request the public key from at least threshold + 1 MPC nodes and make sure that all the returned public keys are equal, before you trust the public key. Obtaining the public key from at least threshold + 1 MPC nodes ensures that you obtain the public key from at least one honest MPC node.

A complete example, showing how to import an RSA key and use it for encryption and signing, is provided at the bottom if this page.

Key Export

Exporting an RSA key is done by running an MPC session. The export MPC session only runs if all involved SDKs call the method. This gives each SDK user the opportunity to enforce whatever policy it finds necessary before allowing the export. As an additional level of security, all MPC nodes involved in the MPC session must have RSA key export enabled in their configuration.

The MPC session computes a new random secret sharing of the RSA key and one share of the new sharing is output to each SDK. Each share is wrapped before being output to the SDK, by a wrapping key provided by the SDK. Only wrapping keys that are whitelisted in the MPC node configuration is accepted.

If you want to migrate the RSA key from one Builder Vault instance to another, you can obtain the wrapping key from the target node as follows:

wrappingKey, err := targetClient.WrappingKey().WrappingKey(ctx)
publicKey, err := x509.ParsePKIXPublicKey(wrappingKey)
publicWrappingKey, ok := publicKey.(*rsa.PublicKey)

Alternatively, you can generate a wrapping key like this:

privateWrappingKey, err := rsa.GenerateKey(rand.Reader, 4096)
publicWrappingKey, err := x509.MarshalPKIXPublicKey(&privateWrappingKey.PublicKey)

In any case, you must ensure that the public wrapping key is whitelisted in the source MPC node configuration.

res, err := client.RSA().ExportKeyShares(ctx, exportSessionConfig, keyID, publicWrappingKey)

The returned result can now be used in an import session at another Builder Vault instance. Alternatively, if you have access to threshold+1 or more key shares and their private unwrapping keys, you can recover the AES key as follows:

// If you do this for all key shares
keyShare, err := tsmutils.EnvelopeUnwrap(privateWrappingKey, res.WrappedKeyShare)

// Then you can recover the RSA key as follows
exportedPrivateRSAKey, err := tsmutils.RSARecombine([][]byte{ keyShare1, keyShare2, keyShare3})

A complete example, showing how to export an RSA key, is given at the end of this page.

📘

Migrating Keys without Single Point of Trust

A major goal of the Builder Vault is to eliminate any single point of trust. If someone exports and unwraps all key shares and recovers the key as shown above, that party will essentially be a single point of trust. To avoid this, it is recommended to only unwrap and recover the exported AES key by importing the wrapped key shares into another Builder Vault instance, or in some other high-security environment, such as a HSM.

Signing

The Builder Vault supports both RSASSA-PSS signatures and RSASSA-PKCS1v15 signatures. Once an RSA key is imported into Builder Vault, you can generate an RSASSA-PSS signature of a message as follows:

message := []byte("some message to be signed")
sha256Hashed := sha256.Sum256(message)
hashedMessage := sha256Hashed[:]
hashFunction := tsm.HashFunctionSHA256
partialSignature, err := client.RSA().SignPSS(ctx, signSessionConfig, keyID, hashFunction, hashedMessage)

When the SignPSS() method is called on the SDKs belonging to all MPC nodes in the session, a signature is generated, and a partial signature is returned to each SDK. The partial signatures must be collected and combined into the final signature as follows:

signature, err := tsm.RSAFinalizeSignaturePSS(hashFunction, hashedMessage, partialSignatures)

The hashedMessage is optional. If provided, the final signature will be validated as part of the finalization. The salt size used in PSS is the same length as the output length of the hash function.

You can generate signatures according to RSASSA-PKCS1-V1_5-SIGN from RSA PKCS #1 v1.5 in much the same way:

// Call this concurrently on each SDK
partialSignature, err := client.RSA().SignPKCS1v15(ctx, keyID, hashFunction, hashedMessage)

// Then call this somewhere, after collecting all partial signatures returned from the SDKs 
signature, err := tsm.RSAFinalizeSignaturePKCS1v15(hashFunction, hashedMessage, partialSignatures)

The supported hash functions are tsm.HashFuctionNone, tsm.HashFunctionSHA1, and tsm.HashFunctionSHA256. Note that the hashedMessage must be the result of hashing the input message using the given hash function. If hash is tsm.HashFunctionNone the hashedMessage is signed directly. This isn't advisable except for interoperability.

A complete example, showing how to import an RSA key and use it for encryption and signing, is provided at the bottom if this page.

Decrypting

The Builder Vault supports both OAEP, PKCS1v15 and raw decryption. Given an RSA ciphertext generated using the public RSA key, you can decrypt by having each SDK call this method concurrently:

partialDecryption, err := client.RSA().Decrypt(ctx, keyID, ciphertext)

The partial decryption results can then be collected and turned into the final plaintext using one of the following calls:

plaintext, err := tsm.RSAFinalizeDecryptionRaw(partialDecryptions)
plaintext, err := tsm.RSAFinalizeDecryptionPKCS1v15(partialDecryptions)
plaintext, err := tsm.RSAFinalizeDecryptionOAEP(hashFunction, label, partialDecryptions)

Which one to use depends on whether you produced the ciphertext using raw, PKCS1v1.5 or OAEP encryption.

A Complete Example

A complete example is shown here. It imports an RSA key, uses it to sign and decrypt, and exports the key. The example a running instance of the Builder Vault with three MPC nodes with player IDs 0, 1, 2 that are reachable at the addresses http://localhost:8500, http://localhost:8501, http://localhost:8502. You can for example follow the tutorial here to set up a Builder Vault instance locally like this, or you can use the hosted sandbox Builder Vault provided by Blockdaemon, as described here.

package main

import (
	"bytes"
	"context"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/hex"
	"fmt"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v65/tsm"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v65/tsm/tsmutils"
	"golang.org/x/sync/errgroup"
	"sync"
)

func main() {

	// Generate an RSA key to be imported into Builder Vault

	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		panic(err)
	}

	// Split the private RSA key into a secret sharing

	players := []int{0, 1, 2} // The key should be secret shared among the MPC three nodes with these player IDs
	threshold := 1            // The security threshold that we want for the imported key

	sharing, err := tsmutils.RSASecretShare(threshold, players, privateKey)
	if err != nil {
		panic(err)
	}

	// Create a client for each of the nodes

	configs := []*tsm.Configuration{
		tsm.Configuration{URL: "http://localhost:8500"}.WithAPIKeyAuthentication("apikey0"),
		tsm.Configuration{URL: "http://localhost:8501"}.WithAPIKeyAuthentication("apikey1"),
		tsm.Configuration{URL: "http://localhost:8502"}.WithAPIKeyAuthentication("apikey2"),
	}

	clients := make([]*tsm.Client, len(configs))
	for i, config := range configs {
		var err error
		if clients[i], err = tsm.NewClient(config); err != nil {
			panic(err)
		}
	}

	// Wrap each key share with the wrapping key of the MPC node that should receive the share

	ctx := context.Background()
	wrappedKeyShares := make([][]byte, len(players))
	for i, client := range clients {

		// Get the wrapping key from the MPC node

		wrappingKey, err := client.WrappingKey().WrappingKey(ctx)
		if err != nil {
			panic(err)
		}
		publicKey, err := x509.ParsePKIXPublicKey(wrappingKey)
		if err != nil {
			panic(err)
		}
		publicRSAKey, ok := publicKey.(*rsa.PublicKey)
		if !ok {
			panic("error parsing wrapping key: not an rsa key")
		}

		// Wrap the key share using the wrapping key

		keyShare := sharing[players[i]]
		wrappedKeyShares[i], err = tsmutils.EnvelopeWrap(publicRSAKey, keyShare)
		if err != nil {
			panic(err)
		}

	}

	// Import the wrapped RSA key shares into the Builder Vault

	importSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), players, nil)
	keyIDs := map[int]string{}
	var eg errgroup.Group
	var lock sync.Mutex
	for playerID, client := range clients {
		playerID, client := playerID, client
		eg.Go(func() error {
			res, err := client.RSA().ImportKeyShares(ctx, importSessionConfig, wrappedKeyShares[playerID], "")
			if err != nil {
				return err
			}
			lock.Lock()
			keyIDs[playerID] = res
			lock.Unlock()
			return nil
		})
	}

	// Validate key ID; make sure none of the nodes aborted during import, before you use the key.

	if err := eg.Wait(); err != nil {
		panic(err)
	}
	for i := 1; i < len(keyIDs); i++ {
		if keyIDs[0] != keyIDs[i] {
			panic("key IDs do not match")
		}
	}
	keyID := keyIDs[0]
	fmt.Println("Key ID of imported key:", keyID)

	// CAVEAT: The public RSA key can be obtained from any one MPC node as follows. It is important to only use the
	// public key if you trust that MPC node. Generally, you need to obtain the public key from at least threshold+1
	// MPC nodes and make sure the returned public keys are equal, before you start using the key. Obtaining the
	// public key from threshold + 1 nodes ensures that at least one honest MPC node is involved.

	publicKey, err := clients[0].RSA().PublicKey(ctx, keyID)
	if err != nil {
		panic(err)
	}

	fmt.Println("Public key (ASN.1 DER encoding of SubjectPublicKeyInfo):", hex.EncodeToString(publicKey))

	// Now we can use the imported key, for example to generate an RSA-PSS signature on a message

	message := []byte("some message")
	sha256Hashed := sha256.Sum256(message)
	hashedMessage := sha256Hashed[:]
	hashFunction := tsm.HashFunctionSHA256

	fmt.Println("Hashed message          :", hex.EncodeToString(hashedMessage))

	var partialSignatures [][]byte = make([][]byte, 0)
	signSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), players, nil)
	for _, client := range clients {
		client := client
		eg.Go(func() error {
			partialSignature, err := client.RSA().SignPSS(ctx, signSessionConfig, keyID, hashFunction, hashedMessage)
			if err != nil {
				return err
			}
			lock.Lock()
			partialSignatures = append(partialSignatures, partialSignature)
			lock.Unlock()
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		panic(err)
	}

	// Combine partial signatures into the final RSASSA-PSS signature.

	signature, err := tsm.RSAFinalizeSignaturePSS(hashFunction, hashedMessage, partialSignatures)

	fmt.Println("RSA-PSS signature       :", hex.EncodeToString(signature))

	pubKey := privateKey.Public().(*rsa.PublicKey)
	err = rsa.VerifyPSS(pubKey, crypto.SHA256, hashedMessage, signature, nil)
	if err != nil {
		panic("invalid signature")
	}

	// OAEP e a message using the public RSA key

	label := []byte("hello label")
	ciphertext, err := rsa.EncryptOAEP(crypto.SHA256.New(), rand.Reader, pubKey, hashedMessage, label)
	if err != nil {
		panic(err)
	}

	// Use Builder Vault to decrypt

	var partialDecryptions [][]byte
	for _, client := range clients {
		client := client
		eg.Go(func() error {
			partialDecryption, err := client.RSA().Decrypt(ctx, keyID, ciphertext)
			if err != nil {
				return err
			}
			lock.Lock()
			partialDecryptions = append(partialDecryptions, partialDecryption)
			lock.Unlock()
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		panic(err)
	}

	// Recombine the partial decrypt results

	decryptedHashedMessage, err := tsm.RSAFinalizeDecryptionOAEP(hashFunction, label, partialDecryptions)
	if err != nil {
		panic(err)
	}

	fmt.Println("Decrypted hashed message:", hex.EncodeToString(decryptedHashedMessage))

	if !bytes.Equal(hashedMessage, decryptedHashedMessage) {
		panic("decrypted message does not match original message")
	}

	// We can export the RSA key from the Builder Vault as follows

	// Key shares are wrapped before being returned to the SDKs.
	// In a real application, you would probably use different wrapping keys for each MPC node. For example, you would
	// use the wrapping key obtained from another instance of the MPC node with the same player ID.
	// For the sake of simplicity, we use the same wrapping key for all MPC nodes in this example.
	// In any case, the wrapping key must be whitelisted and key export must be enabled in the MPC node configuration,
	// before the export can take place.

	privateWrappingKey, err := rsa.GenerateKey(rand.Reader, 4096)
	if err != nil {
		panic(err)
	}
	publicWrappingKey, err := x509.MarshalPKIXPublicKey(&privateWrappingKey.PublicKey)
	if err != nil {
		panic(err)
	}

	exportSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), players, nil)
	exportedWrappedKeyShares := make(map[int]*tsm.RSAWrappedKeyShare, len(clients))
	for playerID, client := range clients {
		playerID, client := playerID, client
		eg.Go(func() error {
			res, err := client.RSA().ExportKeyShares(ctx, exportSessionConfig, keyID, publicWrappingKey)
			if err != nil {
				return err
			}
			lock.Lock()
			exportedWrappedKeyShares[playerID] = res
			lock.Unlock()
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		panic(err)
	}

	for i := 1; i < len(keyIDs); i++ {
		if !bytes.Equal(exportedWrappedKeyShares[0].PKIXPublicKey, exportedWrappedKeyShares[i].PKIXPublicKey) {
			panic("checksums do not match")
		}
	}
	pkixPubKey := exportedWrappedKeyShares[0].PKIXPublicKey
	fmt.Println("Exported key checksum: ", hex.EncodeToString(pkixPubKey))
	if !bytes.Equal(publicKey, pkixPubKey) {
		panic("public keys do not match")
	}

	// Given all the key shares and the private wrapping key, you can recover the AES key from the shares as follows.
	// But for maximum security, you can also just import the wrapped key shares and checksum into another
	// Builder Vault instance. This will migrate the key between the Builder Vault instances, without any single point
	// of trust.

	// Unwrap the wrapped key shares

	exportedKeyShares := make(map[int][]byte, 0)
	for playerID, wrappedKeyShare := range exportedWrappedKeyShares {
		keyShare, err := tsmutils.EnvelopeUnwrap(privateWrappingKey, wrappedKeyShare.WrappedKeyShare)
		if err != nil {
			panic(err)
		}
		exportedKeyShares[playerID] = keyShare
	}

	// Then recover the HMAC key from the key shares

	exportedPrivateKey, err := tsmutils.RSARecombine(exportedKeyShares)
	if err != nil {
		panic(err)
	}

	// Sanity check: Exported private key should equal original private key

	if exportedPrivateKey.N.Cmp(privateKey.N) != 0 {
		panic("rsa key mismatch")
	}
	if exportedPrivateKey.D.Cmp(privateKey.D) != 0 {
		panic("rsa key mismatch")
	}

}

Running this example should produce output similar to this:

Key ID of imported key: tK7HVxPwLbZtw9uyHg2IZezp0VkH
Public key (ASN.1 DER encoding of SubjectPublicKeyInfo): 30820122300d06092a864886f70d01010105000382010f003082010a0282010100b8bca77dc3c4f5a3e05af244a312fd24baaa9ada5b1ecbe347acd487ae8e5bc22a615b40e06a0bd1a5a4cf84626992d4b829623b8df8996f9c5a151deb9729aaa6c3382119f8aecf745809c2270ba45b3b05e5727eaf88cfb6c34b9405b8b7f8750e92cf7329b933dbd08c12f46c50d096953ba858e7125b48c296323e9a612b6169c9060b6b615cb5e750bd431abdaa3c618e1e86293cfe9b7dde596233814c8b7d6d1b10b467ca73ef1966f5ff4e5567d88ff41b929f6d29f876018a905035bf8113ea6aebc464c08ef8232c9419b11fde4858a71cff274119653d32378ea003189011bfd2f626848428a067b91ee117b6f161a48b59b89cac078ad5746bef0203010001
Hashed message          : c47757abe4020b9168d0776f6c91617f9290e790ac2f6ce2bd6787c74ad88199
RSA-PSS signature       : 38d4012678e5f5046857e7df19c5012acf2b88c902f1f74166072edbd1629bc589c4ec6001674738e9b0038fc0cf38a674e2ce89174640e40dc5e149d0c56fffbd040ccf32157e8ca811975e9b0fb3d7c90b812ffcd2be40c492cd87b7cc3a3a44a9ec8ed6c33d8ab7540f2a2b452d7d18272cfa72e57e93c1ec59ea8f998c13d7094ad18d273615d238758b2b86c3444509447fa443e84b8dfff3b3668c65b8de208ac6e5b4f531cf5eeabd8c24d459f2e9584eb41613493df13d138c114cfb153bce476036161535aaa2f3325c1a3324a5a6e1cc9a9f9014e52812365d07f4734e57db3ea1b8426b22b7ce442bd6bea3240bbf7ddf26ee8a59b1b108107fbe
Decrypted hashed message: c47757abe4020b9168d0776f6c91617f9290e790ac2f6ce2bd6787c74ad88199
Exported key checksum:  30820122300d06092a864886f70d01010105000382010f003082010a0282010100b8bca77dc3c4f5a3e05af244a312fd24baaa9ada5b1ecbe347acd487ae8e5bc22a615b40e06a0bd1a5a4cf84626992d4b829623b8df8996f9c5a151deb9729aaa6c3382119f8aecf745809c2270ba45b3b05e5727eaf88cfb6c34b9405b8b7f8750e92cf7329b933dbd08c12f46c50d096953ba858e7125b48c296323e9a612b6169c9060b6b615cb5e750bd431abdaa3c618e1e86293cfe9b7dde596233814c8b7d6d1b10b467ca73ef1966f5ff4e5567d88ff41b929f6d29f876018a905035bf8113ea6aebc464c08ef8232c9419b11fde4858a71cff274119653d32378ea003189011bfd2f626848428a067b91ee117b6f161a48b59b89cac078ad5746bef0203010001