feat(marmot): Ed25519.keyPairFromSeed

Some MLS stacks store Ed25519 signing keys as the 32-byte seed only, while
Quartz uses the 64-byte seed||public form. keyPairFromSeed rebuilds the
pair from the seed (RFC 8032 §5.1.5) with each platform's existing
derivePublicKey.
This commit is contained in:
jeremyd
2026-09-23 20:30:11 +00:00
parent 715f6dc877
commit c17b0075f5
5 changed files with 41 additions and 0 deletions
@@ -128,6 +128,12 @@ actual object Ed25519 {
return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
}
actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair {
require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" }
val publicKey = derivePublicKey(seed)
return Ed25519KeyPair(seed + publicKey, publicKey)
}
// --- Internal operations ---
/** Derive Ed25519 public key from 32-byte seed. */
@@ -64,6 +64,12 @@ expect object Ed25519 {
* @return 32-byte public key
*/
fun publicFromPrivate(privateKey: ByteArray): ByteArray
/**
* Rebuild a key pair from a 32-byte seed (RFC 8032 §5.1.5), for keys stored as seeds only.
* @return pair of (privateKey: 64 bytes seed+public, publicKey: 32 bytes)
*/
fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair
}
data class Ed25519KeyPair(
@@ -114,6 +114,12 @@ actual object Ed25519 {
return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
}
actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair {
require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" }
val publicKey = derivePublicKey(seed)
return Ed25519KeyPair(seed + publicKey, publicKey)
}
private fun derivePublicKey(seed: ByteArray): ByteArray {
val d = sha512(seed)
d[0] = (d[0].toInt() and 248).toByte()
@@ -114,4 +114,21 @@ class Ed25519Test {
) {
kotlin.test.assertContentEquals(expected, actual)
}
@Test
fun keyPairFromSeedMatchesRfc8032TestVector1() {
val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60".hexToByteArray()
val kp = Ed25519.keyPairFromSeed(seed)
assertContentEquals("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".hexToByteArray(), kp.publicKey)
assertContentEquals(seed + kp.publicKey, kp.privateKey)
}
@Test
fun keyPairFromSeedSignsLikeTheGeneratedPair() {
val generated = Ed25519.generateKeyPair()
val rebuilt = Ed25519.keyPairFromSeed(generated.privateKey.copyOfRange(0, 32))
assertEquals(generated, rebuilt)
val msg = "hi".encodeToByteArray()
assertTrue(Ed25519.verify(msg, Ed25519.sign(msg, rebuilt.privateKey), generated.publicKey))
}
}
@@ -114,6 +114,12 @@ actual object Ed25519 {
return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
}
actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair {
require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" }
val publicKey = derivePublicKey(seed)
return Ed25519KeyPair(seed + publicKey, publicKey)
}
private fun derivePublicKey(seed: ByteArray): ByteArray {
val d = sha512(seed)
d[0] = (d[0].toInt() and 248).toByte()