External Key Import (EdDSA)

Importing an EdDSA key from an external key store or wallet works much like importing an external ECDSA key.

Importing from a Seed (RFC 8032)

When importing an Ed25519 or Ed448 key, as in the example below, the TSM expects you to start with the raw private key scalar. But some external wallets instead hold an seed, which is then derived into a raw Ed25519 or Ed448 key pair according to RFC 8032 Section 5.1.5. If you start out with a seed like this, you first need to convert it into a raw private key.

The following example shows how you can use a 3rd party tool like @noble/ed25519 to convert the RFC 8032 seed into a raw Ed25519 key pair. The example illustrates that the same conversion happens internally in tools such as @polkadot-util-crypto .

const { hexToU8a, u8aToHex } = require('@polkadot/util');
const { ed25519PairFromSeed } = require('@polkadot/util-crypto');
const ed  = require('@noble/ed25519');  // npm install @noble/[email protected] (2.0 doesn't support import via 'require')

const Example = async () => {

  // The Ed25519 RFC 8032 seed to import

  const seed = hexToU8a("b6b3dd3021cffe5fdaaccd9c2fa2543ea97584ad1da01e3bd12fe0656f1bf4b6")

  // Derive the raw Ed25519 key pair from the seed according to RFC-8032 (Section 5.1.5)

  const hash = await ed.utils.sha512(seed)	
  var left = hash.slice(0,32)
  left[0] &= 248;
  left[31] &= 127;
  left[31] |= 64;

  const privateKey = modlLE(left);
  const publicKey = ed.Point.BASE.multiply(privateKey);

  // We can use polkadot-js to test that we have done it correctly. It also derives according to
  // RFC-8032, so we should get the same public key from the seed:

  const keyPairPolkaJS = ed25519PairFromSeed(seed);

  console.log("private key               :", privateKey.toString(16).padStart(64, '0'));
  console.log("public key                :", u8aToHex(publicKey.toRawBytes()));
  console.log("public key (polkadot-js)  :", u8aToHex(keyPairPolkaJS.publicKey));

}

function modlLE(uint8a) {
  const bytesLE = Uint8Array.from(uint8a).reverse();
  const hex = Buffer.from(bytesLE).toString('hex').padStart(64, "0");
  let scalar = BigInt('0x' + hex) % ed.CURVE.l;
  return scalar >= BigInt(0) ? scalar : ed.CURVE.l + scalar;
}

Example().catch(console.error).finally(() => process.exit());

The raw private key output by this example can be used in the following example.

Code Example

This example shows how to import a raw Ed25519 key into the TSM.

package main

import (
	"bytes"
	"context"
	"crypto/rsa"
	"crypto/x509"
	"encoding/hex"
	"gitlab.com/sepior/go-tsm-sdkv2/ec"
	"gitlab.com/sepior/go-tsm-sdkv2/tsm"
	"gitlab.com/sepior/go-tsm-sdkv2/tsm/tsmutils"
	"golang.org/x/sync/errgroup"
)

