mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-07-28 21:00:59 +00:00
use hybrid ed25519 + pq crypto signatures for session authentication
This commit is contained in:
+19
-16
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
module github.com/garethgeorge/backrest
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
al.essio.dev/pkg/shellescape v1.6.0
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var migrations = []*func(*v1.Config) error{
|
||||
&migration003RelativeScheduling,
|
||||
&migration004RepoGuid,
|
||||
&migration005CheckRepoPasswords,
|
||||
&migration006Ed25519Identity,
|
||||
}
|
||||
|
||||
var CurrentVersion = int32(len(migrations))
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user