use hybrid ed25519 + pq crypto signatures for session authentication

This commit is contained in:
Gareth George
2026-05-03 18:38:11 -07:00
parent cce8a4886b
commit bd8aafa5ce
21 changed files with 966 additions and 409 deletions
+19 -16
View File
@@ -92,7 +92,7 @@ func (x *SignedMessage) GetTimestampMillis() int64 {
type PublicKey struct {
state protoimpl.MessageState `protogen:"open.v1"`
Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key.
EcdsaPub string `protobuf:"bytes,2,opt,name=ecdsa_pub,json=ecdsaPub,proto3" json:"ecdsa_pub,omitempty"`
Ed25519Pub string `protobuf:"bytes,2,opt,name=ed25519pub,proto3" json:"ed25519pub,omitempty"` // raw base64-encoded ed25519 public key.
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -134,18 +134,18 @@ func (x *PublicKey) GetKeyid() string {
return ""
}
func (x *PublicKey) GetEcdsaPub() string {
func (x *PublicKey) GetEd25519Pub() string {
if x != nil {
return x.EcdsaPub
return x.Ed25519Pub
}
return ""
}
type PrivateKey struct {
state protoimpl.MessageState `protogen:"open.v1"`
Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key
EcdsaPriv string `protobuf:"bytes,2,opt,name=ecdsa_priv,json=ecdsaPriv,proto3" json:"ecdsa_priv,omitempty"`
EcdsaPub string `protobuf:"bytes,3,opt,name=ecdsa_pub,json=ecdsaPub,proto3" json:"ecdsa_pub,omitempty"`
Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key
Ed25519Priv string `protobuf:"bytes,2,opt,name=ed25519priv,proto3" json:"ed25519priv,omitempty"` // raw base64-encoded ed25519 private key seed.
Ed25519Pub string `protobuf:"bytes,3,opt,name=ed25519pub,proto3" json:"ed25519pub,omitempty"` // raw base64-encoded ed25519 public key.
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -187,16 +187,16 @@ func (x *PrivateKey) GetKeyid() string {
return ""
}
func (x *PrivateKey) GetEcdsaPriv() string {
func (x *PrivateKey) GetEd25519Priv() string {
if x != nil {
return x.EcdsaPriv
return x.Ed25519Priv
}
return ""
}
func (x *PrivateKey) GetEcdsaPub() string {
func (x *PrivateKey) GetEd25519Pub() string {
if x != nil {
return x.EcdsaPub
return x.Ed25519Pub
}
return ""
}
@@ -210,16 +210,19 @@ const file_v1_crypto_proto_rawDesc = "" +
"\x05keyid\x18\x01 \x01(\tR\x05keyid\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1c\n" +
"\tsignature\x18\x03 \x01(\fR\tsignature\x12(\n" +
"\x0ftimestampMillis\x18\x04 \x01(\x03R\x0ftimestampMillis\">\n" +
"\x0ftimestampMillis\x18\x04 \x01(\x03R\x0ftimestampMillis\"A\n" +
"\tPublicKey\x12\x14\n" +
"\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1b\n" +
"\tecdsa_pub\x18\x02 \x01(\tR\becdsaPub\"^\n" +
"\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1e\n" +
"\n" +
"ed25519pub\x18\x02 \x01(\tR\n" +
"ed25519pub\"d\n" +
"\n" +
"PrivateKey\x12\x14\n" +
"\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1d\n" +
"\x05keyid\x18\x01 \x01(\tR\x05keyId\x12 \n" +
"\ved25519priv\x18\x02 \x01(\tR\ved25519priv\x12\x1e\n" +
"\n" +
"ecdsa_priv\x18\x02 \x01(\tR\tecdsaPriv\x12\x1b\n" +
"\tecdsa_pub\x18\x03 \x01(\tR\becdsaPubB,Z*github.com/garethgeorge/backrest/gen/go/v1b\x06proto3"
"ed25519pub\x18\x03 \x01(\tR\n" +
"ed25519pubB,Z*github.com/garethgeorge/backrest/gen/go/v1b\x06proto3"
var (
file_v1_crypto_proto_rawDescOnce sync.Once
+40 -15
View File
@@ -1260,7 +1260,8 @@ func (x *SyncStreamItem_SyncActionHandshake) GetPairingSecret() string {
}
// SyncActionEncrypted wraps an encrypted SyncStreamItem.
// After ECDH key exchange, all subsequent messages are sent inside this envelope.
// After the post-quantum KEM handshake, all subsequent messages are sent
// inside this envelope.
type SyncStreamItem_SyncActionEncrypted struct {
state protoimpl.MessageState `protogen:"open.v1"`
Nonce []byte `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"` // 12-byte GCM nonce
@@ -1901,15 +1902,23 @@ func (x *SyncStreamItem_SyncActionThrottle) GetDelayMs() int64 {
return 0
}
// SyncEstablishSharedSecret is exchanged immediately after the handshake.
// Each side sends an ephemeral ECDH P-256 public key. Both sides then perform
// ECDH to derive a shared AES-256-GCM session key. All subsequent messages
// must be wrapped in SyncActionEncrypted.
// SyncEstablishSharedSecret is exchanged immediately after the connection
// is opened. The initiator (client) sends kem_public_key. The responder
// (server) replies with kem_encapsulation. Both sides then derive a shared
// AES-256-GCM session key via the HPKE Export interface. All subsequent
// messages must be wrapped in SyncActionEncrypted.
//
// The KEM is the post-quantum hybrid ML-KEM-1024 + ECDH-P384 (HPKE
// ciphersuite ML-KEM-1024-P384 / KEM ID 0x0050, RFC 9180 + the IETF hybrid
// KEM drafts). KDF is HKDF-SHA256, AEAD is AES-256-GCM. Peers must use
// protocol_version=1; mismatched versions abort the connection.
type SyncStreamItem_SyncEstablishSharedSecret struct {
state protoimpl.MessageState `protogen:"open.v1"`
EcdhPublicKey []byte `protobuf:"bytes,1,opt,name=ecdh_public_key,json=ecdhPublicKey,proto3" json:"ecdh_public_key,omitempty"` // raw ECDH P-256 public key bytes
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` // current: 1
KemPublicKey []byte `protobuf:"bytes,2,opt,name=kem_public_key,json=kemPublicKey,proto3" json:"kem_public_key,omitempty"` // set by initiator
KemEncapsulation []byte `protobuf:"bytes,3,opt,name=kem_encapsulation,json=kemEncapsulation,proto3" json:"kem_encapsulation,omitempty"` // set by responder
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SyncStreamItem_SyncEstablishSharedSecret) Reset() {
@@ -1942,9 +1951,23 @@ func (*SyncStreamItem_SyncEstablishSharedSecret) Descriptor() ([]byte, []int) {
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 14}
}
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetEcdhPublicKey() []byte {
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetProtocolVersion() uint32 {
if x != nil {
return x.EcdhPublicKey
return x.ProtocolVersion
}
return 0
}
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetKemPublicKey() []byte {
if x != nil {
return x.KemPublicKey
}
return nil
}
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetKemEncapsulation() []byte {
if x != nil {
return x.KemEncapsulation
}
return nil
}
@@ -2010,7 +2033,7 @@ const file_v1sync_syncservice_proto_rawDesc = "" +
"\n" +
"public_key\x18\x01 \x01(\v2\r.v1.PublicKeyR\tpublicKey\x122\n" +
"\vinstance_id\x18\x02 \x01(\v2\x11.v1.SignedMessageR\n" +
"instanceId\"\x8f\x16\n" +
"instanceId\"\xe6\x16\n" +
"\x0eSyncStreamItem\x12:\n" +
"\x0esigned_message\x18\x01 \x01(\v2\x11.v1.SignedMessageH\x00R\rsignedMessage\x12J\n" +
"\thandshake\x18\x03 \x01(\v2*.v1sync.SyncStreamItem.SyncActionHandshakeH\x00R\thandshake\x12J\n" +
@@ -2072,9 +2095,11 @@ const file_v1sync_syncservice_proto_rawDesc = "" +
"\x05chunk\x18\x04 \x01(\fR\x05chunk\x12#\n" +
"\rerror_message\x18\x05 \x01(\tR\ferrorMessage\x1a/\n" +
"\x12SyncActionThrottle\x12\x19\n" +
"\bdelay_ms\x18\x01 \x01(\x03R\adelayMs\x1aC\n" +
"\x19SyncEstablishSharedSecret\x12&\n" +
"\x0fecdh_public_key\x18\x01 \x01(\fR\recdhPublicKey\"\xb4\x01\n" +
"\bdelay_ms\x18\x01 \x01(\x03R\adelayMs\x1a\x99\x01\n" +
"\x19SyncEstablishSharedSecret\x12)\n" +
"\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x12$\n" +
"\x0ekem_public_key\x18\x02 \x01(\fR\fkemPublicKey\x12+\n" +
"\x11kem_encapsulation\x18\x03 \x01(\fR\x10kemEncapsulation\"\xb4\x01\n" +
"\x13RepoConnectionState\x12\x1c\n" +
"\x18CONNECTION_STATE_UNKNOWN\x10\x00\x12\x1c\n" +
"\x18CONNECTION_STATE_PENDING\x10\x01\x12\x1e\n" +
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/garethgeorge/backrest
go 1.25
go 1.26
require (
al.essio.dev/pkg/shellescape v1.6.0
+79 -38
View File
@@ -1,7 +1,6 @@
package syncapi
import (
"bytes"
"context"
"errors"
"fmt"
@@ -111,14 +110,16 @@ func (s *bidiSyncCommandStream) ReceiveWithinDuration(ctx context.Context, d tim
}
// ConnectStream bridges the channel-based bidiSyncCommandStream to a real transport.
// It first performs an ECDH key exchange on the raw transport to establish an encrypted
// session, then starts the send/recv pump loop over the encrypted channel.
func (s *bidiSyncCommandStream) ConnectStream(ctx context.Context, stream syncCommandStreamTrait) error {
// It first performs a post-quantum KEM handshake on the raw transport to
// establish an encrypted session, then starts the send/recv pump loop over the
// encrypted channel. isInitiator must be true on the side that opens the
// connection (the client) and false on the side that accepts it (the server).
func (s *bidiSyncCommandStream) ConnectStream(ctx context.Context, stream syncCommandStreamTrait, isInitiator bool) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Perform ECDH key exchange on the raw transport before starting the pump.
transport, err := establishEncryption(stream)
// Perform the PQ KEM handshake on the raw transport before starting the pump.
transport, err := establishEncryption(stream, isInitiator)
if err != nil {
// Signal termination so any goroutine parked in ReceiveWithinDuration
// (e.g. runSync waiting for the handshake reply) wakes up immediately
@@ -164,52 +165,92 @@ func (s *bidiSyncCommandStream) ConnectStream(ctx context.Context, stream syncCo
}
}
// establishEncryption performs an ECDH key exchange on the raw transport and
// returns an encrypted stream wrapper. Each side generates an ephemeral ECDH P-256
// key pair, exchanges public keys, and derives a shared AES-256-GCM session key.
// The handshake (identity authentication) runs over the encrypted channel afterward.
func establishEncryption(stream syncCommandStreamTrait) (syncCommandStreamTrait, error) {
keyPair, err := cryptoutil.GenerateECDHKeyPair()
if err != nil {
return nil, NewSyncErrorInternal(fmt.Errorf("generating ephemeral ECDH key: %w", err))
}
// establishEncryption performs a post-quantum KEM handshake on the raw
// transport and returns an encrypted stream wrapper. The KEM ciphersuite is
// hard-pinned to TransportProtocolVersion and tied to the wire format.
//
// Flow: the initiator generates an ephemeral hybrid (ML-KEM-1024 + ECDH-P384)
// HPKE keypair and sends its public key. The responder encapsulates against
// it and replies with the encapsulation. Both sides derive an AES-256-GCM
// session key via the HPKE Export interface. The handshake (identity
// authentication) runs over the encrypted channel afterward.
//
// Initiators use nonce prefix 0x00; responders use 0x01 — fixed by role to
// avoid nonce reuse. isInitiator must be true on the connecting side (client)
// and false on the accepting side (server).
func establishEncryption(stream syncCommandStreamTrait, isInitiator bool) (syncCommandStreamTrait, error) {
if isInitiator {
recipient, pubBytes, err := cryptoutil.NewTransportRecipient()
if err != nil {
return nil, NewSyncErrorInternal(fmt.Errorf("generating ephemeral KEM key: %w", err))
}
// Send our ephemeral ECDH public key
if err := stream.Send(&v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_EstablishSharedSecret{
EstablishSharedSecret: &v1sync.SyncStreamItem_SyncEstablishSharedSecret{
EcdhPublicKey: keyPair.Public.Bytes(),
if err := stream.Send(&v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_EstablishSharedSecret{
EstablishSharedSecret: &v1sync.SyncStreamItem_SyncEstablishSharedSecret{
ProtocolVersion: cryptoutil.TransportProtocolVersion,
KemPublicKey: pubBytes,
},
},
},
}); err != nil {
return nil, NewSyncErrorDisconnected(fmt.Errorf("sending ECDH public key: %w", err))
}); err != nil {
return nil, NewSyncErrorDisconnected(fmt.Errorf("sending KEM public key: %w", err))
}
peerMsg, err := stream.Receive()
if err != nil {
return nil, NewSyncErrorDisconnected(fmt.Errorf("receiving KEM encapsulation: %w", err))
}
peerSecret := peerMsg.GetEstablishSharedSecret()
if peerSecret == nil {
return nil, NewSyncErrorProtocol(fmt.Errorf("expected KEM key exchange, got %T", peerMsg.GetAction()))
}
if peerSecret.GetProtocolVersion() != cryptoutil.TransportProtocolVersion {
return nil, NewSyncErrorProtocol(fmt.Errorf("unsupported transport protocol version %d (this build requires v%d, post-quantum)", peerSecret.GetProtocolVersion(), cryptoutil.TransportProtocolVersion))
}
if len(peerSecret.GetKemEncapsulation()) == 0 {
return nil, NewSyncErrorProtocol(errors.New("responder did not send KEM encapsulation"))
}
sess, err := recipient.Decapsulate(peerSecret.GetKemEncapsulation())
if err != nil {
return nil, NewSyncErrorProtocol(fmt.Errorf("decapsulating KEM: %w", err))
}
zap.L().Info("encrypted sync session established (initiator)")
return newEncryptedStream(stream, sess), nil
}
// Receive the peer's ephemeral ECDH public key
peerMsg, err := stream.Receive()
if err != nil {
return nil, NewSyncErrorDisconnected(fmt.Errorf("receiving ECDH public key: %w", err))
return nil, NewSyncErrorDisconnected(fmt.Errorf("receiving KEM public key: %w", err))
}
peerSecret := peerMsg.GetEstablishSharedSecret()
if peerSecret == nil {
return nil, NewSyncErrorProtocol(fmt.Errorf("expected ECDH key exchange, got %T", peerMsg.GetAction()))
return nil, NewSyncErrorProtocol(fmt.Errorf("expected KEM key exchange, got %T", peerMsg.GetAction()))
}
if peerSecret.GetProtocolVersion() != cryptoutil.TransportProtocolVersion {
return nil, NewSyncErrorProtocol(fmt.Errorf("unsupported transport protocol version %d (this build requires v%d, post-quantum)", peerSecret.GetProtocolVersion(), cryptoutil.TransportProtocolVersion))
}
if len(peerSecret.GetKemPublicKey()) == 0 {
return nil, NewSyncErrorProtocol(errors.New("initiator did not send KEM public key"))
}
peerECDHPub, err := cryptoutil.ParseECDHPublicKey(peerSecret.GetEcdhPublicKey())
enc, sess, err := cryptoutil.EncapsulateToTransport(peerSecret.GetKemPublicKey())
if err != nil {
return nil, NewSyncErrorProtocol(fmt.Errorf("parsing peer ECDH public key: %w", err))
return nil, NewSyncErrorProtocol(fmt.Errorf("encapsulating to KEM public key: %w", err))
}
// Derive AES-256-GCM session key
gcm, err := cryptoutil.DeriveSessionKey(keyPair.Private, peerECDHPub)
if err != nil {
return nil, NewSyncErrorProtocol(fmt.Errorf("deriving session key: %w", err))
if err := stream.Send(&v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_EstablishSharedSecret{
EstablishSharedSecret: &v1sync.SyncStreamItem_SyncEstablishSharedSecret{
ProtocolVersion: cryptoutil.TransportProtocolVersion,
KemEncapsulation: enc,
},
},
}); err != nil {
return nil, NewSyncErrorDisconnected(fmt.Errorf("sending KEM encapsulation: %w", err))
}
// Determine nonce direction: side with smaller public key uses prefix 0x00
localIsSmaller := bytes.Compare(keyPair.Public.Bytes(), peerECDHPub.Bytes()) < 0
zap.L().Info("encrypted sync session established")
return newEncryptedStream(stream, gcm, localIsSmaller), nil
zap.L().Info("encrypted sync session established (responder)")
return newEncryptedStream(stream, sess), nil
}
+25 -35
View File
@@ -7,23 +7,20 @@ import (
"sync"
"github.com/garethgeorge/backrest/gen/go/v1sync"
"github.com/garethgeorge/backrest/internal/cryptoutil"
"google.golang.org/protobuf/proto"
)
// encryptedStream wraps a syncCommandStreamTrait with AES-256-GCM encryption.
// Outgoing SyncStreamItems are serialized, encrypted, and sent as SyncActionEncrypted.
// Incoming SyncActionEncrypted messages are decrypted and deserialized back to SyncStreamItems.
// encryptedStream wraps a syncCommandStreamTrait with AES-256-GCM encryption
// using the per-direction AEADs derived during the transport handshake.
//
// To avoid nonce reuse (since both sides share the same key), each direction
// uses a different nonce prefix byte: the side with the lexicographically smaller
// ECDH public key uses prefix 0x00 for sending and expects 0x01 for receiving,
// and vice versa.
// Each direction has an independent key (initiator-to-responder vs
// responder-to-initiator), so a counter-based nonce starting at zero is
// sufficient: there is no shared (key, nonce) space to collide in.
type encryptedStream struct {
inner syncCommandStreamTrait
gcm cipher.AEAD
sendPrefix byte
recvPrefix byte
send cipher.AEAD
recv cipher.AEAD
sendMu sync.Mutex
sendCounter uint64
@@ -32,18 +29,11 @@ type encryptedStream struct {
recvCounter uint64
}
func newEncryptedStream(inner syncCommandStreamTrait, gcm cipher.AEAD, localIsSmaller bool) *encryptedStream {
var sendPrefix, recvPrefix byte
if localIsSmaller {
sendPrefix, recvPrefix = 0x00, 0x01
} else {
sendPrefix, recvPrefix = 0x01, 0x00
}
func newEncryptedStream(inner syncCommandStreamTrait, sess *cryptoutil.TransportSession) *encryptedStream {
return &encryptedStream{
inner: inner,
gcm: gcm,
sendPrefix: sendPrefix,
recvPrefix: recvPrefix,
inner: inner,
send: sess.Send,
recv: sess.Recv,
}
}
@@ -54,11 +44,11 @@ func (s *encryptedStream) Send(item *v1sync.SyncStreamItem) error {
}
s.sendMu.Lock()
nonce := s.makeNonce(s.sendPrefix, s.sendCounter)
nonce := makeNonce(s.send.NonceSize(), s.sendCounter)
s.sendCounter++
s.sendMu.Unlock()
ciphertext := s.gcm.Seal(nil, nonce, plaintext, nil)
ciphertext := s.send.Seal(nil, nonce, plaintext, nil)
return s.inner.Send(&v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_Encrypted{
@@ -82,22 +72,22 @@ func (s *encryptedStream) Receive() (*v1sync.SyncStreamItem, error) {
}
s.recvMu.Lock()
expectedNonce := s.makeNonce(s.recvPrefix, s.recvCounter)
expectedNonce := makeNonce(s.recv.NonceSize(), s.recvCounter)
s.recvCounter++
s.recvMu.Unlock()
if len(encrypted.Nonce) != s.gcm.NonceSize() {
return nil, fmt.Errorf("invalid nonce size: got %d, want %d", len(encrypted.Nonce), s.gcm.NonceSize())
if len(encrypted.Nonce) != s.recv.NonceSize() {
return nil, fmt.Errorf("invalid nonce size: got %d, want %d", len(encrypted.Nonce), s.recv.NonceSize())
}
// Verify nonce matches expected counter to prevent replay/reorder attacks
// Verify nonce matches expected counter to prevent replay/reorder attacks.
for i := range expectedNonce {
if expectedNonce[i] != encrypted.Nonce[i] {
return nil, fmt.Errorf("nonce mismatch: possible replay or reorder attack")
}
}
plaintext, err := s.gcm.Open(nil, encrypted.Nonce, encrypted.Ciphertext, nil)
plaintext, err := s.recv.Open(nil, encrypted.Nonce, encrypted.Ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decrypt message: %w", err)
}
@@ -110,11 +100,11 @@ func (s *encryptedStream) Receive() (*v1sync.SyncStreamItem, error) {
return &inner, nil
}
// makeNonce creates a 12-byte GCM nonce. The first byte is the direction prefix
// (0x00 or 0x01), bytes 1-3 are zero, and bytes 4-11 are the counter in big-endian.
func (s *encryptedStream) makeNonce(prefix byte, counter uint64) []byte {
nonce := make([]byte, s.gcm.NonceSize()) // 12 bytes for GCM
nonce[0] = prefix
binary.BigEndian.PutUint64(nonce[4:], counter)
// makeNonce builds an N-byte AES-GCM nonce by big-endian-encoding the counter
// in the trailing 8 bytes; the leading bytes are zero. The counter never
// repeats within a single session direction, so the nonce never repeats.
func makeNonce(size int, counter uint64) []byte {
nonce := make([]byte, size)
binary.BigEndian.PutUint64(nonce[size-8:], counter)
return nonce
}
+57 -29
View File
@@ -1,6 +1,7 @@
package syncapi
import (
"crypto/rand"
"sync"
"testing"
@@ -32,28 +33,33 @@ func newFakeStreamPair() (*fakeStream, *fakeStream) {
return &fakeStream{sendCh: ab, recvCh: ba}, &fakeStream{sendCh: ba, recvCh: ab}
}
// runHandshake performs the post-quantum KEM handshake between an initiator
// and responder over a fakeStream pair, returning the resulting sessions in
// (initiator, responder) order.
func runHandshake(t *testing.T) (initiatorSess, responderSess *cryptoutil.TransportSession) {
t.Helper()
recipient, pubBytes, err := cryptoutil.NewTransportRecipient()
if err != nil {
t.Fatalf("NewTransportRecipient: %v", err)
}
enc, respSess, err := cryptoutil.EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatalf("EncapsulateToTransport: %v", err)
}
initSess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatalf("Decapsulate: %v", err)
}
return initSess, respSess
}
func TestEncryptedStream_RoundTrip(t *testing.T) {
alice, err := cryptoutil.GenerateECDHKeyPair()
if err != nil {
t.Fatal(err)
}
bob, err := cryptoutil.GenerateECDHKeyPair()
if err != nil {
t.Fatal(err)
}
gcm, err := cryptoutil.DeriveSessionKey(alice.Private, bob.Public)
if err != nil {
t.Fatal(err)
}
aliceIsSmaller := string(alice.Public.Bytes()) < string(bob.Public.Bytes())
initSess, respSess := runHandshake(t)
transportA, transportB := newFakeStreamPair()
encA := newEncryptedStream(transportA, gcm, aliceIsSmaller)
encB := newEncryptedStream(transportB, gcm, !aliceIsSmaller)
encA := newEncryptedStream(transportA, initSess)
encB := newEncryptedStream(transportB, respSess)
// Send a heartbeat from A to B
sendItem := &v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_Heartbeat{
Heartbeat: &v1sync.SyncStreamItem_SyncActionHeartbeat{},
@@ -81,15 +87,11 @@ func TestEncryptedStream_RoundTrip(t *testing.T) {
}
func TestEncryptedStream_BidirectionalMultiMessage(t *testing.T) {
alice, _ := cryptoutil.GenerateECDHKeyPair()
bob, _ := cryptoutil.GenerateECDHKeyPair()
gcm, _ := cryptoutil.DeriveSessionKey(alice.Private, bob.Public)
aliceIsSmaller := string(alice.Public.Bytes()) < string(bob.Public.Bytes())
initSess, respSess := runHandshake(t)
transportA, transportB := newFakeStreamPair()
encA := newEncryptedStream(transportA, gcm, aliceIsSmaller)
encB := newEncryptedStream(transportB, gcm, !aliceIsSmaller)
encA := newEncryptedStream(transportA, initSess)
encB := newEncryptedStream(transportB, respSess)
heartbeat := &v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_Heartbeat{
@@ -97,7 +99,6 @@ func TestEncryptedStream_BidirectionalMultiMessage(t *testing.T) {
},
}
// Send 5 messages A→B sequentially, then 5 messages B→A sequentially
var wg sync.WaitGroup
// A→B direction
@@ -146,11 +147,13 @@ func TestEstablishEncryption_Integration(t *testing.T) {
wg.Add(2)
go func() {
defer wg.Done()
encA, errA = establishEncryption(transportA)
// A is the initiator (client side).
encA, errA = establishEncryption(transportA, true)
}()
go func() {
defer wg.Done()
encB, errB = establishEncryption(transportB)
// B is the responder (server side).
encB, errB = establishEncryption(transportB, false)
}()
wg.Wait()
@@ -161,7 +164,6 @@ func TestEstablishEncryption_Integration(t *testing.T) {
t.Fatalf("establish B: %v", errB)
}
// Verify encrypted communication works
heartbeat := &v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_Heartbeat{
Heartbeat: &v1sync.SyncStreamItem_SyncActionHeartbeat{},
@@ -186,3 +188,29 @@ func TestEstablishEncryption_Integration(t *testing.T) {
t.Fatalf("expected heartbeat, got %T", recv.GetAction())
}
}
func TestEstablishEncryption_ProtocolVersionMismatch(t *testing.T) {
testutil.InstallZapLogger(t)
transportA, transportB := newFakeStreamPair()
// Responder receives a handshake with the wrong protocol version and
// must reject it with a protocol error.
junkPub := make([]byte, 32)
if _, err := rand.Read(junkPub); err != nil {
t.Fatal(err)
}
go func() {
_ = transportA.Send(&v1sync.SyncStreamItem{
Action: &v1sync.SyncStreamItem_EstablishSharedSecret{
EstablishSharedSecret: &v1sync.SyncStreamItem_SyncEstablishSharedSecret{
ProtocolVersion: cryptoutil.TransportProtocolVersion + 1,
KemPublicKey: junkPub,
},
},
})
}()
if _, err := establishEncryption(transportB, false); err == nil {
t.Fatal("expected protocol version mismatch to fail handshake")
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ func (c *SyncClient) RunSync(ctx context.Context) {
cmdStream.SendErrorAndTerminate(err)
}()
connectErr := cmdStream.ConnectStream(ctx, c.client.Sync(ctx))
connectErr := cmdStream.ConnectStream(ctx, c.client.Sync(ctx), true /* isInitiator: client side */)
if connectErr != nil {
c.l.Sugar().Infof("lost stream connection to peer %q (%s): %v", c.peer.InstanceId, c.peer.Keyid, connectErr)
var syncErr *SyncError
+1 -1
View File
@@ -64,7 +64,7 @@ func (h *BackrestSyncHandler) Sync(ctx context.Context, stream *connect.BidiStre
cmdStream.SendErrorAndTerminate(err)
}()
if err := cmdStream.ConnectStream(ctx, stream); err != nil {
if err := cmdStream.ConnectStream(ctx, stream, false /* isInitiator: server side */); err != nil {
zap.S().Errorf("sync handler stream error: %v", err)
var syncErr *SyncError
if errors.As(err, &syncErr) {
@@ -0,0 +1,30 @@
package migrations
import (
"strings"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"go.uber.org/zap"
)
// migration006Ed25519Identity drops any existing multihost identity that does
// not use the ed25519 key scheme. The previous ECDSA-based identity used
// PEM-encoded keys with an "ecdsa." keyid prefix; those keys are no longer
// readable by the cryptoutil package. Clearing the identity here lets
// PopulateRequiredFields generate a fresh ed25519 identity on next load.
var migration006Ed25519Identity = func(config *v1.Config) error {
multihost := config.GetMultihost()
if multihost == nil {
return nil
}
identity := multihost.GetIdentity()
if identity == nil {
return nil
}
if strings.HasPrefix(identity.GetKeyid(), "ed25519.") && identity.GetEd25519Priv() != "" && identity.GetEd25519Pub() != "" {
return nil
}
zap.S().Warnf("dropping legacy multihost identity %q; a new ed25519 identity will be generated", identity.GetKeyid())
multihost.Identity = nil
return nil
}
+1
View File
@@ -14,6 +14,7 @@ var migrations = []*func(*v1.Config) error{
&migration003RelativeScheduling,
&migration004RepoGuid,
&migration005CheckRepoPasswords,
&migration006Ed25519Identity,
}
var CurrentVersion = int32(len(migrations))
+12 -12
View File
@@ -25,8 +25,8 @@ func TestSanitizeForNetwork(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "test-private-key",
EcdsaPub: "test-public-key",
Ed25519Priv: "test-private-key",
Ed25519Pub: "test-public-key",
},
},
},
@@ -34,8 +34,8 @@ func TestSanitizeForNetwork(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "",
EcdsaPub: "",
Ed25519Priv: "",
Ed25519Pub: "",
},
},
},
@@ -85,8 +85,8 @@ func TestSanitizeForNetwork(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "secret-key",
EcdsaPub: "public-key",
Ed25519Priv: "secret-key",
Ed25519Pub: "public-key",
},
},
Auth: &v1.Auth{
@@ -104,8 +104,8 @@ func TestSanitizeForNetwork(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "",
EcdsaPub: "",
Ed25519Priv: "",
Ed25519Pub: "",
},
},
Auth: &v1.Auth{
@@ -174,8 +174,8 @@ func TestRehydrateNetworkSanitizedConfig(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "secret-key-data",
EcdsaPub: "public-key-data",
Ed25519Priv: "secret-key-data",
Ed25519Pub: "public-key-data",
},
},
},
@@ -183,8 +183,8 @@ func TestRehydrateNetworkSanitizedConfig(t *testing.T) {
Multihost: &v1.Multihost{
Identity: &v1.PrivateKey{
Keyid: "test-key-id",
EcdsaPriv: "secret-key-data",
EcdsaPub: "public-key-data",
Ed25519Priv: "secret-key-data",
Ed25519Pub: "public-key-data",
},
},
},
-78
View File
@@ -1,78 +0,0 @@
package cryptoutil
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"golang.org/x/crypto/hkdf"
)
// ECDHKeyPair holds an ephemeral ECDH key pair for key exchange.
type ECDHKeyPair struct {
Private *ecdh.PrivateKey
Public *ecdh.PublicKey
}
// GenerateECDHKeyPair generates an ephemeral ECDH P-256 key pair.
func GenerateECDHKeyPair() (*ECDHKeyPair, error) {
privKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate ECDH key: %w", err)
}
return &ECDHKeyPair{
Private: privKey,
Public: privKey.PublicKey(),
}, nil
}
// DeriveSessionKey performs ECDH with the peer's public key and derives an
// AES-256-GCM AEAD using HKDF-SHA256. Both ephemeral public keys are included
// as HKDF salt to bind the derived key to this specific exchange. Authentication
// of the peers is provided by the handshake layer (signature verification) which
// runs over the encrypted channel.
func DeriveSessionKey(localPrivate *ecdh.PrivateKey, peerPublic *ecdh.PublicKey) (cipher.AEAD, error) {
sharedSecret, err := localPrivate.ECDH(peerPublic)
if err != nil {
return nil, fmt.Errorf("ECDH key agreement: %w", err)
}
// Sort public keys so both sides produce the same salt regardless of role
pubA, pubB := localPrivate.PublicKey().Bytes(), peerPublic.Bytes()
if bytes.Compare(pubA, pubB) > 0 {
pubA, pubB = pubB, pubA
}
salt := append(pubA, pubB...)
info := []byte("backrest-sync-v2")
hkdfReader := hkdf.New(sha256.New, sharedSecret, salt, info)
key := make([]byte, 32)
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return nil, fmt.Errorf("HKDF key derivation: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return gcm, nil
}
// ParseECDHPublicKey parses raw ECDH P-256 public key bytes.
func ParseECDHPublicKey(raw []byte) (*ecdh.PublicKey, error) {
pub, err := ecdh.P256().NewPublicKey(raw)
if err != nil {
return nil, fmt.Errorf("parse ECDH public key: %w", err)
}
return pub, nil
}
-91
View File
@@ -1,91 +0,0 @@
package cryptoutil
import (
"testing"
)
func TestGenerateECDHKeyPair(t *testing.T) {
kp, err := GenerateECDHKeyPair()
if err != nil {
t.Fatalf("GenerateECDHKeyPair: %v", err)
}
if kp.Private == nil || kp.Public == nil {
t.Fatal("key pair has nil fields")
}
if len(kp.Public.Bytes()) == 0 {
t.Fatal("public key bytes are empty")
}
}
func TestDeriveSessionKey_Symmetric(t *testing.T) {
alice, err := GenerateECDHKeyPair()
if err != nil {
t.Fatal(err)
}
bob, err := GenerateECDHKeyPair()
if err != nil {
t.Fatal(err)
}
gcmAlice, err := DeriveSessionKey(alice.Private, bob.Public)
if err != nil {
t.Fatalf("DeriveSessionKey (alice): %v", err)
}
gcmBob, err := DeriveSessionKey(bob.Private, alice.Public)
if err != nil {
t.Fatalf("DeriveSessionKey (bob): %v", err)
}
// Both sides should produce the same key: encrypt with alice, decrypt with bob
plaintext := []byte("hello backrest")
nonce := make([]byte, gcmAlice.NonceSize())
ciphertext := gcmAlice.Seal(nil, nonce, plaintext, nil)
decrypted, err := gcmBob.Open(nil, nonce, ciphertext, nil)
if err != nil {
t.Fatalf("bob failed to decrypt alice's message: %v", err)
}
if string(decrypted) != string(plaintext) {
t.Fatalf("decrypted %q, want %q", decrypted, plaintext)
}
}
func TestDeriveSessionKey_DifferentPairs(t *testing.T) {
a, _ := GenerateECDHKeyPair()
b, _ := GenerateECDHKeyPair()
c, _ := GenerateECDHKeyPair()
gcmAB, _ := DeriveSessionKey(a.Private, b.Public)
gcmAC, _ := DeriveSessionKey(a.Private, c.Public)
plaintext := []byte("test")
nonce := make([]byte, gcmAB.NonceSize())
ciphertext := gcmAB.Seal(nil, nonce, plaintext, nil)
// AC key should NOT be able to decrypt AB ciphertext
if _, err := gcmAC.Open(nil, nonce, ciphertext, nil); err == nil {
t.Fatal("different key pair should not decrypt")
}
}
func TestParseECDHPublicKey_RoundTrip(t *testing.T) {
kp, err := GenerateECDHKeyPair()
if err != nil {
t.Fatal(err)
}
raw := kp.Public.Bytes()
parsed, err := ParseECDHPublicKey(raw)
if err != nil {
t.Fatalf("ParseECDHPublicKey: %v", err)
}
if string(parsed.Bytes()) != string(raw) {
t.Fatal("round-trip failed")
}
}
func TestParseECDHPublicKey_Invalid(t *testing.T) {
if _, err := ParseECDHPublicKey([]byte("not a key")); err == nil {
t.Fatal("expected error for invalid key")
}
}
+33 -65
View File
@@ -1,13 +1,11 @@
package cryptoutil
import (
"crypto/ecdsa"
"crypto/elliptic"
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
@@ -15,38 +13,30 @@ import (
"google.golang.org/protobuf/proto"
)
var (
curve = elliptic.P256()
)
const keyIDPrefix = "ed25519."
type PublicKey struct {
proto *v1.PublicKey
publicCryptoKey ecdsa.PublicKey
publicCryptoKey ed25519.PublicKey
}
func NewPublicKey(pubkey *v1.PublicKey) (*PublicKey, error) {
pubKeyBlock, _ := pem.Decode([]byte(pubkey.EcdsaPub))
if pubKeyBlock == nil {
return nil, errors.New("no public key found in pem")
}
pkixPubKey, err := x509.ParsePKIXPublicKey(pubKeyBlock.Bytes)
pubBytes, err := base64.RawStdEncoding.DecodeString(pubkey.Ed25519Pub)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
return nil, fmt.Errorf("decode public key: %w", err)
}
if len(pubBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid ed25519 public key size: got %d, want %d", len(pubBytes), ed25519.PublicKeySize)
}
ecdsaPubKey, ok := pkixPubKey.(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("not an ECDSA public key")
}
if derived := deriveKeyId(ecdsaPubKey); derived != pubkey.Keyid {
edPubKey := ed25519.PublicKey(pubBytes)
if derived := deriveKeyId(edPubKey); derived != pubkey.Keyid {
return nil, fmt.Errorf("public key_id provided does not match the derived key: %s != %s", derived, pubkey.Keyid)
}
return &PublicKey{
proto: pubkey,
publicCryptoKey: *ecdsaPubKey,
publicCryptoKey: edPubKey,
}, nil
}
@@ -58,10 +48,8 @@ func (pk *PublicKey) PublicKeyProto() *v1.PublicKey {
return proto.Clone(pk.proto).(*v1.PublicKey)
}
// VerifySignature verifies the signature of a message
func (pk *PublicKey) Verify(message, sig []byte) error {
hash := sha256.Sum256(message)
if !ecdsa.VerifyASN1(&pk.publicCryptoKey, hash[:], sig) {
if !ed25519.Verify(pk.publicCryptoKey, message, sig) {
return errors.New("signature verification failed")
}
return nil
@@ -70,61 +58,49 @@ func (pk *PublicKey) Verify(message, sig []byte) error {
type PrivateKey struct {
*PublicKey
proto *v1.PrivateKey
privateCryptoKey *ecdsa.PrivateKey
privateCryptoKey ed25519.PrivateKey
}
func NewPrivateKey(privkey *v1.PrivateKey) (*PrivateKey, error) {
privKeyBlock, _ := pem.Decode([]byte(privkey.EcdsaPriv))
if privKeyBlock == nil {
return nil, errors.New("no private key found in pem")
}
ecdsaPrivKey, err := x509.ParseECPrivateKey(privKeyBlock.Bytes)
seed, err := base64.RawStdEncoding.DecodeString(privkey.Ed25519Priv)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
return nil, fmt.Errorf("decode private key: %w", err)
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("invalid ed25519 private key seed size: got %d, want %d", len(seed), ed25519.SeedSize)
}
edPrivKey := ed25519.NewKeyFromSeed(seed)
pubKey, err := NewPublicKey(&v1.PublicKey{
Keyid: privkey.Keyid,
EcdsaPub: privkey.EcdsaPub,
Ed25519Pub: privkey.Ed25519Pub,
})
if err != nil {
return nil, err
}
if ecdsaPrivKey.PublicKey.X.Cmp(pubKey.publicCryptoKey.X) != 0 ||
ecdsaPrivKey.PublicKey.Y.Cmp(pubKey.publicCryptoKey.Y) != 0 {
derivedPub := edPrivKey.Public().(ed25519.PublicKey)
if !bytes.Equal(derivedPub, pubKey.publicCryptoKey) {
return nil, errors.New("private key does not match public key")
}
return &PrivateKey{
PublicKey: pubKey,
proto: privkey,
privateCryptoKey: ecdsaPrivKey,
privateCryptoKey: edPrivKey,
}, nil
}
func GeneratePrivateKey() (*v1.PrivateKey, error) {
privKey, err := ecdsa.GenerateKey(curve, rand.Reader)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
return nil, fmt.Errorf("generate ed25519 key: %w", err)
}
privateKeyBytes, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return nil, fmt.Errorf("marshal private key: %w", err)
}
pemPrivateKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE", Bytes: privateKeyBytes})
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
pemPublicKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PUBLIC", Bytes: publicKeyBytes})
return &v1.PrivateKey{
Keyid: deriveKeyId(&privKey.PublicKey),
EcdsaPriv: string(pemPrivateKeyBytes),
EcdsaPub: string(pemPublicKeyBytes),
Keyid: deriveKeyId(pub),
Ed25519Priv: base64.RawStdEncoding.EncodeToString(priv.Seed()),
Ed25519Pub: base64.RawStdEncoding.EncodeToString(pub),
}, nil
}
@@ -132,19 +108,11 @@ func (pk *PrivateKey) PrivateKeyProto() *v1.PrivateKey {
return proto.Clone(pk.proto).(*v1.PrivateKey)
}
// SignMessage signs a message using the private key
func (pk *PrivateKey) Sign(message []byte) ([]byte, error) {
hash := sha256.Sum256(message)
sig, err := ecdsa.SignASN1(rand.Reader, pk.privateCryptoKey, hash[:])
if err != nil {
return nil, fmt.Errorf("sign message: %w", err)
}
return sig, nil
return ed25519.Sign(pk.privateCryptoKey, message), nil
}
func deriveKeyId(key *ecdsa.PublicKey) string {
shasum := sha256.New()
shasum.Write(key.X.Bytes())
shasum.Write(key.Y.Bytes())
return "ecdsa." + base64.RawURLEncoding.EncodeToString(shasum.Sum(nil))
func deriveKeyId(key ed25519.PublicKey) string {
shasum := sha256.Sum256(key)
return keyIDPrefix + base64.RawURLEncoding.EncodeToString(shasum[:])
}
+7 -2
View File
@@ -1,6 +1,7 @@
package cryptoutil
import (
"strings"
"testing"
)
@@ -10,13 +11,17 @@ func TestGenerateKeypair(t *testing.T) {
t.Fatalf("failed to generate key pair: %v", err)
}
if len(privateKey.EcdsaPriv) == 0 {
if len(privateKey.Ed25519Priv) == 0 {
t.Fatalf("must populate private key")
}
if len(privateKey.EcdsaPub) == 0 {
if len(privateKey.Ed25519Pub) == 0 {
t.Fatalf("must populate public key")
}
if !strings.HasPrefix(privateKey.Keyid, "ed25519.") {
t.Fatalf("expected keyid to use ed25519. prefix, got %q", privateKey.Keyid)
}
}
func TestLoadKey(t *testing.T) {
+277
View File
@@ -0,0 +1,277 @@
package cryptoutil
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/hpke"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
)
// TransportProtocolVersion is the wire-format version of the sync transport
// handshake. Both peers MUST use the same value; mismatches abort the
// connection. The version is bound into the HPKE info string and every
// exporter label, so a peer running a different version cannot derive a
// matching session key even if the underlying ciphersuite is unchanged.
// Bump this whenever the on-wire handshake or KEM ciphersuite changes.
const TransportProtocolVersion uint32 = 1
const transportSessionKeyLen = 32 // AES-256
// transportRole records which side of the handshake produced a session.
// The initiator is the holder of the ephemeral KEM private key (HPKE
// recipient). The responder is the encapsulator (HPKE sender).
type transportRole int
const (
roleInitiator transportRole = iota
roleResponder
)
var transportInfo = []byte(fmt.Sprintf("backrest-sync-transport-v%d", TransportProtocolVersion))
func exporterLabelI2R() string {
return fmt.Sprintf("backrest-sync-session-key/v%d/initiator-to-responder", TransportProtocolVersion)
}
func exporterLabelR2I() string {
return fmt.Sprintf("backrest-sync-session-key/v%d/responder-to-initiator", TransportProtocolVersion)
}
// exporterLabelBound builds a label that binds both peers' ed25519 identity
// public keys into the HPKE Export context. The label is a printable prefix
// followed by NUL and the raw 32-byte identity pubkeys in canonical order
// (initiator || responder). HPKE treats the label as opaque bytes, so the
// embedded binary is safe.
func exporterLabelBound(direction string, initiator, responder ed25519.PublicKey) string {
prefix := fmt.Sprintf("backrest-sync-session-key/v%d/%s/identity-bound\x00", TransportProtocolVersion, direction)
return prefix + string(initiator) + string(responder)
}
// transportCiphersuite returns the HPKE ciphersuite used by the sync transport.
// Keep this opinionated and pinned to TransportProtocolVersion: hybrid PQ KEM
// (ML-KEM-1024 + ECDH-P384), HKDF-SHA256, AES-256-GCM.
func transportCiphersuite() (hpke.KEM, hpke.KDF, hpke.AEAD) {
return hpke.MLKEM1024P384(), hpke.HKDFSHA256(), hpke.AES256GCM()
}
// TransportSession is the result of a successful handshake: a pair of one-way
// AEADs plus a transcript hash for ed25519 identity authentication.
//
// Send is for outbound traffic, Recv for inbound. The two AEADs hold
// independent keys derived from distinct HPKE exporter labels, so callers
// may use any nonce discipline (a counter starting at zero is recommended)
// without risk of cross-direction reuse.
//
// Recommended use with the project's ed25519 identity layer:
//
// 1. Each peer signs Transcript() with its own ed25519 private key and
// sends (signature, identity public key) over Send.
// 2. Each peer Recvs the peer's signature and identity, verifies the
// signature against Transcript(), and applies any out-of-band identity
// policy (matching against a known public key, etc.).
// 3. After both signatures verify, each peer calls BindIdentities(self,
// peer). The Send and Recv AEADs are re-derived with both identity
// keys mixed into the HPKE exporter context. From this point on the
// channel is cryptographically tied to the agreed identity pair, so
// even a MITM that bypassed step 2 can no longer read or forge
// messages.
type TransportSession struct {
Send cipher.AEAD
Recv cipher.AEAD
transcript [sha256.Size]byte
exporter func(context string, length int) ([]byte, error)
role transportRole
}
// Transcript returns a hash that commits to the protocol version, the
// initiator's ephemeral KEM public key, and the encapsulation. Both peers
// compute the identical value. Sign this with your ed25519 identity key
// to authenticate the channel; a MITM cannot produce a signature over
// the transcript that the legitimate peer derived.
func (s *TransportSession) Transcript() []byte {
out := make([]byte, len(s.transcript))
copy(out, s.transcript[:])
return out
}
// BindIdentities re-derives Send and Recv using exporter labels that include
// both peers' ed25519 identity public keys. The caller MUST have already
// verified that `peer` owns its identity (e.g. via a signature over
// Transcript()) before invoking this method. BindIdentities does not itself
// authenticate; it tightens the channel so that any MITM that survived
// the signature exchange still cannot decrypt or forge messages, because
// the bound keys cannot be derived without agreement on both identities.
//
// The role of the local peer (initiator vs responder) is captured at
// handshake time, so the caller need only pass its own identity and the
// peer's; the canonical (initiator, responder) ordering used in the label
// is determined by this session's role.
//
// After BindIdentities returns successfully the previous Send / Recv AEADs
// MUST NOT be used. References captured before this call must be dropped.
func (s *TransportSession) BindIdentities(self, peer ed25519.PublicKey) error {
if len(self) != ed25519.PublicKeySize {
return fmt.Errorf("self identity must be %d bytes, got %d", ed25519.PublicKeySize, len(self))
}
if len(peer) != ed25519.PublicKeySize {
return fmt.Errorf("peer identity must be %d bytes, got %d", ed25519.PublicKeySize, len(peer))
}
var initiator, responder ed25519.PublicKey
if s.role == roleInitiator {
initiator, responder = self, peer
} else {
initiator, responder = peer, self
}
i2rKey, err := s.exporter(exporterLabelBound("initiator-to-responder", initiator, responder), transportSessionKeyLen)
if err != nil {
return fmt.Errorf("export identity-bound i2r key: %w", err)
}
r2iKey, err := s.exporter(exporterLabelBound("responder-to-initiator", initiator, responder), transportSessionKeyLen)
if err != nil {
return fmt.Errorf("export identity-bound r2i key: %w", err)
}
i2rAEAD, err := newSessionAEAD(i2rKey)
if err != nil {
return err
}
r2iAEAD, err := newSessionAEAD(r2iKey)
if err != nil {
return err
}
if s.role == roleInitiator {
s.Send, s.Recv = i2rAEAD, r2iAEAD
} else {
s.Send, s.Recv = r2iAEAD, i2rAEAD
}
return nil
}
// TransportRecipient is the initiator side of the handshake. The initiator
// generates an ephemeral KEM keypair, sends its public key, and receives
// the encapsulation from the responder before deriving the session.
type TransportRecipient struct {
priv hpke.PrivateKey
pub []byte // cached for transcript construction
}
// NewTransportRecipient generates an ephemeral KEM keypair for the initiator
// side of the transport handshake. It returns the recipient state and the
// raw bytes of the public key that should be sent to the peer.
func NewTransportRecipient() (*TransportRecipient, []byte, error) {
kem, _, _ := transportCiphersuite()
priv, err := kem.GenerateKey()
if err != nil {
return nil, nil, fmt.Errorf("generate transport KEM key: %w", err)
}
pub := priv.PublicKey().Bytes()
return &TransportRecipient{priv: priv, pub: pub}, pub, nil
}
// Decapsulate consumes the encapsulation bytes received from the responder
// and returns the initiator's session.
func (r *TransportRecipient) Decapsulate(enc []byte) (*TransportSession, error) {
if r == nil || r.priv == nil {
return nil, errors.New("transport recipient: nil state")
}
if len(enc) == 0 {
return nil, errors.New("transport recipient: empty encapsulation")
}
_, kdf, aead := transportCiphersuite()
recipient, err := hpke.NewRecipient(enc, r.priv, kdf, aead, transportInfo)
if err != nil {
return nil, fmt.Errorf("decapsulate transport KEM: %w", err)
}
return buildSession(recipient.Export, r.pub, enc, roleInitiator)
}
// EncapsulateToTransport is the responder side of the handshake. Given the
// initiator's serialized public key bytes, it returns the encapsulation to
// send back and the responder's session.
func EncapsulateToTransport(peerPubBytes []byte) (enc []byte, _ *TransportSession, _ error) {
if len(peerPubBytes) == 0 {
return nil, nil, errors.New("transport responder: empty peer public key")
}
kem, kdf, aead := transportCiphersuite()
pub, err := kem.NewPublicKey(peerPubBytes)
if err != nil {
return nil, nil, fmt.Errorf("parse peer transport public key: %w", err)
}
enc, sender, err := hpke.NewSender(pub, kdf, aead, transportInfo)
if err != nil {
return nil, nil, fmt.Errorf("encapsulate transport KEM: %w", err)
}
sess, err := buildSession(sender.Export, peerPubBytes, enc, roleResponder)
if err != nil {
return nil, nil, err
}
return enc, sess, nil
}
func buildSession(exporter func(string, int) ([]byte, error), initiatorPub, enc []byte, role transportRole) (*TransportSession, error) {
i2rKey, err := exporter(exporterLabelI2R(), transportSessionKeyLen)
if err != nil {
return nil, fmt.Errorf("export i2r session key: %w", err)
}
r2iKey, err := exporter(exporterLabelR2I(), transportSessionKeyLen)
if err != nil {
return nil, fmt.Errorf("export r2i session key: %w", err)
}
i2rAEAD, err := newSessionAEAD(i2rKey)
if err != nil {
return nil, err
}
r2iAEAD, err := newSessionAEAD(r2iKey)
if err != nil {
return nil, err
}
s := &TransportSession{
transcript: computeTranscript(initiatorPub, enc),
exporter: exporter,
role: role,
}
if role == roleInitiator {
s.Send, s.Recv = i2rAEAD, r2iAEAD
} else {
s.Send, s.Recv = r2iAEAD, i2rAEAD
}
return s, nil
}
func computeTranscript(initiatorPub, enc []byte) [sha256.Size]byte {
h := sha256.New()
// Domain-separate from any other transcript-style hash in the project.
h.Write([]byte("backrest-sync-transport-transcript/v1\x00"))
var versionBytes [4]byte
binary.BigEndian.PutUint32(versionBytes[:], TransportProtocolVersion)
h.Write(versionBytes[:])
writeLengthPrefixed(h, initiatorPub)
writeLengthPrefixed(h, enc)
var out [sha256.Size]byte
h.Sum(out[:0])
return out
}
func writeLengthPrefixed(h hash.Hash, b []byte) {
var lenBytes [4]byte
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(b)))
h.Write(lenBytes[:])
h.Write(b)
}
func newSessionAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return gcm, nil
}
+322
View File
@@ -0,0 +1,322 @@
package cryptoutil
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"testing"
)
func TestTransportHandshake_RoundTrip(t *testing.T) {
recipient, pubBytes, err := NewTransportRecipient()
if err != nil {
t.Fatalf("NewTransportRecipient: %v", err)
}
if len(pubBytes) == 0 {
t.Fatal("public key bytes are empty")
}
enc, respSess, err := EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatalf("EncapsulateToTransport: %v", err)
}
if len(enc) == 0 {
t.Fatal("encapsulation bytes are empty")
}
initSess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatalf("Decapsulate: %v", err)
}
// Both peers must derive the same transcript.
if !bytes.Equal(initSess.Transcript(), respSess.Transcript()) {
t.Fatal("transcripts differ between initiator and responder")
}
plaintext := []byte("hello backrest pq")
nonce := make([]byte, respSess.Send.NonceSize())
// responder -> initiator
ct1 := respSess.Send.Seal(nil, nonce, plaintext, nil)
got, err := initSess.Recv.Open(nil, nonce, ct1, nil)
if err != nil {
t.Fatalf("initiator Recv failed to open responder Send: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("plaintext round-trip mismatch: got %q want %q", got, plaintext)
}
// initiator -> responder, reusing the same nonce: must succeed because
// the two directions hold independent keys.
ct2 := initSess.Send.Seal(nil, nonce, plaintext, nil)
got2, err := respSess.Recv.Open(nil, nonce, ct2, nil)
if err != nil {
t.Fatalf("responder Recv failed to open initiator Send: %v", err)
}
if !bytes.Equal(got2, plaintext) {
t.Fatalf("reverse plaintext round-trip mismatch: got %q want %q", got2, plaintext)
}
}
func TestTransportHandshake_PerDirectionKeysAreDistinct(t *testing.T) {
recipient, pubBytes, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc, respSess, err := EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatal(err)
}
initSess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatal(err)
}
plaintext := []byte("direction-isolation")
nonce := make([]byte, respSess.Send.NonceSize())
// A ciphertext from the responder->initiator direction must NOT be
// openable by the responder's own Recv (which is the initiator->responder
// key). If keys weren't direction-split this would succeed and silently
// reuse a (key, nonce) pair.
ct := respSess.Send.Seal(nil, nonce, plaintext, nil)
if _, err := respSess.Recv.Open(nil, nonce, ct, nil); err == nil {
t.Fatal("responder Recv must not open responder Send ciphertext")
}
if _, err := initSess.Send.Open(nil, nonce, ct, nil); err == nil {
t.Fatal("initiator Send must not open responder Send ciphertext")
}
}
func TestTransportHandshake_DistinctSessions(t *testing.T) {
r1, pub1, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
r2, pub2, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc1, sess1, err := EncapsulateToTransport(pub1)
if err != nil {
t.Fatal(err)
}
enc2, sess2, err := EncapsulateToTransport(pub2)
if err != nil {
t.Fatal(err)
}
// Each recipient pairs only with its own encapsulation. ML-KEM uses
// implicit rejection, so Decapsulate may succeed against a foreign enc;
// the mismatch must be caught by AEAD authentication.
sessR1, err := r1.Decapsulate(enc1)
if err != nil {
t.Fatalf("r1 decapsulate own enc: %v", err)
}
sessR2, err := r2.Decapsulate(enc2)
if err != nil {
t.Fatalf("r2 decapsulate own enc: %v", err)
}
plaintext := []byte("isolation check")
nonce := make([]byte, sess1.Send.NonceSize())
ct1 := sess1.Send.Seal(nil, nonce, plaintext, nil)
if got, err := sessR1.Recv.Open(nil, nonce, ct1, nil); err != nil {
t.Fatalf("paired session 1 should decrypt: %v", err)
} else if !bytes.Equal(got, plaintext) {
t.Fatalf("paired session 1 plaintext mismatch")
}
if _, err := sessR2.Recv.Open(nil, nonce, ct1, nil); err == nil {
t.Fatal("session 2 must not decrypt session 1 ciphertext")
}
ct2 := sess2.Send.Seal(nil, nonce, plaintext, nil)
if got, err := sessR2.Recv.Open(nil, nonce, ct2, nil); err != nil {
t.Fatalf("paired session 2 should decrypt: %v", err)
} else if !bytes.Equal(got, plaintext) {
t.Fatalf("paired session 2 plaintext mismatch")
}
if _, err := sessR1.Recv.Open(nil, nonce, ct2, nil); err == nil {
t.Fatal("session 1 must not decrypt session 2 ciphertext")
}
}
func TestTransportHandshake_BindIdentities(t *testing.T) {
initPub, initPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
respPub, respPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
_ = initPriv
_ = respPriv
recipient, pubBytes, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc, respSess, err := EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatal(err)
}
initSess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatal(err)
}
if err := initSess.BindIdentities(initPub, respPub); err != nil {
t.Fatalf("initiator BindIdentities: %v", err)
}
if err := respSess.BindIdentities(respPub, initPub); err != nil {
t.Fatalf("responder BindIdentities: %v", err)
}
plaintext := []byte("bound channel")
nonce := make([]byte, initSess.Send.NonceSize())
ct := initSess.Send.Seal(nil, nonce, plaintext, nil)
got, err := respSess.Recv.Open(nil, nonce, ct, nil)
if err != nil {
t.Fatalf("bound channel initiator->responder failed: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatal("bound channel plaintext mismatch")
}
ct2 := respSess.Send.Seal(nil, nonce, plaintext, nil)
got2, err := initSess.Recv.Open(nil, nonce, ct2, nil)
if err != nil {
t.Fatalf("bound channel responder->initiator failed: %v", err)
}
if !bytes.Equal(got2, plaintext) {
t.Fatal("bound channel reverse plaintext mismatch")
}
}
func TestTransportHandshake_BindIdentities_MismatchFailsToDecrypt(t *testing.T) {
initPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
respPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
wrongPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
recipient, pubBytes, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc, respSess, err := EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatal(err)
}
initSess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatal(err)
}
// Initiator binds with the correct pair; responder binds against a
// different "initiator" identity (e.g. a MITM impersonating). The bound
// keys must diverge so traffic fails to authenticate.
if err := initSess.BindIdentities(initPub, respPub); err != nil {
t.Fatal(err)
}
if err := respSess.BindIdentities(respPub, wrongPub); err != nil {
t.Fatal(err)
}
nonce := make([]byte, initSess.Send.NonceSize())
ct := initSess.Send.Seal(nil, nonce, []byte("should not decrypt"), nil)
if _, err := respSess.Recv.Open(nil, nonce, ct, nil); err == nil {
t.Fatal("identity-bound channels with mismatched bindings must not decrypt each other")
}
}
func TestTransportHandshake_TranscriptCommitsToHandshake(t *testing.T) {
r1, pub1, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc1, sess1, err := EncapsulateToTransport(pub1)
if err != nil {
t.Fatal(err)
}
sessR1, err := r1.Decapsulate(enc1)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(sess1.Transcript(), sessR1.Transcript()) {
t.Fatal("paired sessions must agree on transcript")
}
// A second handshake must produce a different transcript even though
// the protocol version and ciphersuite are unchanged.
_, pub2, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
_, sess2, err := EncapsulateToTransport(pub2)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(sess1.Transcript(), sess2.Transcript()) {
t.Fatal("distinct handshakes must produce distinct transcripts")
}
}
func TestEncapsulateToTransport_RejectsBadInput(t *testing.T) {
if _, _, err := EncapsulateToTransport(nil); err == nil {
t.Fatal("expected error for empty peer public key")
}
if _, _, err := EncapsulateToTransport([]byte{0x01, 0x02, 0x03}); err == nil {
t.Fatal("expected error for malformed peer public key")
}
}
func TestTransportRecipient_Decapsulate_BadInput(t *testing.T) {
recipient, _, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
if _, err := recipient.Decapsulate(nil); err == nil {
t.Fatal("expected error for empty encapsulation")
}
if _, err := recipient.Decapsulate([]byte{0xde, 0xad}); err == nil {
t.Fatal("expected error for malformed encapsulation")
}
}
func TestTransportSession_BindIdentities_RejectsBadKeyLengths(t *testing.T) {
recipient, pubBytes, err := NewTransportRecipient()
if err != nil {
t.Fatal(err)
}
enc, _, err := EncapsulateToTransport(pubBytes)
if err != nil {
t.Fatal(err)
}
sess, err := recipient.Decapsulate(enc)
if err != nil {
t.Fatal(err)
}
good, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if err := sess.BindIdentities(make([]byte, 16), good); err == nil {
t.Fatal("expected error for short self key")
}
if err := sess.BindIdentities(good, make([]byte, 16)); err == nil {
t.Fatal("expected error for short peer key")
}
}
+3 -3
View File
@@ -13,11 +13,11 @@ message SignedMessage {
message PublicKey {
string keyid = 1 [json_name="keyId"]; // a unique identifier generated as the SHA256 of the public key.
string ecdsa_pub = 2 [json_name="ecdsaPub"];
string ed25519pub = 2 [json_name="ed25519pub"]; // raw base64-encoded ed25519 public key.
}
message PrivateKey {
string keyid = 1 [json_name="keyId"]; // a unique identifier generated as the SHA256 of the public key
string ecdsa_priv = 2 [json_name="ecdsaPriv"];
string ecdsa_pub = 3 [json_name="ecdsaPub"];
string ed25519priv = 2 [json_name="ed25519priv"]; // raw base64-encoded ed25519 private key seed.
string ed25519pub = 3 [json_name="ed25519pub"]; // raw base64-encoded ed25519 public key.
}
+15 -6
View File
@@ -148,7 +148,8 @@ message SyncStreamItem {
}
// SyncActionEncrypted wraps an encrypted SyncStreamItem.
// After ECDH key exchange, all subsequent messages are sent inside this envelope.
// After the post-quantum KEM handshake, all subsequent messages are sent
// inside this envelope.
message SyncActionEncrypted {
bytes nonce = 1; // 12-byte GCM nonce
bytes ciphertext = 2; // AES-256-GCM(serialized SyncStreamItem)
@@ -222,11 +223,19 @@ message SyncStreamItem {
int64 delay_ms = 1;
}
// SyncEstablishSharedSecret is exchanged immediately after the handshake.
// Each side sends an ephemeral ECDH P-256 public key. Both sides then perform
// ECDH to derive a shared AES-256-GCM session key. All subsequent messages
// must be wrapped in SyncActionEncrypted.
// SyncEstablishSharedSecret is exchanged immediately after the connection
// is opened. The initiator (client) sends kem_public_key. The responder
// (server) replies with kem_encapsulation. Both sides then derive a shared
// AES-256-GCM session key via the HPKE Export interface. All subsequent
// messages must be wrapped in SyncActionEncrypted.
//
// The KEM is the post-quantum hybrid ML-KEM-1024 + ECDH-P384 (HPKE
// ciphersuite ML-KEM-1024-P384 / KEM ID 0x0050, RFC 9180 + the IETF hybrid
// KEM drafts). KDF is HKDF-SHA256, AEAD is AES-256-GCM. Peers must use
// protocol_version=1; mismatched versions abort the connection.
message SyncEstablishSharedSecret {
bytes ecdh_public_key = 1; // raw ECDH P-256 public key bytes
uint32 protocol_version = 1; // current: 1
bytes kem_public_key = 2; // set by initiator
bytes kem_encapsulation = 3; // set by responder
}
}
+13 -7
View File
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file v1/crypto.proto.
*/
export const file_v1_crypto: GenFile = /*@__PURE__*/
fileDesc("Cg92MS9jcnlwdG8ucHJvdG8SAnYxIlsKDVNpZ25lZE1lc3NhZ2USDQoFa2V5aWQYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIRCglzaWduYXR1cmUYAyABKAwSFwoPdGltZXN0YW1wTWlsbGlzGAQgASgDIjQKCVB1YmxpY0tleRIUCgVrZXlpZBgBIAEoCVIFa2V5SWQSEQoJZWNkc2FfcHViGAIgASgJIkkKClByaXZhdGVLZXkSFAoFa2V5aWQYASABKAlSBWtleUlkEhIKCmVjZHNhX3ByaXYYAiABKAkSEQoJZWNkc2FfcHViGAMgASgJQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z");
fileDesc("Cg92MS9jcnlwdG8ucHJvdG8SAnYxIlsKDVNpZ25lZE1lc3NhZ2USDQoFa2V5aWQYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIRCglzaWduYXR1cmUYAyABKAwSFwoPdGltZXN0YW1wTWlsbGlzGAQgASgDIjUKCVB1YmxpY0tleRIUCgVrZXlpZBgBIAEoCVIFa2V5SWQSEgoKZWQyNTUxOXB1YhgCIAEoCSJLCgpQcml2YXRlS2V5EhQKBWtleWlkGAEgASgJUgVrZXlJZBITCgtlZDI1NTE5cHJpdhgCIAEoCRISCgplZDI1NTE5cHViGAMgASgJQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z");
/**
* @generated from message v1.SignedMessage
@@ -64,9 +64,11 @@ export type PublicKey = Message<"v1.PublicKey"> & {
keyid: string;
/**
* @generated from field: string ecdsa_pub = 2;
* raw base64-encoded ed25519 public key.
*
* @generated from field: string ed25519pub = 2;
*/
ecdsaPub: string;
ed25519pub: string;
};
/**
@@ -88,14 +90,18 @@ export type PrivateKey = Message<"v1.PrivateKey"> & {
keyid: string;
/**
* @generated from field: string ecdsa_priv = 2;
* raw base64-encoded ed25519 private key seed.
*
* @generated from field: string ed25519priv = 2;
*/
ecdsaPriv: string;
ed25519priv: string;
/**
* @generated from field: string ecdsa_pub = 3;
* raw base64-encoded ed25519 public key.
*
* @generated from field: string ed25519pub = 3;
*/
ecdsaPub: string;
ed25519pub: string;
};
/**
File diff suppressed because one or more lines are too long