AES

In addition to ECDSA signatures, Schnorr signatures, and RSA operations, the Builder Vault also supports 128, 192, and 256-bit AES in CTR, CBC, and GCM mode.

📘

A Note on Performance and Parameters

Computing AES in a threshold wallet requires the use of so-called general purpose MPC. Builder Vault uses one of the most efficient general purpose MPC protocols, but even so, the computation of AES still requires a significant amount of bandwidth between the MPC nodes. For this reason, it it is not suited for use cases which require high throughput or with long plaintexts.

Builder Vault currently only supports AES keys shared among three MPC nodes and with a security threshold of one. Contact the Builder Vault support team for more information.

Key Generation

You can generate a new AES key among a set of MPC nodes as follows:

keyID, err := client.AES().GenerateKey(ctx, sessionConfig, threshold, keyLength, desiredKeyID)

As usual, the sessionConfig defines the set of MPC nodes among which the resulting AES key should be secret shared and a unique session ID. The threshold is the security threshold for the key. The keyLength specifies the byte length of the key to generate. You can choose between 16, 24, or 32. Finally, desiredKeyID can be used to give the key a specific ID. If desiredKeyID is set to the empty string, the MPC nodes will agree on a random key ID for the new key.

To start the MPC key generation, the above method must be called on the SDKs of all the MPC nodes specified in the session configuration. The MPC session will only succeed if they agree on the session configuration, as well as the threshold, keyLength, and desired key ID.

If the MPC operation succeeds, the key ID will be returned to each SDK.

📘

When to Use the Key

If an honest MPC node succeeds with the key generation 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. In the worst case, only a few honest players will succeed, too few to actually perform a decryption or signature. Therefore you should make sure that all MPC nodes successfully completed the key generation session before using the new key.

Key Import

Existing AES keys can be imported into or exported from the Builder Vault. To import an AES 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:

players := []int{2, 3, 5}
sharing, err := tsmutils.AESSecretShare(players, aesKey)
shares, checksum := sharing.Shares, sharing.Checksum

This splits the key into a number of "xor" shares of the key. In addition, it returns a checksum. The checksum is the first three bytes of the encryption of the 16-byte block 0x01010101010101010101010101010101.

Each share must then be encrypted under the MPC nodes' public wrapping key:

wrappingKey, err := client.WrappingKey().WrappingKey(ctx)
publicKey, err := x509.ParsePKIXPublicKey(wrappingKey)
publicRSAKey, _ := publicKey.(*rsa.PublicKey)
wrappedKeyShare, err := tsmutils.Wrap(publicRSAKey, keyShare)

Finally, the shares are imported in an MPC session, where each wrapped share is input by the corresponding SDK.

threshold := 1
keyID, err := client.AES().ImportKeyShares(ctx, sessionConfig, threshold, wrappedKeyShare, checksum, desiredKeyID)

The threshold is the security threshold that you want for the imported key sharing. All SDKs must agree on the threshold and the checksum. The SDKs may all omit the checksum, but if they do provide the checksum, it is used to validate the imported key. This is a good way to protect the integrity of the key. Without the checksum, any one of the SDK users may tamper with it's share and this will result in modifications to the imported 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.

A complete example, showing how to import an AES key, is given at the end of the AES-CTR section below.

Key Export

Exporting an AES key is done by running an MPC session. The export MPC session only runs if all involved SDKs call the method. So 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 AES key export enabled in their configuration.

The MPC session computes a new random secret sharing of the AES 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 AES 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 key:

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.AES().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 all key shares and their private unwrapping keys, you can recover the AES key:

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

// Then you can recover the AES key as follows
exportedKey, err := tsmutils.AESRecombine([][]byte{ keyShare1, keyShare2, keyShare3}, checksum)

A complete example, showing how to export an AES key, is given at the end of the next section.

📘

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.

AES-CTR

