diff --git a/gen/go/v1/crypto.pb.go b/gen/go/v1/crypto.pb.go index 86c6d9d1..78679cd8 100644 --- a/gen/go/v1/crypto.pb.go +++ b/gen/go/v1/crypto.pb.go @@ -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 diff --git a/gen/go/v1sync/syncservice.pb.go b/gen/go/v1sync/syncservice.pb.go index 87295d4a..500c8a17 100644 --- a/gen/go/v1sync/syncservice.pb.go +++ b/gen/go/v1sync/syncservice.pb.go @@ -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" + diff --git a/go.mod b/go.mod index 62e51fed..f7d8d28d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/garethgeorge/backrest -go 1.25 +go 1.26 require ( al.essio.dev/pkg/shellescape v1.6.0 diff --git a/internal/api/syncapi/cmdstreamutil.go b/internal/api/syncapi/cmdstreamutil.go index 4c893dc5..2f07fda8 100644 --- a/internal/api/syncapi/cmdstreamutil.go +++ b/internal/api/syncapi/cmdstreamutil.go @@ -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 } diff --git a/internal/api/syncapi/encryption.go b/internal/api/syncapi/encryption.go index c08c2c7e..15cde456 100644 --- a/internal/api/syncapi/encryption.go +++ b/internal/api/syncapi/encryption.go @@ -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 } diff --git a/internal/api/syncapi/encryption_test.go b/internal/api/syncapi/encryption_test.go index 15a6f57d..aca8598a 100644 --- a/internal/api/syncapi/encryption_test.go +++ b/internal/api/syncapi/encryption_test.go @@ -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") + } +} diff --git a/internal/api/syncapi/syncclient.go b/internal/api/syncapi/syncclient.go index e76dbd71..67deb98f 100644 --- a/internal/api/syncapi/syncclient.go +++ b/internal/api/syncapi/syncclient.go @@ -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 diff --git a/internal/api/syncapi/syncserver.go b/internal/api/syncapi/syncserver.go index d8d14f32..0d4fe082 100644 --- a/internal/api/syncapi/syncserver.go +++ b/internal/api/syncapi/syncserver.go @@ -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) { diff --git a/internal/config/migrations/006ed25519identity.go b/internal/config/migrations/006ed25519identity.go new file mode 100644 index 00000000..5dfffb67 --- /dev/null +++ b/internal/config/migrations/006ed25519identity.go @@ -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 +} diff --git a/internal/config/migrations/migrations.go b/internal/config/migrations/migrations.go index 41a63b86..bb29b598 100644 --- a/internal/config/migrations/migrations.go +++ b/internal/config/migrations/migrations.go @@ -14,6 +14,7 @@ var migrations = []*func(*v1.Config) error{ &migration003RelativeScheduling, &migration004RepoGuid, &migration005CheckRepoPasswords, + &migration006Ed25519Identity, } var CurrentVersion = int32(len(migrations)) diff --git a/internal/config/networksanitize_test.go b/internal/config/networksanitize_test.go index 341e4e4a..72cca5d0 100644 --- a/internal/config/networksanitize_test.go +++ b/internal/config/networksanitize_test.go @@ -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", }, }, }, diff --git a/internal/cryptoutil/ecdh.go b/internal/cryptoutil/ecdh.go deleted file mode 100644 index 87f9b707..00000000 --- a/internal/cryptoutil/ecdh.go +++ /dev/null @@ -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 -} diff --git a/internal/cryptoutil/ecdh_test.go b/internal/cryptoutil/ecdh_test.go deleted file mode 100644 index 42a743e8..00000000 --- a/internal/cryptoutil/ecdh_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/cryptoutil/identity.go b/internal/cryptoutil/identity.go index 307d3726..2da9f395 100644 --- a/internal/cryptoutil/identity.go +++ b/internal/cryptoutil/identity.go @@ -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[:]) } diff --git a/internal/cryptoutil/identity_test.go b/internal/cryptoutil/identity_test.go index cb19ebd5..9cd091e5 100644 --- a/internal/cryptoutil/identity_test.go +++ b/internal/cryptoutil/identity_test.go @@ -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) { diff --git a/internal/cryptoutil/transport.go b/internal/cryptoutil/transport.go new file mode 100644 index 00000000..f7a151ba --- /dev/null +++ b/internal/cryptoutil/transport.go @@ -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 +} diff --git a/internal/cryptoutil/transport_test.go b/internal/cryptoutil/transport_test.go new file mode 100644 index 00000000..26b3b96b --- /dev/null +++ b/internal/cryptoutil/transport_test.go @@ -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") + } +} diff --git a/proto/v1/crypto.proto b/proto/v1/crypto.proto index 778fd15a..9427b065 100644 --- a/proto/v1/crypto.proto +++ b/proto/v1/crypto.proto @@ -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. } diff --git a/proto/v1sync/syncservice.proto b/proto/v1sync/syncservice.proto index 3833af09..6c102933 100644 --- a/proto/v1sync/syncservice.proto +++ b/proto/v1sync/syncservice.proto @@ -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 } } diff --git a/webui/gen/ts/v1/crypto_pb.ts b/webui/gen/ts/v1/crypto_pb.ts index 302bdc9b..3cf05bdc 100644 --- a/webui/gen/ts/v1/crypto_pb.ts +++ b/webui/gen/ts/v1/crypto_pb.ts @@ -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; }; /** diff --git a/webui/gen/ts/v1sync/syncservice_pb.ts b/webui/gen/ts/v1sync/syncservice_pb.ts index 8f7ae557..6b6752b4 100644 --- a/webui/gen/ts/v1sync/syncservice_pb.ts +++ b/webui/gen/ts/v1sync/syncservice_pb.ts @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1sync/syncservice.proto. */ export const file_v1sync_syncservice: GenFile = /*@__PURE__*/ - fileDesc("Chh2MXN5bmMvc3luY3NlcnZpY2UucHJvdG8SBnYxc3luYyIrChZTeW5jU3RhdGVTdHJlYW1SZXF1ZXN0EhEKCXN1YnNjcmliZRgBIAEoCCKbAgoJUGVlclN0YXRlEhgKEHBlZXJfaW5zdGFuY2VfaWQYASABKAkSEgoKcGVlcl9rZXlpZBgCIAEoCRImCgVzdGF0ZRgDIAEoDjIXLnYxc3luYy5Db25uZWN0aW9uU3RhdGUSFgoOc3RhdHVzX21lc3NhZ2UYBCABKAkSKQoLa25vd25fcGxhbnMYBSADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhEikKC2tub3duX3JlcG9zGAYgAygLMhQudjFzeW5jLlJlcG9NZXRhZGF0YRIrCg1yZW1vdGVfY29uZmlnGAcgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxIdChVsYXN0X2hlYXJ0YmVhdF9taWxsaXMYCCABKAMiPQoTQXV0aGVudGljYXRlUmVxdWVzdBImCgtpbnN0YW5jZV9pZBgBIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2UiPgocR2V0T3BlcmF0aW9uTWV0YWRhdGFSZXNwb25zZRIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDIl0KDExvZ0RhdGFFbnRyeRIOCgZsb2dfaWQYASABKAkSEgoKb3duZXJfb3BpZBgCIAEoAxIaChJleHBpcmF0aW9uX3RzX3VuaXgYAyABKAMSDQoFY2h1bmsYBCABKAwiaAocU2V0QXZhaWxhYmxlUmVzb3VyY2VzUmVxdWVzdBIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5QbGFuTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUmVwb01ldGFkYXRhIigKDFJlcG9NZXRhZGF0YRIKCgJpZBgBIAEoCRIMCgRndWlkGAIgASgJIhoKDFBsYW5NZXRhZGF0YRIKCgJpZBgBIAEoCSJ2ChBTZXRDb25maWdSZXF1ZXN0EhcKBXBsYW5zGAEgAygLMggudjEuUGxhbhIXCgVyZXBvcxgCIAMoCzIILnYxLlJlcG8SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCSKWAQocU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVxdWVzdBISCgpwZWVyX2tleWlkGAEgASgJEhcKBXJlcG9zGAIgAygLMggudjEuUmVwbxIXCgVwbGFucxgDIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAQgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgFIAMoCSIfCh1TZXRSZW1vdGVDbGllbnRDb25maWdSZXNwb25zZSJgCgxSZW1vdGVDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgCIAEoBRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuIl8KEkF1dGhvcml6YXRpb25Ub2tlbhIhCgpwdWJsaWNfa2V5GAEgASgLMg0udjEuUHVibGljS2V5EiYKC2luc3RhbmNlX2lkGAIgASgLMhEudjEuU2lnbmVkTWVzc2FnZSKeEgoOU3luY1N0cmVhbUl0ZW0SKwoOc2lnbmVkX21lc3NhZ2UYASABKAsyES52MS5TaWduZWRNZXNzYWdlSAASPwoJaGFuZHNoYWtlGAMgASgLMioudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25IYW5kc2hha2VIABI/CgloZWFydGJlYXQYBCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkhlYXJ0YmVhdEgAElAKEm9wZXJhdGlvbl9tYW5pZmVzdBgUIAEoCzIyLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uT3BlcmF0aW9uTWFuaWZlc3RIABJQChJyZWNlaXZlX29wZXJhdGlvbnMYFSABKAsyMi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zSAASVwoWcmVxdWVzdF9vcGVyYXRpb25fZGF0YRgWIAEoCzI1LnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdE9wZXJhdGlvbkRhdGFIABJICg5yZWNlaXZlX2NvbmZpZxgXIAEoCzIuLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVjZWl2ZUNvbmZpZ0gAEkAKCnNldF9jb25maWcYGCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblNldENvbmZpZ0gAEk4KEXJlcXVlc3RfcmVzb3VyY2VzGBkgASgLMjEudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25SZXF1ZXN0UmVzb3VyY2VzSAASTgoRcmVjZWl2ZV9yZXNvdXJjZXMYGiABKAsyMS52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVSZXNvdXJjZXNIABJCCgtyZXF1ZXN0X2xvZxgeIAEoCzIrLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdExvZ0gAEksKEHJlY2VpdmVfbG9nX2RhdGEYHyABKAsyLy52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVMb2dEYXRhSAASPgoIdGhyb3R0bGUY6AcgASgLMikudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25UaHJvdHRsZUgAElMKF2VzdGFibGlzaF9zaGFyZWRfc2VjcmV0GAIgASgLMjAudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXRIABI/CgllbmNyeXB0ZWQYBSABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkVuY3J5cHRlZEgAGpIBChNTeW5jQWN0aW9uSGFuZHNoYWtlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAMSIQoKcHVibGljX2tleRgCIAEoCzINLnYxLlB1YmxpY0tleRImCgtpbnN0YW5jZV9pZBgDIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2USFgoOcGFpcmluZ19zZWNyZXQYBCABKAkaOAoTU3luY0FjdGlvbkVuY3J5cHRlZBINCgVub25jZRgBIAEoDBISCgpjaXBoZXJ0ZXh0GAIgASgMGhUKE1N5bmNBY3Rpb25IZWFydGJlYXQaPwoXU3luY0FjdGlvblJlY2VpdmVDb25maWcSJAoGY29uZmlnGAEgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxp5ChNTeW5jQWN0aW9uU2V0Q29uZmlnEhcKBXJlcG9zGAEgAygLMggudjEuUmVwbxIXCgVwbGFucxgCIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCRocChpTeW5jQWN0aW9uUmVxdWVzdFJlc291cmNlcxpmChpTeW5jQWN0aW9uUmVjZWl2ZVJlc291cmNlcxIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5SZXBvTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhGigKFVN5bmNBY3Rpb25Db25uZWN0UmVwbxIPCgdyZXBvX2lkGAEgASgJGj0KG1N5bmNBY3Rpb25PcGVyYXRpb25NYW5pZmVzdBIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDGjAKHlN5bmNBY3Rpb25SZXF1ZXN0T3BlcmF0aW9uRGF0YRIOCgZvcF9pZHMYASADKAMaQAobU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zEiEKBWV2ZW50GAEgASgLMhIudjEuT3BlcmF0aW9uRXZlbnQaJgoUU3luY0FjdGlvblJlcXVlc3RMb2cSDgoGbG9nX2lkGAEgASgJGoABChhTeW5jQWN0aW9uUmVjZWl2ZUxvZ0RhdGESDgoGbG9nX2lkGAEgASgJEhIKCm93bmVyX29waWQYAiABKAMSGgoSZXhwaXJhdGlvbl90c191bml4GAMgASgDEg0KBWNodW5rGAQgASgMEhUKDWVycm9yX21lc3NhZ2UYBSABKAkaJgoSU3luY0FjdGlvblRocm90dGxlEhAKCGRlbGF5X21zGAEgASgDGjQKGVN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXQSFwoPZWNkaF9wdWJsaWNfa2V5GAEgASgMIrQBChNSZXBvQ29ubmVjdGlvblN0YXRlEhwKGENPTk5FQ1RJT05fU1RBVEVfVU5LTk9XThAAEhwKGENPTk5FQ1RJT05fU1RBVEVfUEVORElORxABEh4KGkNPTk5FQ1RJT05fU1RBVEVfQ09OTkVDVEVEEAISIQodQ09OTkVDVElPTl9TVEFURV9VTkFVVEhPUklaRUQQAxIeChpDT05ORUNUSU9OX1NUQVRFX05PVF9GT1VORBAEQggKBmFjdGlvbiqcAgoPQ29ubmVjdGlvblN0YXRlEhwKGENPTk5FQ1RJT05fU1RBVEVfVU5LTk9XThAAEhwKGENPTk5FQ1RJT05fU1RBVEVfUEVORElORxABEh4KGkNPTk5FQ1RJT05fU1RBVEVfQ09OTkVDVEVEEAISIQodQ09OTkVDVElPTl9TVEFURV9ESVNDT05ORUNURUQQAxIfChtDT05ORUNUSU9OX1NUQVRFX1JFVFJZX1dBSVQQBBIfChtDT05ORUNUSU9OX1NUQVRFX0VSUk9SX0FVVEgQChIjCh9DT05ORUNUSU9OX1NUQVRFX0VSUk9SX1BST1RPQ09MEAsSIwofQ09OTkVDVElPTl9TVEFURV9FUlJPUl9JTlRFUk5BTBAMMlMKE0JhY2tyZXN0U3luY1NlcnZpY2USPAoEU3luYxIWLnYxc3luYy5TeW5jU3RyZWFtSXRlbRoWLnYxc3luYy5TeW5jU3RyZWFtSXRlbSIAKAEwATLUAQoYQmFja3Jlc3RTeW5jU3RhdGVTZXJ2aWNlElAKF0dldFBlZXJTeW5jU3RhdGVzU3RyZWFtEh4udjFzeW5jLlN5bmNTdGF0ZVN0cmVhbVJlcXVlc3QaES52MXN5bmMuUGVlclN0YXRlIgAwARJmChVTZXRSZW1vdGVDbGllbnRDb25maWcSJC52MXN5bmMuU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVxdWVzdBolLnYxc3luYy5TZXRSZW1vdGVDbGllbnRDb25maWdSZXNwb25zZSIAQjBaLmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MXN5bmNiBnByb3RvMw", [file_v1_config, file_v1_crypto, file_v1_restic, file_v1_service, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations, file_google_protobuf_any]); + fileDesc("Chh2MXN5bmMvc3luY3NlcnZpY2UucHJvdG8SBnYxc3luYyIrChZTeW5jU3RhdGVTdHJlYW1SZXF1ZXN0EhEKCXN1YnNjcmliZRgBIAEoCCKbAgoJUGVlclN0YXRlEhgKEHBlZXJfaW5zdGFuY2VfaWQYASABKAkSEgoKcGVlcl9rZXlpZBgCIAEoCRImCgVzdGF0ZRgDIAEoDjIXLnYxc3luYy5Db25uZWN0aW9uU3RhdGUSFgoOc3RhdHVzX21lc3NhZ2UYBCABKAkSKQoLa25vd25fcGxhbnMYBSADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhEikKC2tub3duX3JlcG9zGAYgAygLMhQudjFzeW5jLlJlcG9NZXRhZGF0YRIrCg1yZW1vdGVfY29uZmlnGAcgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxIdChVsYXN0X2hlYXJ0YmVhdF9taWxsaXMYCCABKAMiPQoTQXV0aGVudGljYXRlUmVxdWVzdBImCgtpbnN0YW5jZV9pZBgBIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2UiPgocR2V0T3BlcmF0aW9uTWV0YWRhdGFSZXNwb25zZRIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDIl0KDExvZ0RhdGFFbnRyeRIOCgZsb2dfaWQYASABKAkSEgoKb3duZXJfb3BpZBgCIAEoAxIaChJleHBpcmF0aW9uX3RzX3VuaXgYAyABKAMSDQoFY2h1bmsYBCABKAwiaAocU2V0QXZhaWxhYmxlUmVzb3VyY2VzUmVxdWVzdBIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5QbGFuTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUmVwb01ldGFkYXRhIigKDFJlcG9NZXRhZGF0YRIKCgJpZBgBIAEoCRIMCgRndWlkGAIgASgJIhoKDFBsYW5NZXRhZGF0YRIKCgJpZBgBIAEoCSJ2ChBTZXRDb25maWdSZXF1ZXN0EhcKBXBsYW5zGAEgAygLMggudjEuUGxhbhIXCgVyZXBvcxgCIAMoCzIILnYxLlJlcG8SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCSKWAQocU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVxdWVzdBISCgpwZWVyX2tleWlkGAEgASgJEhcKBXJlcG9zGAIgAygLMggudjEuUmVwbxIXCgVwbGFucxgDIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAQgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgFIAMoCSIfCh1TZXRSZW1vdGVDbGllbnRDb25maWdSZXNwb25zZSJgCgxSZW1vdGVDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgCIAEoBRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuIl8KEkF1dGhvcml6YXRpb25Ub2tlbhIhCgpwdWJsaWNfa2V5GAEgASgLMg0udjEuUHVibGljS2V5EiYKC2luc3RhbmNlX2lkGAIgASgLMhEudjEuU2lnbmVkTWVzc2FnZSLSEgoOU3luY1N0cmVhbUl0ZW0SKwoOc2lnbmVkX21lc3NhZ2UYASABKAsyES52MS5TaWduZWRNZXNzYWdlSAASPwoJaGFuZHNoYWtlGAMgASgLMioudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25IYW5kc2hha2VIABI/CgloZWFydGJlYXQYBCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkhlYXJ0YmVhdEgAElAKEm9wZXJhdGlvbl9tYW5pZmVzdBgUIAEoCzIyLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uT3BlcmF0aW9uTWFuaWZlc3RIABJQChJyZWNlaXZlX29wZXJhdGlvbnMYFSABKAsyMi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zSAASVwoWcmVxdWVzdF9vcGVyYXRpb25fZGF0YRgWIAEoCzI1LnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdE9wZXJhdGlvbkRhdGFIABJICg5yZWNlaXZlX2NvbmZpZxgXIAEoCzIuLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVjZWl2ZUNvbmZpZ0gAEkAKCnNldF9jb25maWcYGCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblNldENvbmZpZ0gAEk4KEXJlcXVlc3RfcmVzb3VyY2VzGBkgASgLMjEudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25SZXF1ZXN0UmVzb3VyY2VzSAASTgoRcmVjZWl2ZV9yZXNvdXJjZXMYGiABKAsyMS52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVSZXNvdXJjZXNIABJCCgtyZXF1ZXN0X2xvZxgeIAEoCzIrLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdExvZ0gAEksKEHJlY2VpdmVfbG9nX2RhdGEYHyABKAsyLy52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVMb2dEYXRhSAASPgoIdGhyb3R0bGUY6AcgASgLMikudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25UaHJvdHRsZUgAElMKF2VzdGFibGlzaF9zaGFyZWRfc2VjcmV0GAIgASgLMjAudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXRIABI/CgllbmNyeXB0ZWQYBSABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkVuY3J5cHRlZEgAGpIBChNTeW5jQWN0aW9uSGFuZHNoYWtlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAMSIQoKcHVibGljX2tleRgCIAEoCzINLnYxLlB1YmxpY0tleRImCgtpbnN0YW5jZV9pZBgDIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2USFgoOcGFpcmluZ19zZWNyZXQYBCABKAkaOAoTU3luY0FjdGlvbkVuY3J5cHRlZBINCgVub25jZRgBIAEoDBISCgpjaXBoZXJ0ZXh0GAIgASgMGhUKE1N5bmNBY3Rpb25IZWFydGJlYXQaPwoXU3luY0FjdGlvblJlY2VpdmVDb25maWcSJAoGY29uZmlnGAEgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxp5ChNTeW5jQWN0aW9uU2V0Q29uZmlnEhcKBXJlcG9zGAEgAygLMggudjEuUmVwbxIXCgVwbGFucxgCIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCRocChpTeW5jQWN0aW9uUmVxdWVzdFJlc291cmNlcxpmChpTeW5jQWN0aW9uUmVjZWl2ZVJlc291cmNlcxIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5SZXBvTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhGigKFVN5bmNBY3Rpb25Db25uZWN0UmVwbxIPCgdyZXBvX2lkGAEgASgJGj0KG1N5bmNBY3Rpb25PcGVyYXRpb25NYW5pZmVzdBIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDGjAKHlN5bmNBY3Rpb25SZXF1ZXN0T3BlcmF0aW9uRGF0YRIOCgZvcF9pZHMYASADKAMaQAobU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zEiEKBWV2ZW50GAEgASgLMhIudjEuT3BlcmF0aW9uRXZlbnQaJgoUU3luY0FjdGlvblJlcXVlc3RMb2cSDgoGbG9nX2lkGAEgASgJGoABChhTeW5jQWN0aW9uUmVjZWl2ZUxvZ0RhdGESDgoGbG9nX2lkGAEgASgJEhIKCm93bmVyX29waWQYAiABKAMSGgoSZXhwaXJhdGlvbl90c191bml4GAMgASgDEg0KBWNodW5rGAQgASgMEhUKDWVycm9yX21lc3NhZ2UYBSABKAkaJgoSU3luY0FjdGlvblRocm90dGxlEhAKCGRlbGF5X21zGAEgASgDGmgKGVN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXQSGAoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoDRIWCg5rZW1fcHVibGljX2tleRgCIAEoDBIZChFrZW1fZW5jYXBzdWxhdGlvbhgDIAEoDCK0AQoTUmVwb0Nvbm5lY3Rpb25TdGF0ZRIcChhDT05ORUNUSU9OX1NUQVRFX1VOS05PV04QABIcChhDT05ORUNUSU9OX1NUQVRFX1BFTkRJTkcQARIeChpDT05ORUNUSU9OX1NUQVRFX0NPTk5FQ1RFRBACEiEKHUNPTk5FQ1RJT05fU1RBVEVfVU5BVVRIT1JJWkVEEAMSHgoaQ09OTkVDVElPTl9TVEFURV9OT1RfRk9VTkQQBEIICgZhY3Rpb24qnAIKD0Nvbm5lY3Rpb25TdGF0ZRIcChhDT05ORUNUSU9OX1NUQVRFX1VOS05PV04QABIcChhDT05ORUNUSU9OX1NUQVRFX1BFTkRJTkcQARIeChpDT05ORUNUSU9OX1NUQVRFX0NPTk5FQ1RFRBACEiEKHUNPTk5FQ1RJT05fU1RBVEVfRElTQ09OTkVDVEVEEAMSHwobQ09OTkVDVElPTl9TVEFURV9SRVRSWV9XQUlUEAQSHwobQ09OTkVDVElPTl9TVEFURV9FUlJPUl9BVVRIEAoSIwofQ09OTkVDVElPTl9TVEFURV9FUlJPUl9QUk9UT0NPTBALEiMKH0NPTk5FQ1RJT05fU1RBVEVfRVJST1JfSU5URVJOQUwQDDJTChNCYWNrcmVzdFN5bmNTZXJ2aWNlEjwKBFN5bmMSFi52MXN5bmMuU3luY1N0cmVhbUl0ZW0aFi52MXN5bmMuU3luY1N0cmVhbUl0ZW0iACgBMAEy1AEKGEJhY2tyZXN0U3luY1N0YXRlU2VydmljZRJQChdHZXRQZWVyU3luY1N0YXRlc1N0cmVhbRIeLnYxc3luYy5TeW5jU3RhdGVTdHJlYW1SZXF1ZXN0GhEudjFzeW5jLlBlZXJTdGF0ZSIAMAESZgoVU2V0UmVtb3RlQ2xpZW50Q29uZmlnEiQudjFzeW5jLlNldFJlbW90ZUNsaWVudENvbmZpZ1JlcXVlc3QaJS52MXN5bmMuU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVzcG9uc2UiAEIwWi5naXRodWIuY29tL2dhcmV0aGdlb3JnZS9iYWNrcmVzdC9nZW4vZ28vdjFzeW5jYgZwcm90bzM", [file_v1_config, file_v1_crypto, file_v1_restic, file_v1_service, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations, file_google_protobuf_any]); /** * @generated from message v1sync.SyncStateStreamRequest @@ -561,7 +561,8 @@ export const SyncStreamItem_SyncActionHandshakeSchema: GenMessage & { /** - * raw ECDH P-256 public key bytes + * current: 1 * - * @generated from field: bytes ecdh_public_key = 1; + * @generated from field: uint32 protocol_version = 1; */ - ecdhPublicKey: Uint8Array; + protocolVersion: number; + + /** + * set by initiator + * + * @generated from field: bytes kem_public_key = 2; + */ + kemPublicKey: Uint8Array; + + /** + * set by responder + * + * @generated from field: bytes kem_encapsulation = 3; + */ + kemEncapsulation: Uint8Array; }; /**