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 desiredKeyID
let'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"
Updated 27 days ago