After an AES key have been generated in or imported into the Builder Vault, the MPC nodes can generate an AES CTR key stream from a key and an initialization vector as follows:

partialResult, err := client.AES().CTRKeyStream(ctx, sessionConfig, keyID, iv, keyStreamLength)

The keyStreamLength is the length of the desired key stream. As with the key generation, this is an MPC session, and the method call must be made on the SDK for each of the MPC nodes in the MPC session, and they must agree on the parameters (key ID, IV, keyStreamLength).

Once the MPC session succeeds, the partial results from each SDK must be collected, and the final AES CTR key stream can be obtained as follows:

keyStream, err := tsm.AESFinalizeCTR([]byte{ partialResult1, partialResult2, ... })

If you want to use the key stream to encrypt or decrypt a message, you must compute the logical xor of the key stream and the message, like this:

ciphertext := make([]byte, len(plaintext))
for i := range plaintext {
    ciphertext[i] = plaintext[i] ^ keyStream[i]
}

A full example, showing how to import an AES key, use it to generarte an AES-CTR key stream, and exporting the key, is shown below. The example assumes a running instance of the Builder Vault with three MPC nodes that have player IDs 0, 1, 2 and 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/aes"
	"crypto/rand"
	"crypto/rsa"
	"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() {

	// A 256-bit AES key to be imported into Builder Vault

	aesKey, err := hex.DecodeString("e7e34471f8366f1946a5a97dfab895cf4a4561ff9f6665578809a500da9adb7d")
	if err != nil {
		panic(err)
	}

	// Split the AES 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
	sharing, err := tsmutils.AESSecretShare(players, aesKey)
	if err != nil {
		panic(err)
	}

	shares := sharing.Shares     // This is the actual shares
	checksum := sharing.Checksum // This is the first three bytes of AES_Encrypt(key, 0x01010101010101010101010101010101)
	fmt.Println("Key                   :", hex.EncodeToString(aesKey))
	fmt.Println("Key checksum          :", hex.EncodeToString(checksum))

	// 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")
		}

		// Encrypt the key share using the wrapping key

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

	}

	// Import the wrapped key shares into the Builder Vault

	threshold := 1 // The security threshold that we want for the resulting key sharing

	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.AES().ImportKeyShares(ctx, importSessionConfig, threshold, wrappedKeyShares[playerID], checksum, "")
			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 keygen, 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)

	// Now we can use the imported key, for example to generate an AES-CTR key stream

	iv := make([]byte, aes.BlockSize)

	ctrSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), players, nil)
	partialCTRResults := make([][]byte, len(clients))
	for playerID, client := range clients {
		playerID, client := playerID, client
		eg.Go(func() error {
			res, err := client.AES().CTRKeyStream(ctx, ctrSessionConfig, keyID, iv, 32)
			if err != nil {
				return err
			}
			partialCTRResults[playerID] = res
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		panic(err)
	}

	keyStream, err := tsm.AESFinalizeCTR(partialCTRResults)
	if err != nil {
		panic(err)
	}

	fmt.Println("AES-CTR IV            :", hex.EncodeToString(iv))
	fmt.Println("AES-CTR key stream    :", hex.EncodeToString(keyStream))

	// We can export the AES 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, 2048)
	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.AESWrappedKeyShare, len(clients))
	for playerID, client := range clients {
		playerID, client := playerID, client
		eg.Go(func() error {
			res, err := client.AES().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].Checksum, exportedWrappedKeyShares[i].Checksum) {
			panic("checksums do not match")
		}
	}
	exportedKeyChecksum := exportedWrappedKeyShares[0].Checksum
	fmt.Println("Exported key checksum: ", hex.EncodeToString(exportedKeyChecksum))

	// 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.Unwrap(privateWrappingKey, wrappedKeyShare.WrappedKeyShare)
		if err != nil {
			panic(err)
		}
		exportedKeyShares[playerID] = keyShare
	}

	// Then recover the AES key from the key shares

	exportedKey, err := tsmutils.AESRecombine(exportedKeyShares, checksum)
	if err != nil {
		panic(err)
	}

	fmt.Println("AES key recovered from exported key shares:", hex.EncodeToString(exportedKey))

	// Sanity check: Exported key should equal original key
	if !bytes.Equal(aesKey, exportedKey) {
		panic("aes key mismatch")
	}

}