func main() {

	// This is the external key and chain code that we want to import into the TSM;
	// e.g. as recovered by ERS.
	// Note: The chain code is optional, and only relevant if you use key derivation.
	// To just import a private EdDSA key, you can set the chain code to nil.

	privateKeyHex := "0dd855e20d7af1575858570d7551d237b9b348455e795763e565d636c5acc5b8"
	chainCodeHex := "7365df71160ca42df2fa3f447fb62f74c90e1996a7cacbd437d41a3638a49809"

	privateKey, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	chainCode, err := hex.DecodeString(chainCodeHex)
	if err != nil {
		panic(err)
	}

	// We first compute the public key corresponding to the private key.

	curve, err := ec.NewCurve(ec.Edwards25519.Name())
	x, err := curve.Zn().DecodeScalar(privateKey)
	if err != nil {
		panic(err)
	}
	y := curve.G().Multiply(x)
	pkixPubKey, err := tsmutils.ECPointToPKIXPublicKey(curve.Name(), y.Encode())
	if err != nil {
		panic(err)
	}

	// Create clients for three 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)
		}
	}

	// Split the private key into a secret sharing.

	threshold := 2 // Can also be set to 1
	players := []int{0, 1, 2}
	ecdsaKeyShares, err := tsmutils.ShamirSecretShare(threshold, players, ec.Edwards25519.Name(), privateKey)
	if err != nil {
		panic(err)
	}

	// Import one secret share into each MPC node, encrypted under that MPC node's public wrapping key.

	sessionID := tsm.GenerateSessionID()
	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

			wrappingKey, err := client.WrappingKey().WrappingKey(context.Background())
			if err != nil {
				return err
			}

			pub, err := x509.ParsePKIXPublicKey(wrappingKey)
			if err != nil {
				return err
			}
			rsaWrappingKey := pub.(*rsa.PublicKey)
			wrappedShare, err := tsmutils.Wrap(rsaWrappingKey, ecdsaKeyShares[i])
			if err != nil {
				return err
			}

			wrappedChainCode, err := tsmutils.Wrap(rsaWrappingKey, chainCode)
			if err != nil {
				return err
			}

			sessionConfig := tsm.NewStaticSessionConfig(sessionID, len(clients))
			keyIDs[i], err = client.Schnorr().ImportKeyShares(context.Background(), sessionConfig, threshold, wrappedShare, wrappedChainCode, pkixPubKey, "")
			return err

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

	// Test: All MPC nodes should agree on the new key ID.

	for _, keyID := range keyIDs {
		if keyID != keyIDs[0] {
			panic("keyID disagreement")
		}
	}

	// Test: Once imported, the public key should equal the public key we computed.

	for _, client := range clients {
		pubKey, err := client.Schnorr().PublicKey(context.Background(), keyIDs[0], nil)
		if err != nil {
			panic(err)
		}
		if !bytes.Equal(pkixPubKey, pubKey) {
			panic("public key disagreement")
		}
	}

}

const { TSMClient, Configuration, SessionConfig, curves } = require("@sepior/tsmsdkv2");
const ed  = require('@noble/ed25519');  // npm install @noble/[email protected] (2.0 doesn't support import via 'require')


// This example shows how to import an external Ed25519 key into the Builder Vault
const main = async () => {


	// The Ed25519 seed that we initially want to import into the Builder Vault

	const seed = fromHexString("b6b3dd3021cffe5fdaaccd9c2fa2543ea97584ad1da01e3bd12fe0656f1bf4b6")


	// Derive the raw Ed25519 master key pair and master chain code from the seed according to RFC-8032 (Section 5.1.5)

	const hash = await ed.utils.sha512(seed)	
	var left = hash.slice(0,32)
	left[0] &= 248
	left[31] &= 127
	left[31] |= 64
	const privateKey = modlLE(left)
	const privKeyU8a = fromHexString(privateKey.toString(16).padStart(64, '0'))
	const publicKey = ed.Point.BASE.multiply(privateKey)
	const pubKeyHex = toHexString(publicKey.toRawBytes())


	// In this example we also import a master chain code along with the private key.
	// If you just want to import the private key, you can use chainCode = null.
	const chainCode = fromHexString("bd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2543ea9750708090a03c0bdba3")

	console.log("private key               :", privateKey.toString(16).padStart(64, '0'))
	console.log("public key                :", pubKeyHex);
	console.log("chain code                :", toHexString(chainCode))


	// Initialize sdks for two Builder Vault MPC nodes

	const config0 = await new Configuration("http://localhost:8500")
	await config0.withAPIKeyAuthentication("apikey0")
	const client0 = await TSMClient.withConfiguration(config0)

	const config1 = await new Configuration("http://localhost:8501")
	await config1.withAPIKeyAuthentication("apikey1")
	const client1 = await TSMClient.withConfiguration(config1)


	// Split the private key into secret shares for MPC nodes (with player IDs 0 and 1)

	let threshold = 1
	const players = Uint32Array.from([0, 1])
	let shareMap = await client0.Utils().shamirSecretShare(threshold, players, "ED-25519", privKeyU8a);


	// Get the wrapping keys of the two MPC nodes and encrypt shares and chain code using the wrapping keys

	let wrappingKeys = [
		await client0.WrappingKey().wrappingKey(),
		await client1.WrappingKey().wrappingKey()
	]

	let wrappedKeyShares = [
		await client0.Utils().wrap(wrappingKeys[0], shareMap.get("0")),
		await client0.Utils().wrap(wrappingKeys[1], shareMap.get("1"))
	]

	let wrappedChainCodes = [
		await client0.Utils().wrap(wrappingKeys[0], chainCode),
		await client0.Utils().wrap(wrappingKeys[1], chainCode)
	]


	let pkixPublicKey = await client0.Utils().ecPointToPKIXPublicKey(curves.ED25519, publicKey.toRawBytes())


	// Import the encrypted key shares

	let sessionID = await SessionConfig.GenerateSessionID()
	sessionConfig = await SessionConfig.newStaticSessionConfig(sessionID, 2)
	let desiredKeyID = (Math.random() + 1).toString(36).substring(2)

	promises = [
		client0.Schnorr().importKeyShares(sessionConfig, threshold, wrappedKeyShares[0], wrappedChainCodes[0], pkixPublicKey, desiredKeyID),
		client1.Schnorr().importKeyShares(sessionConfig, threshold, wrappedKeyShares[1], wrappedChainCodes[1], pkixPublicKey, desiredKeyID)

	];

	let results = await Promise.allSettled(promises)
	if (results[0].status !== "fulfilled") {
		throw Error("operation failed: " + results[0].status)
	}


	// Test that the public part of the imported key and the chain code is correct

	importedPublicKey = await client0.Schnorr().publicKey(desiredKeyID)
	importedPoint = await client0.Utils().pkixPublicKeyToCompressedPoint(importedPublicKey);
	let importedPubKeyHex = toHexString(importedPoint)
	console.log("imported private key      :", importedPubKeyHex)
	if (pubKeyHex !== importedPubKeyHex) {
		throw Error("public key mismatch")
	}

	let importedChainCode = await client0.Schnorr().chainCode(desiredKeyID)
	console.log("imported chain code       :", toHexString(importedChainCode))
	if (toHexString(chainCode) !== toHexString(importedChainCode)) {
		throw Error("chain code mismatch")
	}

}


function modlLE(uint8a) {
	const bytesLE = Uint8Array.from(uint8a).reverse()
	const hex = Buffer.from(bytesLE).toString('hex').padStart(64, "0")
	let scalar = BigInt('0x' + hex) % ed.CURVE.l;
	return scalar >= BigInt(0) ? scalar : ed.CURVE.l + scalar
}


// Hex encoding/decoding utils

const byteToHex = [];
for (let n = 0; n <= 0xff; ++n) {
	const hexOctet = n.toString(16).padStart(2, "0")
	byteToHex.push(hexOctet);
}

toHexString = function (buff) {
	const hexOctets = []
	for (let i = 0; i < buff.length; ++i) {
	    hexOctets.push(byteToHex[buff[i]])
	}
	return hexOctets.join("")
}

const fromHexString = hexString => new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))

main()

Running this should produce output like this:

private key               : 06e75d87f5ffb9d42dc8d30f8df1a0527ecde32f58fe7c2d2d2fcab20bb2dc2d
public key                : c03aa4a18e9509e2b653bf33a761cb40286de0c3ad3f8ff7042ddd1c99255962
chain code                : bd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2543ea9750708090a03c0bdba3
imported private key      : c03aa4a18e9509e2b653bf33a761cb40286de0c3ad3f8ff7042ddd1c99255962
imported chain code       : bd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2543ea9750708090a03c0bdba3