Running this example should produce output similar to this:

Key                   : e7e34471f8366f1946a5a97dfab895cf4a4561ff9f6665578809a500da9adb7d
Key checksum          : 12c2c5
Key ID of imported key: PwJaXBb8M3xqBPNEI6uS5kiO84sI
AES-CTR IV            : 00000000000000000000000000000000
AES-CTR key stream    : e3e02033ab7ebbd76ff0b0c5f6d7b536f6c7628ca4b3198a7d6068baa7edc87d
Exported key checksum:  12c2c5
AES key recovered from exported key shares: e7e34471f8366f1946a5a97dfab895cf4a4561ff9f6665578809a500da9adb7d

AES-CBC

Encrypting a message using AES in CBC mode is done as follows:

partialResult, err := client.AES().CBCEncrypt(ctx, sessionConfig, keyID, iv, plaintext)

As with AES-CTR, the partial results must be collected from all involved SDKs, and the ciphertext is generates like this:

ciphertext, err := tsm.AESFinalizeCBCEncrypt([]byte{ partialResult1, partialResult2, ... })

Decryption is done in a similar way:

partialResult, err := client.AES().CBCDecrypt(ctx, sessionConfig, keyID, iv, ciphertext)

and

decryptedMessage, err := tsm.AESFinalizeCBCDecrypt([]byte{ partialResult1, partialResult2, ... })

📘

Partial AES Decryption Results

In the current version of Builder Vault, each partial result contains the decrypted message in the clear and the finalization essentially just ensures that all partial results contain the same decrypted message. This is contrary to the partial signature results of ECDSA and RSA signing, where each partial signature does not reveal information about the final signature.

AES-GCM

The Builder Vault also supports AES in GCM mode. This requires a 12-byte IV (unlike AES-CTR and AES-CBC which requires 16 bytes IV), and it lets you provide additional data to be authenticated.

We illustrate this with a full example, where an 128-bit AES key is first generated. The key is then used for AES-GCM encryption and decryption of a message.

The example assumes a running instance of the Builder Vault with three MPC nodes 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.

As shown in the last part of the example, the combination of the partial results from the AES-GCM decrypt method will return a tsm.ErrMessageAuthenticationFailed if the authenticity of the ciphertext or additional data were broken.

To start the key generation, encryption and decryption MPC sessions in the example, the three SDKs are invoked concurrently using goroutines. This is because each call to the SDK blocks until all SDKs in the MPC session are called. In most real-world cases, each SDK will run in its own environment, e.g., one SDK on a mobile device, and the two other SDKs on different servers, and in that case goroutines are not needed.

package main

import (
	"bytes"
	"context"
	"encoding/hex"
	"errors"
	"fmt"
	"gitlab.com/Blockdaemon/go-tsm-sdkv2/v65/tsm"
	"golang.org/x/sync/errgroup"
)

func main() {

	// 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)
		}
	}

	// Generate an AES key

	keyLength := 16                 // The key should be a 128-bit key
	threshold := 1                  // The security threshold for this key
	keyGenPlayers := []int{0, 1, 2} // The key should be secret shared among all three MPC nodes

	fmt.Println("Generating key using players", keyGenPlayers)
	keyGenSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), keyGenPlayers, nil)
	ctx := context.Background()
	keyIDs := make([]string, len(clients))
	var eg errgroup.Group
	for i, client := range clients {
		client, i := client, i
		eg.Go(func() error {
			var err error
			keyIDs[i], err = client.AES().GenerateKey(ctx, keyGenSessionConfig, threshold, keyLength, "")
			return err
		})
	}

	// Validate key ID; make sure none of the nodes aborted during keygen, 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("Generated key with ID:", keyID)

	// Encrypt a message using AES-GCM

	fmt.Println("Encrypt message using AES-GCM")

	plaintext := []byte("message to be encrypted and authenticated")
	additionalData := []byte("additional data to be authenticated")
	iv := make([]byte, 12) // AES-GCM uses a 12-byte nonce; make sure to use different ivs for each encrypted message

	gcmEncryptSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), keyGenPlayers, nil)
	partialEncryptResults := make([][]byte, len(clients))
	for i, client := range clients {
		client, i := client, i
		eg.Go(func() error {
			var err error
			partialEncryptResults[i], err = client.AES().GCMEncrypt(ctx, gcmEncryptSessionConfig, keyID, iv, plaintext, additionalData)
			return err
		})
	}

	if err := eg.Wait(); err != nil {
		panic(err)
	}

	encryptedResult, err := tsm.AESFinalizeGCMEncrypt(partialEncryptResults)
	if err != nil {
		panic(err)
	}
	ciphertext := encryptedResult.Ciphertext
	tag := encryptedResult.Tag
	fmt.Println("Ciphertext:", hex.EncodeToString(ciphertext))
	fmt.Println("Tag       :", hex.EncodeToString(tag))

	// Decrypt to recover original plaintext

	gcmDecryptSessionConfig := tsm.NewSessionConfig(tsm.GenerateSessionID(), keyGenPlayers, nil)
	partialDecryptResults := make([][]byte, len(clients))
	for i, client := range clients {
		client, i := client, i
		eg.Go(func() error {
			var err error
			partialDecryptResults[i], err = client.AES().GCMDecrypt(ctx, gcmDecryptSessionConfig, keyID, iv, ciphertext, additionalData, tag)
			return err
		})
	}

	if err := eg.Wait(); err != nil {
		panic(err)
	}

	decryptedPlaintext, err := tsm.AESFinalizeGCMDecrypt(partialDecryptResults)
	if err != nil {
		panic(err)
	}

	fmt.Println("Plaintext :", string(decryptedPlaintext))

	// Sanity check: We should obtain the original plaintext when decrypting the ciphertext

	if !bytes.Equal(plaintext, decryptedPlaintext) {
		panic("decrypted plaintext do not match")
	}

	// Try to decrypt with modified data; this should raise an error

	modifiedAdditionalData := additionalData
	modifiedAdditionalData[2] ^= 1 // flip a bit somewhere in the additional data

	gcmDecryptSessionConfig = tsm.NewSessionConfig(tsm.GenerateSessionID(), keyGenPlayers, nil)
	for i, client := range clients {
		client, i := client, i
		eg.Go(func() error {
			var err error
			partialDecryptResults[i], err = client.AES().GCMDecrypt(ctx, gcmDecryptSessionConfig, keyID, iv, ciphertext, modifiedAdditionalData, tag)
			return err
		})
	}

	if err := eg.Wait(); err != nil {
		panic(err)
	}

	_, err = tsm.AESFinalizeGCMDecrypt(partialDecryptResults)

	if !errors.Is(err, tsm.ErrMessageAuthenticationFailed) {
		panic("expected an authentication error")
	}

	fmt.Printf("Decrypting with non-authentic data resulted in: %q\n", err)

}

Running the example will produce output similar to this:

Generating key using players [0 1 2]
Generated key with ID: YnzYYTmVaaTCZbYwFxZWCluQqAhi
Encrypt message using AES-GCM
Ciphertext: e7e34471f8366f1946a5a97dfab895cf4a4561ff9f6665578809a500da9adb7d5c9897b3daba13d2cf
Tag       : 5bb5e2e63df63bfd6d0cee45bf6aee9b
Plaintext : message to be encrypted and authenticated
Decrypting with non-authentic data resulted in: "message authentication failed"