mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-25 10:26:35 +00:00
fix: post PQ safe transport for syncapi (#1216)
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
|
||||
|
||||
+280
-121
@@ -918,6 +918,8 @@ type SyncStreamItem struct {
|
||||
// *SyncStreamItem_RequestLog
|
||||
// *SyncStreamItem_ReceiveLogData
|
||||
// *SyncStreamItem_Throttle
|
||||
// *SyncStreamItem_EstablishSharedSecret
|
||||
// *SyncStreamItem_Encrypted
|
||||
Action isSyncStreamItem_Action `protobuf_oneof:"action"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1077,6 +1079,24 @@ func (x *SyncStreamItem) GetThrottle() *SyncStreamItem_SyncActionThrottle {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem) GetEstablishSharedSecret() *SyncStreamItem_SyncEstablishSharedSecret {
|
||||
if x != nil {
|
||||
if x, ok := x.Action.(*SyncStreamItem_EstablishSharedSecret); ok {
|
||||
return x.EstablishSharedSecret
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem) GetEncrypted() *SyncStreamItem_SyncActionEncrypted {
|
||||
if x != nil {
|
||||
if x, ok := x.Action.(*SyncStreamItem_Encrypted); ok {
|
||||
return x.Encrypted
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isSyncStreamItem_Action interface {
|
||||
isSyncStreamItem_Action()
|
||||
}
|
||||
@@ -1133,6 +1153,14 @@ type SyncStreamItem_Throttle struct {
|
||||
Throttle *SyncStreamItem_SyncActionThrottle `protobuf:"bytes,1000,opt,name=throttle,proto3,oneof"`
|
||||
}
|
||||
|
||||
type SyncStreamItem_EstablishSharedSecret struct {
|
||||
EstablishSharedSecret *SyncStreamItem_SyncEstablishSharedSecret `protobuf:"bytes,2,opt,name=establish_shared_secret,json=establishSharedSecret,proto3,oneof"`
|
||||
}
|
||||
|
||||
type SyncStreamItem_Encrypted struct {
|
||||
Encrypted *SyncStreamItem_SyncActionEncrypted `protobuf:"bytes,5,opt,name=encrypted,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*SyncStreamItem_SignedMessage) isSyncStreamItem_Action() {}
|
||||
|
||||
func (*SyncStreamItem_Handshake) isSyncStreamItem_Action() {}
|
||||
@@ -1159,12 +1187,43 @@ func (*SyncStreamItem_ReceiveLogData) isSyncStreamItem_Action() {}
|
||||
|
||||
func (*SyncStreamItem_Throttle) isSyncStreamItem_Action() {}
|
||||
|
||||
func (*SyncStreamItem_EstablishSharedSecret) isSyncStreamItem_Action() {}
|
||||
|
||||
func (*SyncStreamItem_Encrypted) isSyncStreamItem_Action() {}
|
||||
|
||||
// SyncActionHandshake is the first message sent by each peer over the
|
||||
// post-quantum encrypted channel. It carries the sender's long-term
|
||||
// ed25519 identity, its instance ID, and a single signature that binds
|
||||
// the identity to *this* transport session.
|
||||
//
|
||||
// The signature covers a domain-separated hash of:
|
||||
//
|
||||
// "backrest-sync-handshake/v1\x00"
|
||||
// || protocol_version (8 bytes BE)
|
||||
// || LP(instance_id)
|
||||
// || LP(pairing_secret)
|
||||
// || LP(transport transcript)
|
||||
//
|
||||
// where LP(x) = 4-byte BE length prefix || x, and the transport transcript
|
||||
// is cryptoutil.TransportSession.Transcript() — a hash that commits to
|
||||
// the ephemeral KEM messages of this connection.
|
||||
//
|
||||
// The transcript binding is what defeats a MITM that completes a separate
|
||||
// KEM with each side: each leg has a different transcript, and the
|
||||
// legitimate peer's signature only commits to its own transcript, so the
|
||||
// attacker cannot forward a usable signature to either side.
|
||||
//
|
||||
// Receivers MUST recompute the transcript locally from their TransportSession
|
||||
// and reject the handshake if the signature does not verify against
|
||||
// public_key. There is no timestamp because freshness is provided by the
|
||||
// ephemeral KEM, not by clock comparison.
|
||||
type SyncStreamItem_SyncActionHandshake struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ProtocolVersion int64 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
|
||||
PublicKey *v1.PublicKey `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
|
||||
InstanceId *v1.SignedMessage `protobuf:"bytes,3,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"`
|
||||
PairingSecret string `protobuf:"bytes,4,opt,name=pairing_secret,json=pairingSecret,proto3" json:"pairing_secret,omitempty"` // optional one-time secret from a pairing token, used to auto-authorize a new client
|
||||
PublicKey *v1.PublicKey `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` // sender's long-term ed25519 identity
|
||||
InstanceId string `protobuf:"bytes,3,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` // covered by signature below
|
||||
PairingSecret string `protobuf:"bytes,4,opt,name=pairing_secret,json=pairingSecret,proto3" json:"pairing_secret,omitempty"` // optional pairing token; covered by signature below
|
||||
Signature []byte `protobuf:"bytes,5,opt,name=signature,proto3" json:"signature,omitempty"` // ed25519(public_key, H(handshake bind input))
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1213,11 +1272,11 @@ func (x *SyncStreamItem_SyncActionHandshake) GetPublicKey() *v1.PublicKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionHandshake) GetInstanceId() *v1.SignedMessage {
|
||||
func (x *SyncStreamItem_SyncActionHandshake) GetInstanceId() string {
|
||||
if x != nil {
|
||||
return x.InstanceId
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionHandshake) GetPairingSecret() string {
|
||||
@@ -1227,6 +1286,68 @@ func (x *SyncStreamItem_SyncActionHandshake) GetPairingSecret() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionHandshake) GetSignature() []byte {
|
||||
if x != nil {
|
||||
return x.Signature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncActionEncrypted wraps an encrypted SyncStreamItem.
|
||||
// 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
|
||||
Ciphertext []byte `protobuf:"bytes,2,opt,name=ciphertext,proto3" json:"ciphertext,omitempty"` // AES-256-GCM(serialized SyncStreamItem)
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionEncrypted) Reset() {
|
||||
*x = SyncStreamItem_SyncActionEncrypted{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionEncrypted) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStreamItem_SyncActionEncrypted) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionEncrypted) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionEncrypted.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionEncrypted) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 1}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionEncrypted) GetNonce() []byte {
|
||||
if x != nil {
|
||||
return x.Nonce
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionEncrypted) GetCiphertext() []byte {
|
||||
if x != nil {
|
||||
return x.Ciphertext
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncActionHeartbeat is sent periodically to keep the connection alive.
|
||||
type SyncStreamItem_SyncActionHeartbeat struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -1236,7 +1357,7 @@ type SyncStreamItem_SyncActionHeartbeat struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionHeartbeat) Reset() {
|
||||
*x = SyncStreamItem_SyncActionHeartbeat{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[15]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1248,7 +1369,7 @@ func (x *SyncStreamItem_SyncActionHeartbeat) String() string {
|
||||
func (*SyncStreamItem_SyncActionHeartbeat) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionHeartbeat) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[15]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1261,7 +1382,7 @@ func (x *SyncStreamItem_SyncActionHeartbeat) ProtoReflect() protoreflect.Message
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionHeartbeat.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionHeartbeat) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 1}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 2}
|
||||
}
|
||||
|
||||
type SyncStreamItem_SyncActionReceiveConfig struct {
|
||||
@@ -1273,7 +1394,7 @@ type SyncStreamItem_SyncActionReceiveConfig struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveConfig) Reset() {
|
||||
*x = SyncStreamItem_SyncActionReceiveConfig{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[16]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1285,7 +1406,7 @@ func (x *SyncStreamItem_SyncActionReceiveConfig) String() string {
|
||||
func (*SyncStreamItem_SyncActionReceiveConfig) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[16]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1298,7 +1419,7 @@ func (x *SyncStreamItem_SyncActionReceiveConfig) ProtoReflect() protoreflect.Mes
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionReceiveConfig.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionReceiveConfig) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 2}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 3}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveConfig) GetConfig() *RemoteConfig {
|
||||
@@ -1320,7 +1441,7 @@ type SyncStreamItem_SyncActionSetConfig struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionSetConfig) Reset() {
|
||||
*x = SyncStreamItem_SyncActionSetConfig{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[17]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1332,7 +1453,7 @@ func (x *SyncStreamItem_SyncActionSetConfig) String() string {
|
||||
func (*SyncStreamItem_SyncActionSetConfig) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionSetConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[17]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1345,7 +1466,7 @@ func (x *SyncStreamItem_SyncActionSetConfig) ProtoReflect() protoreflect.Message
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionSetConfig.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionSetConfig) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 3}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 4}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionSetConfig) GetRepos() []*v1.Repo {
|
||||
@@ -1384,7 +1505,7 @@ type SyncStreamItem_SyncActionRequestResources struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestResources) Reset() {
|
||||
*x = SyncStreamItem_SyncActionRequestResources{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[18]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1396,7 +1517,7 @@ func (x *SyncStreamItem_SyncActionRequestResources) String() string {
|
||||
func (*SyncStreamItem_SyncActionRequestResources) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestResources) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[18]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[19]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1409,7 +1530,7 @@ func (x *SyncStreamItem_SyncActionRequestResources) ProtoReflect() protoreflect.
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionRequestResources.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionRequestResources) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 4}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 5}
|
||||
}
|
||||
|
||||
type SyncStreamItem_SyncActionReceiveResources struct {
|
||||
@@ -1422,7 +1543,7 @@ type SyncStreamItem_SyncActionReceiveResources struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveResources) Reset() {
|
||||
*x = SyncStreamItem_SyncActionReceiveResources{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[19]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1434,7 +1555,7 @@ func (x *SyncStreamItem_SyncActionReceiveResources) String() string {
|
||||
func (*SyncStreamItem_SyncActionReceiveResources) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveResources) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[19]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[20]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1447,7 +1568,7 @@ func (x *SyncStreamItem_SyncActionReceiveResources) ProtoReflect() protoreflect.
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionReceiveResources.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionReceiveResources) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 5}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 6}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveResources) GetRepos() []*RepoMetadata {
|
||||
@@ -1473,7 +1594,7 @@ type SyncStreamItem_SyncActionConnectRepo struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionConnectRepo) Reset() {
|
||||
*x = SyncStreamItem_SyncActionConnectRepo{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[20]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1485,7 +1606,7 @@ func (x *SyncStreamItem_SyncActionConnectRepo) String() string {
|
||||
func (*SyncStreamItem_SyncActionConnectRepo) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionConnectRepo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[20]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1498,7 +1619,7 @@ func (x *SyncStreamItem_SyncActionConnectRepo) ProtoReflect() protoreflect.Messa
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionConnectRepo.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionConnectRepo) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 6}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 7}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionConnectRepo) GetRepoId() string {
|
||||
@@ -1518,7 +1639,7 @@ type SyncStreamItem_SyncActionOperationManifest struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionOperationManifest) Reset() {
|
||||
*x = SyncStreamItem_SyncActionOperationManifest{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[21]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1530,7 +1651,7 @@ func (x *SyncStreamItem_SyncActionOperationManifest) String() string {
|
||||
func (*SyncStreamItem_SyncActionOperationManifest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionOperationManifest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[21]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1543,7 +1664,7 @@ func (x *SyncStreamItem_SyncActionOperationManifest) ProtoReflect() protoreflect
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionOperationManifest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionOperationManifest) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 7}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 8}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionOperationManifest) GetOpIds() []int64 {
|
||||
@@ -1569,7 +1690,7 @@ type SyncStreamItem_SyncActionRequestOperationData struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestOperationData) Reset() {
|
||||
*x = SyncStreamItem_SyncActionRequestOperationData{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[22]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1581,7 +1702,7 @@ func (x *SyncStreamItem_SyncActionRequestOperationData) String() string {
|
||||
func (*SyncStreamItem_SyncActionRequestOperationData) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestOperationData) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[22]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1594,7 +1715,7 @@ func (x *SyncStreamItem_SyncActionRequestOperationData) ProtoReflect() protorefl
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionRequestOperationData.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionRequestOperationData) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 8}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 9}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestOperationData) GetOpIds() []int64 {
|
||||
@@ -1613,7 +1734,7 @@ type SyncStreamItem_SyncActionReceiveOperations struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveOperations) Reset() {
|
||||
*x = SyncStreamItem_SyncActionReceiveOperations{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[23]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[24]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1625,7 +1746,7 @@ func (x *SyncStreamItem_SyncActionReceiveOperations) String() string {
|
||||
func (*SyncStreamItem_SyncActionReceiveOperations) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveOperations) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[23]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[24]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1638,7 +1759,7 @@ func (x *SyncStreamItem_SyncActionReceiveOperations) ProtoReflect() protoreflect
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionReceiveOperations.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionReceiveOperations) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 9}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 10}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveOperations) GetEvent() *v1.OperationEvent {
|
||||
@@ -1657,7 +1778,7 @@ type SyncStreamItem_SyncActionRequestLog struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestLog) Reset() {
|
||||
*x = SyncStreamItem_SyncActionRequestLog{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[24]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[25]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1669,7 +1790,7 @@ func (x *SyncStreamItem_SyncActionRequestLog) String() string {
|
||||
func (*SyncStreamItem_SyncActionRequestLog) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestLog) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[24]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[25]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1682,7 +1803,7 @@ func (x *SyncStreamItem_SyncActionRequestLog) ProtoReflect() protoreflect.Messag
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionRequestLog.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionRequestLog) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 10}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 11}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionRequestLog) GetLogId() string {
|
||||
@@ -1708,7 +1829,7 @@ type SyncStreamItem_SyncActionReceiveLogData struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveLogData) Reset() {
|
||||
*x = SyncStreamItem_SyncActionReceiveLogData{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[25]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[26]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1720,7 +1841,7 @@ func (x *SyncStreamItem_SyncActionReceiveLogData) String() string {
|
||||
func (*SyncStreamItem_SyncActionReceiveLogData) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveLogData) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[25]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[26]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1733,7 +1854,7 @@ func (x *SyncStreamItem_SyncActionReceiveLogData) ProtoReflect() protoreflect.Me
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionReceiveLogData.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionReceiveLogData) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 11}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 12}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionReceiveLogData) GetLogId() string {
|
||||
@@ -1780,7 +1901,7 @@ type SyncStreamItem_SyncActionThrottle struct {
|
||||
|
||||
func (x *SyncStreamItem_SyncActionThrottle) Reset() {
|
||||
*x = SyncStreamItem_SyncActionThrottle{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[26]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[27]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1792,7 +1913,7 @@ func (x *SyncStreamItem_SyncActionThrottle) String() string {
|
||||
func (*SyncStreamItem_SyncActionThrottle) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionThrottle) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[26]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[27]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1805,7 +1926,7 @@ func (x *SyncStreamItem_SyncActionThrottle) ProtoReflect() protoreflect.Message
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncActionThrottle.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncActionThrottle) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 12}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 13}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncActionThrottle) GetDelayMs() int64 {
|
||||
@@ -1815,18 +1936,28 @@ func (x *SyncStreamItem_SyncActionThrottle) GetDelayMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// a one-time-use ECDSA public key with a matching unshared private key. Used to perform a key exchange.
|
||||
// See https://pkg.go.dev/crypto/ecdh#PrivateKey.ECDH .
|
||||
EcdsaPub string `protobuf:"bytes,2,opt,name=ecdsa_pub,json=ecdsaPub,proto3" json:"ecdsa_pub,omitempty"` // base64 encoded public key
|
||||
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() {
|
||||
*x = SyncStreamItem_SyncEstablishSharedSecret{}
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[27]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[28]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1838,7 +1969,7 @@ func (x *SyncStreamItem_SyncEstablishSharedSecret) String() string {
|
||||
func (*SyncStreamItem_SyncEstablishSharedSecret) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStreamItem_SyncEstablishSharedSecret) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[27]
|
||||
mi := &file_v1sync_syncservice_proto_msgTypes[28]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1851,14 +1982,28 @@ func (x *SyncStreamItem_SyncEstablishSharedSecret) ProtoReflect() protoreflect.M
|
||||
|
||||
// Deprecated: Use SyncStreamItem_SyncEstablishSharedSecret.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStreamItem_SyncEstablishSharedSecret) Descriptor() ([]byte, []int) {
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 13}
|
||||
return file_v1sync_syncservice_proto_rawDescGZIP(), []int{13, 14}
|
||||
}
|
||||
|
||||
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetEcdsaPub() string {
|
||||
func (x *SyncStreamItem_SyncEstablishSharedSecret) GetProtocolVersion() uint32 {
|
||||
if x != nil {
|
||||
return x.EcdsaPub
|
||||
return x.ProtocolVersion
|
||||
}
|
||||
return ""
|
||||
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
|
||||
}
|
||||
|
||||
var File_v1sync_syncservice_proto protoreflect.FileDescriptor
|
||||
@@ -1922,7 +2067,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\"\xff\x13\n" +
|
||||
"instanceId\"\xf1\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" +
|
||||
@@ -1938,14 +2083,22 @@ const file_v1sync_syncservice_proto_rawDesc = "" +
|
||||
"\vrequest_log\x18\x1e \x01(\v2+.v1sync.SyncStreamItem.SyncActionRequestLogH\x00R\n" +
|
||||
"requestLog\x12[\n" +
|
||||
"\x10receive_log_data\x18\x1f \x01(\v2/.v1sync.SyncStreamItem.SyncActionReceiveLogDataH\x00R\x0ereceiveLogData\x12H\n" +
|
||||
"\bthrottle\x18\xe8\a \x01(\v2).v1sync.SyncStreamItem.SyncActionThrottleH\x00R\bthrottle\x1a\xc9\x01\n" +
|
||||
"\bthrottle\x18\xe8\a \x01(\v2).v1sync.SyncStreamItem.SyncActionThrottleH\x00R\bthrottle\x12j\n" +
|
||||
"\x17establish_shared_secret\x18\x02 \x01(\v20.v1sync.SyncStreamItem.SyncEstablishSharedSecretH\x00R\x15establishSharedSecret\x12J\n" +
|
||||
"\tencrypted\x18\x05 \x01(\v2*.v1sync.SyncStreamItem.SyncActionEncryptedH\x00R\tencrypted\x1a\xd4\x01\n" +
|
||||
"\x13SyncActionHandshake\x12)\n" +
|
||||
"\x10protocol_version\x18\x01 \x01(\x03R\x0fprotocolVersion\x12,\n" +
|
||||
"\n" +
|
||||
"public_key\x18\x02 \x01(\v2\r.v1.PublicKeyR\tpublicKey\x122\n" +
|
||||
"\vinstance_id\x18\x03 \x01(\v2\x11.v1.SignedMessageR\n" +
|
||||
"public_key\x18\x02 \x01(\v2\r.v1.PublicKeyR\tpublicKey\x12\x1f\n" +
|
||||
"\vinstance_id\x18\x03 \x01(\tR\n" +
|
||||
"instanceId\x12%\n" +
|
||||
"\x0epairing_secret\x18\x04 \x01(\tR\rpairingSecret\x1a\x15\n" +
|
||||
"\x0epairing_secret\x18\x04 \x01(\tR\rpairingSecret\x12\x1c\n" +
|
||||
"\tsignature\x18\x05 \x01(\fR\tsignature\x1aK\n" +
|
||||
"\x13SyncActionEncrypted\x12\x14\n" +
|
||||
"\x05nonce\x18\x01 \x01(\fR\x05nonce\x12\x1e\n" +
|
||||
"\n" +
|
||||
"ciphertext\x18\x02 \x01(\fR\n" +
|
||||
"ciphertext\x1a\x15\n" +
|
||||
"\x13SyncActionHeartbeat\x1aG\n" +
|
||||
"\x17SyncActionReceiveConfig\x12,\n" +
|
||||
"\x06config\x18\x01 \x01(\v2\x14.v1sync.RemoteConfigR\x06config\x1a\xa5\x01\n" +
|
||||
@@ -1977,9 +2130,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\x1a8\n" +
|
||||
"\x19SyncEstablishSharedSecret\x12\x1b\n" +
|
||||
"\tecdsa_pub\x18\x02 \x01(\tR\becdsaPub\"\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" +
|
||||
@@ -2016,7 +2171,7 @@ func file_v1sync_syncservice_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_v1sync_syncservice_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
|
||||
var file_v1sync_syncservice_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
|
||||
var file_v1sync_syncservice_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
|
||||
var file_v1sync_syncservice_proto_goTypes = []any{
|
||||
(ConnectionState)(0), // 0: v1sync.ConnectionState
|
||||
(SyncStreamItem_RepoConnectionState)(0), // 1: v1sync.SyncStreamItem.RepoConnectionState
|
||||
@@ -2035,73 +2190,75 @@ var file_v1sync_syncservice_proto_goTypes = []any{
|
||||
(*AuthorizationToken)(nil), // 14: v1sync.AuthorizationToken
|
||||
(*SyncStreamItem)(nil), // 15: v1sync.SyncStreamItem
|
||||
(*SyncStreamItem_SyncActionHandshake)(nil), // 16: v1sync.SyncStreamItem.SyncActionHandshake
|
||||
(*SyncStreamItem_SyncActionHeartbeat)(nil), // 17: v1sync.SyncStreamItem.SyncActionHeartbeat
|
||||
(*SyncStreamItem_SyncActionReceiveConfig)(nil), // 18: v1sync.SyncStreamItem.SyncActionReceiveConfig
|
||||
(*SyncStreamItem_SyncActionSetConfig)(nil), // 19: v1sync.SyncStreamItem.SyncActionSetConfig
|
||||
(*SyncStreamItem_SyncActionRequestResources)(nil), // 20: v1sync.SyncStreamItem.SyncActionRequestResources
|
||||
(*SyncStreamItem_SyncActionReceiveResources)(nil), // 21: v1sync.SyncStreamItem.SyncActionReceiveResources
|
||||
(*SyncStreamItem_SyncActionConnectRepo)(nil), // 22: v1sync.SyncStreamItem.SyncActionConnectRepo
|
||||
(*SyncStreamItem_SyncActionOperationManifest)(nil), // 23: v1sync.SyncStreamItem.SyncActionOperationManifest
|
||||
(*SyncStreamItem_SyncActionRequestOperationData)(nil), // 24: v1sync.SyncStreamItem.SyncActionRequestOperationData
|
||||
(*SyncStreamItem_SyncActionReceiveOperations)(nil), // 25: v1sync.SyncStreamItem.SyncActionReceiveOperations
|
||||
(*SyncStreamItem_SyncActionRequestLog)(nil), // 26: v1sync.SyncStreamItem.SyncActionRequestLog
|
||||
(*SyncStreamItem_SyncActionReceiveLogData)(nil), // 27: v1sync.SyncStreamItem.SyncActionReceiveLogData
|
||||
(*SyncStreamItem_SyncActionThrottle)(nil), // 28: v1sync.SyncStreamItem.SyncActionThrottle
|
||||
(*SyncStreamItem_SyncEstablishSharedSecret)(nil), // 29: v1sync.SyncStreamItem.SyncEstablishSharedSecret
|
||||
(*v1.SignedMessage)(nil), // 30: v1.SignedMessage
|
||||
(*v1.Plan)(nil), // 31: v1.Plan
|
||||
(*v1.Repo)(nil), // 32: v1.Repo
|
||||
(*v1.PublicKey)(nil), // 33: v1.PublicKey
|
||||
(*v1.OperationEvent)(nil), // 34: v1.OperationEvent
|
||||
(*SyncStreamItem_SyncActionEncrypted)(nil), // 17: v1sync.SyncStreamItem.SyncActionEncrypted
|
||||
(*SyncStreamItem_SyncActionHeartbeat)(nil), // 18: v1sync.SyncStreamItem.SyncActionHeartbeat
|
||||
(*SyncStreamItem_SyncActionReceiveConfig)(nil), // 19: v1sync.SyncStreamItem.SyncActionReceiveConfig
|
||||
(*SyncStreamItem_SyncActionSetConfig)(nil), // 20: v1sync.SyncStreamItem.SyncActionSetConfig
|
||||
(*SyncStreamItem_SyncActionRequestResources)(nil), // 21: v1sync.SyncStreamItem.SyncActionRequestResources
|
||||
(*SyncStreamItem_SyncActionReceiveResources)(nil), // 22: v1sync.SyncStreamItem.SyncActionReceiveResources
|
||||
(*SyncStreamItem_SyncActionConnectRepo)(nil), // 23: v1sync.SyncStreamItem.SyncActionConnectRepo
|
||||
(*SyncStreamItem_SyncActionOperationManifest)(nil), // 24: v1sync.SyncStreamItem.SyncActionOperationManifest
|
||||
(*SyncStreamItem_SyncActionRequestOperationData)(nil), // 25: v1sync.SyncStreamItem.SyncActionRequestOperationData
|
||||
(*SyncStreamItem_SyncActionReceiveOperations)(nil), // 26: v1sync.SyncStreamItem.SyncActionReceiveOperations
|
||||
(*SyncStreamItem_SyncActionRequestLog)(nil), // 27: v1sync.SyncStreamItem.SyncActionRequestLog
|
||||
(*SyncStreamItem_SyncActionReceiveLogData)(nil), // 28: v1sync.SyncStreamItem.SyncActionReceiveLogData
|
||||
(*SyncStreamItem_SyncActionThrottle)(nil), // 29: v1sync.SyncStreamItem.SyncActionThrottle
|
||||
(*SyncStreamItem_SyncEstablishSharedSecret)(nil), // 30: v1sync.SyncStreamItem.SyncEstablishSharedSecret
|
||||
(*v1.SignedMessage)(nil), // 31: v1.SignedMessage
|
||||
(*v1.Plan)(nil), // 32: v1.Plan
|
||||
(*v1.Repo)(nil), // 33: v1.Repo
|
||||
(*v1.PublicKey)(nil), // 34: v1.PublicKey
|
||||
(*v1.OperationEvent)(nil), // 35: v1.OperationEvent
|
||||
}
|
||||
var file_v1sync_syncservice_proto_depIdxs = []int32{
|
||||
0, // 0: v1sync.PeerState.state:type_name -> v1sync.ConnectionState
|
||||
9, // 1: v1sync.PeerState.known_plans:type_name -> v1sync.PlanMetadata
|
||||
8, // 2: v1sync.PeerState.known_repos:type_name -> v1sync.RepoMetadata
|
||||
13, // 3: v1sync.PeerState.remote_config:type_name -> v1sync.RemoteConfig
|
||||
30, // 4: v1sync.AuthenticateRequest.instance_id:type_name -> v1.SignedMessage
|
||||
31, // 4: v1sync.AuthenticateRequest.instance_id:type_name -> v1.SignedMessage
|
||||
9, // 5: v1sync.SetAvailableResourcesRequest.repos:type_name -> v1sync.PlanMetadata
|
||||
8, // 6: v1sync.SetAvailableResourcesRequest.plans:type_name -> v1sync.RepoMetadata
|
||||
31, // 7: v1sync.SetConfigRequest.plans:type_name -> v1.Plan
|
||||
32, // 8: v1sync.SetConfigRequest.repos:type_name -> v1.Repo
|
||||
32, // 9: v1sync.SetRemoteClientConfigRequest.repos:type_name -> v1.Repo
|
||||
31, // 10: v1sync.SetRemoteClientConfigRequest.plans:type_name -> v1.Plan
|
||||
32, // 11: v1sync.RemoteConfig.repos:type_name -> v1.Repo
|
||||
31, // 12: v1sync.RemoteConfig.plans:type_name -> v1.Plan
|
||||
33, // 13: v1sync.AuthorizationToken.public_key:type_name -> v1.PublicKey
|
||||
30, // 14: v1sync.AuthorizationToken.instance_id:type_name -> v1.SignedMessage
|
||||
30, // 15: v1sync.SyncStreamItem.signed_message:type_name -> v1.SignedMessage
|
||||
32, // 7: v1sync.SetConfigRequest.plans:type_name -> v1.Plan
|
||||
33, // 8: v1sync.SetConfigRequest.repos:type_name -> v1.Repo
|
||||
33, // 9: v1sync.SetRemoteClientConfigRequest.repos:type_name -> v1.Repo
|
||||
32, // 10: v1sync.SetRemoteClientConfigRequest.plans:type_name -> v1.Plan
|
||||
33, // 11: v1sync.RemoteConfig.repos:type_name -> v1.Repo
|
||||
32, // 12: v1sync.RemoteConfig.plans:type_name -> v1.Plan
|
||||
34, // 13: v1sync.AuthorizationToken.public_key:type_name -> v1.PublicKey
|
||||
31, // 14: v1sync.AuthorizationToken.instance_id:type_name -> v1.SignedMessage
|
||||
31, // 15: v1sync.SyncStreamItem.signed_message:type_name -> v1.SignedMessage
|
||||
16, // 16: v1sync.SyncStreamItem.handshake:type_name -> v1sync.SyncStreamItem.SyncActionHandshake
|
||||
17, // 17: v1sync.SyncStreamItem.heartbeat:type_name -> v1sync.SyncStreamItem.SyncActionHeartbeat
|
||||
23, // 18: v1sync.SyncStreamItem.operation_manifest:type_name -> v1sync.SyncStreamItem.SyncActionOperationManifest
|
||||
25, // 19: v1sync.SyncStreamItem.receive_operations:type_name -> v1sync.SyncStreamItem.SyncActionReceiveOperations
|
||||
24, // 20: v1sync.SyncStreamItem.request_operation_data:type_name -> v1sync.SyncStreamItem.SyncActionRequestOperationData
|
||||
18, // 21: v1sync.SyncStreamItem.receive_config:type_name -> v1sync.SyncStreamItem.SyncActionReceiveConfig
|
||||
19, // 22: v1sync.SyncStreamItem.set_config:type_name -> v1sync.SyncStreamItem.SyncActionSetConfig
|
||||
20, // 23: v1sync.SyncStreamItem.request_resources:type_name -> v1sync.SyncStreamItem.SyncActionRequestResources
|
||||
21, // 24: v1sync.SyncStreamItem.receive_resources:type_name -> v1sync.SyncStreamItem.SyncActionReceiveResources
|
||||
26, // 25: v1sync.SyncStreamItem.request_log:type_name -> v1sync.SyncStreamItem.SyncActionRequestLog
|
||||
27, // 26: v1sync.SyncStreamItem.receive_log_data:type_name -> v1sync.SyncStreamItem.SyncActionReceiveLogData
|
||||
28, // 27: v1sync.SyncStreamItem.throttle:type_name -> v1sync.SyncStreamItem.SyncActionThrottle
|
||||
33, // 28: v1sync.SyncStreamItem.SyncActionHandshake.public_key:type_name -> v1.PublicKey
|
||||
30, // 29: v1sync.SyncStreamItem.SyncActionHandshake.instance_id:type_name -> v1.SignedMessage
|
||||
13, // 30: v1sync.SyncStreamItem.SyncActionReceiveConfig.config:type_name -> v1sync.RemoteConfig
|
||||
32, // 31: v1sync.SyncStreamItem.SyncActionSetConfig.repos:type_name -> v1.Repo
|
||||
31, // 32: v1sync.SyncStreamItem.SyncActionSetConfig.plans:type_name -> v1.Plan
|
||||
8, // 33: v1sync.SyncStreamItem.SyncActionReceiveResources.repos:type_name -> v1sync.RepoMetadata
|
||||
9, // 34: v1sync.SyncStreamItem.SyncActionReceiveResources.plans:type_name -> v1sync.PlanMetadata
|
||||
34, // 35: v1sync.SyncStreamItem.SyncActionReceiveOperations.event:type_name -> v1.OperationEvent
|
||||
15, // 36: v1sync.BackrestSyncService.Sync:input_type -> v1sync.SyncStreamItem
|
||||
2, // 37: v1sync.BackrestSyncStateService.GetPeerSyncStatesStream:input_type -> v1sync.SyncStateStreamRequest
|
||||
11, // 38: v1sync.BackrestSyncStateService.SetRemoteClientConfig:input_type -> v1sync.SetRemoteClientConfigRequest
|
||||
15, // 39: v1sync.BackrestSyncService.Sync:output_type -> v1sync.SyncStreamItem
|
||||
3, // 40: v1sync.BackrestSyncStateService.GetPeerSyncStatesStream:output_type -> v1sync.PeerState
|
||||
12, // 41: v1sync.BackrestSyncStateService.SetRemoteClientConfig:output_type -> v1sync.SetRemoteClientConfigResponse
|
||||
39, // [39:42] is the sub-list for method output_type
|
||||
36, // [36:39] is the sub-list for method input_type
|
||||
36, // [36:36] is the sub-list for extension type_name
|
||||
36, // [36:36] is the sub-list for extension extendee
|
||||
0, // [0:36] is the sub-list for field type_name
|
||||
18, // 17: v1sync.SyncStreamItem.heartbeat:type_name -> v1sync.SyncStreamItem.SyncActionHeartbeat
|
||||
24, // 18: v1sync.SyncStreamItem.operation_manifest:type_name -> v1sync.SyncStreamItem.SyncActionOperationManifest
|
||||
26, // 19: v1sync.SyncStreamItem.receive_operations:type_name -> v1sync.SyncStreamItem.SyncActionReceiveOperations
|
||||
25, // 20: v1sync.SyncStreamItem.request_operation_data:type_name -> v1sync.SyncStreamItem.SyncActionRequestOperationData
|
||||
19, // 21: v1sync.SyncStreamItem.receive_config:type_name -> v1sync.SyncStreamItem.SyncActionReceiveConfig
|
||||
20, // 22: v1sync.SyncStreamItem.set_config:type_name -> v1sync.SyncStreamItem.SyncActionSetConfig
|
||||
21, // 23: v1sync.SyncStreamItem.request_resources:type_name -> v1sync.SyncStreamItem.SyncActionRequestResources
|
||||
22, // 24: v1sync.SyncStreamItem.receive_resources:type_name -> v1sync.SyncStreamItem.SyncActionReceiveResources
|
||||
27, // 25: v1sync.SyncStreamItem.request_log:type_name -> v1sync.SyncStreamItem.SyncActionRequestLog
|
||||
28, // 26: v1sync.SyncStreamItem.receive_log_data:type_name -> v1sync.SyncStreamItem.SyncActionReceiveLogData
|
||||
29, // 27: v1sync.SyncStreamItem.throttle:type_name -> v1sync.SyncStreamItem.SyncActionThrottle
|
||||
30, // 28: v1sync.SyncStreamItem.establish_shared_secret:type_name -> v1sync.SyncStreamItem.SyncEstablishSharedSecret
|
||||
17, // 29: v1sync.SyncStreamItem.encrypted:type_name -> v1sync.SyncStreamItem.SyncActionEncrypted
|
||||
34, // 30: v1sync.SyncStreamItem.SyncActionHandshake.public_key:type_name -> v1.PublicKey
|
||||
13, // 31: v1sync.SyncStreamItem.SyncActionReceiveConfig.config:type_name -> v1sync.RemoteConfig
|
||||
33, // 32: v1sync.SyncStreamItem.SyncActionSetConfig.repos:type_name -> v1.Repo
|
||||
32, // 33: v1sync.SyncStreamItem.SyncActionSetConfig.plans:type_name -> v1.Plan
|
||||
8, // 34: v1sync.SyncStreamItem.SyncActionReceiveResources.repos:type_name -> v1sync.RepoMetadata
|
||||
9, // 35: v1sync.SyncStreamItem.SyncActionReceiveResources.plans:type_name -> v1sync.PlanMetadata
|
||||
35, // 36: v1sync.SyncStreamItem.SyncActionReceiveOperations.event:type_name -> v1.OperationEvent
|
||||
15, // 37: v1sync.BackrestSyncService.Sync:input_type -> v1sync.SyncStreamItem
|
||||
2, // 38: v1sync.BackrestSyncStateService.GetPeerSyncStatesStream:input_type -> v1sync.SyncStateStreamRequest
|
||||
11, // 39: v1sync.BackrestSyncStateService.SetRemoteClientConfig:input_type -> v1sync.SetRemoteClientConfigRequest
|
||||
15, // 40: v1sync.BackrestSyncService.Sync:output_type -> v1sync.SyncStreamItem
|
||||
3, // 41: v1sync.BackrestSyncStateService.GetPeerSyncStatesStream:output_type -> v1sync.PeerState
|
||||
12, // 42: v1sync.BackrestSyncStateService.SetRemoteClientConfig:output_type -> v1sync.SetRemoteClientConfigResponse
|
||||
40, // [40:43] is the sub-list for method output_type
|
||||
37, // [37:40] is the sub-list for method input_type
|
||||
37, // [37:37] is the sub-list for extension type_name
|
||||
37, // [37:37] is the sub-list for extension extendee
|
||||
0, // [0:37] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_v1sync_syncservice_proto_init() }
|
||||
@@ -2123,6 +2280,8 @@ func file_v1sync_syncservice_proto_init() {
|
||||
(*SyncStreamItem_RequestLog)(nil),
|
||||
(*SyncStreamItem_ReceiveLogData)(nil),
|
||||
(*SyncStreamItem_Throttle)(nil),
|
||||
(*SyncStreamItem_EstablishSharedSecret)(nil),
|
||||
(*SyncStreamItem_Encrypted)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
@@ -2130,7 +2289,7 @@ func file_v1sync_syncservice_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1sync_syncservice_proto_rawDesc), len(file_v1sync_syncservice_proto_rawDesc)),
|
||||
NumEnums: 2,
|
||||
NumMessages: 28,
|
||||
NumMessages: 29,
|
||||
NumExtensions: 0,
|
||||
NumServices: 2,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/garethgeorge/backrest
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
al.essio.dev/pkg/shellescape v1.6.0
|
||||
|
||||
@@ -5,10 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/garethgeorge/backrest/gen/go/v1sync"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type syncCommandStreamTrait interface {
|
||||
@@ -16,20 +20,52 @@ type syncCommandStreamTrait interface {
|
||||
Receive() (*v1sync.SyncStreamItem, error)
|
||||
}
|
||||
|
||||
var _ syncCommandStreamTrait = (*connect.BidiStream[v1sync.SyncStreamItem, v1sync.SyncStreamItem])(nil) // Ensure that connect.BidiStream implements syncCommandStreamTrait
|
||||
var _ syncCommandStreamTrait = (*connect.BidiStreamForClient[v1sync.SyncStreamItem, v1sync.SyncStreamItem])(nil) // Ensure that connect.BidiStreamForClient implements syncCommandStreamTrait
|
||||
var _ syncCommandStreamTrait = (*connect.BidiStream[v1sync.SyncStreamItem, v1sync.SyncStreamItem])(nil)
|
||||
var _ syncCommandStreamTrait = (*connect.BidiStreamForClient[v1sync.SyncStreamItem, v1sync.SyncStreamItem])(nil)
|
||||
|
||||
type bidiSyncCommandStream struct {
|
||||
sendChan chan *v1sync.SyncStreamItem
|
||||
recvChan chan *v1sync.SyncStreamItem
|
||||
terminateWithErrChan chan error
|
||||
sendChan chan *v1sync.SyncStreamItem
|
||||
recvChan chan *v1sync.SyncStreamItem
|
||||
|
||||
// done is closed exactly once when the stream is terminated. Readers can
|
||||
// observe termination by selecting on it; the cause (if any) is stored in
|
||||
// terminateErr.
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
terminateErr atomic.Pointer[error]
|
||||
|
||||
// transcript is the post-quantum transport transcript that runSync signs
|
||||
// and verifies in its handshake. It is published once, by ConnectStream,
|
||||
// after the KEM exchange succeeds, then closed via transcriptReady.
|
||||
// Callers retrieve it through AwaitTranscript.
|
||||
transcript []byte
|
||||
transcriptReady chan struct{}
|
||||
}
|
||||
|
||||
func newBidiSyncCommandStream() *bidiSyncCommandStream {
|
||||
return &bidiSyncCommandStream{
|
||||
sendChan: make(chan *v1sync.SyncStreamItem, 256), // Buffered channel to allow sending items without blocking
|
||||
recvChan: make(chan *v1sync.SyncStreamItem, 1),
|
||||
terminateWithErrChan: make(chan error, 1),
|
||||
sendChan: make(chan *v1sync.SyncStreamItem, 256),
|
||||
recvChan: make(chan *v1sync.SyncStreamItem, 1),
|
||||
done: make(chan struct{}),
|
||||
transcriptReady: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitTranscript blocks until the transport-layer handshake completes and
|
||||
// returns the transcript bytes that the higher-layer identity exchange must
|
||||
// sign. Returns ctx.Err() if ctx is cancelled, or the stream's termination
|
||||
// error if the connection failed before the transcript could be published.
|
||||
func (s *bidiSyncCommandStream) AwaitTranscript(ctx context.Context) ([]byte, error) {
|
||||
select {
|
||||
case <-s.transcriptReady:
|
||||
return s.transcript, nil
|
||||
case <-s.done:
|
||||
if err := s.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("stream terminated before transport transcript was available")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +73,6 @@ func (s *bidiSyncCommandStream) Send(item *v1sync.SyncStreamItem) {
|
||||
select {
|
||||
case s.sendChan <- item:
|
||||
default:
|
||||
// Try again with a timeout, if it fails, send an error to terminate the stream
|
||||
select {
|
||||
case s.sendChan <- item:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
@@ -46,37 +81,86 @@ func (s *bidiSyncCommandStream) Send(item *v1sync.SyncStreamItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// SendErrorAndTerminate sends an error to the termination channel.
|
||||
// If the error is nil, it terminates only.
|
||||
// SendErrorAndTerminate marks the stream as terminated. The first call wins:
|
||||
// its err (if non-nil) is the one returned by Err. Subsequent calls are no-ops.
|
||||
// Safe to call from any goroutine; non-blocking.
|
||||
func (s *bidiSyncCommandStream) SendErrorAndTerminate(err error) {
|
||||
select {
|
||||
case s.terminateWithErrChan <- err:
|
||||
default:
|
||||
// If the channel is full, we can't send the error, so we just ignore it.
|
||||
// This is a best-effort termination.
|
||||
s.doneOnce.Do(func() {
|
||||
if err != nil {
|
||||
errCopy := err
|
||||
s.terminateErr.Store(&errCopy)
|
||||
}
|
||||
close(s.done)
|
||||
})
|
||||
}
|
||||
|
||||
// Err returns the termination error, or nil if the stream has not been
|
||||
// terminated or was terminated without an error.
|
||||
func (s *bidiSyncCommandStream) Err() error {
|
||||
if errPtr := s.terminateErr.Load(); errPtr != nil {
|
||||
return *errPtr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the stream is terminated.
|
||||
func (s *bidiSyncCommandStream) Done() <-chan struct{} {
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *bidiSyncCommandStream) ReadChannel() chan *v1sync.SyncStreamItem {
|
||||
return s.recvChan
|
||||
}
|
||||
|
||||
func (s *bidiSyncCommandStream) ReceiveWithinDuration(d time.Duration) *v1sync.SyncStreamItem {
|
||||
// ReceiveWithinDuration waits up to d for the next stream item. The returned
|
||||
// error explains why no item arrived: ctx.Err() if ctx is cancelled, the
|
||||
// stream's termination error (which may itself be nil) if the stream is
|
||||
// terminated, or context.DeadlineExceeded if d elapses first. A nil item with
|
||||
// a nil error means the stream was terminated cleanly with no cause.
|
||||
func (s *bidiSyncCommandStream) ReceiveWithinDuration(ctx context.Context, d time.Duration) (*v1sync.SyncStreamItem, error) {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case item := <-s.recvChan:
|
||||
return item
|
||||
case <-time.After(d):
|
||||
return nil // Return nil if no item is received within the duration
|
||||
case item, ok := <-s.recvChan:
|
||||
if !ok {
|
||||
return nil, s.Err()
|
||||
}
|
||||
return item, nil
|
||||
case <-s.done:
|
||||
return nil, s.Err()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bidiSyncCommandStream) ConnectStream(ctx context.Context, stream syncCommandStreamTrait) error {
|
||||
// ConnectStream bridges the channel-based bidiSyncCommandStream to a real transport.
|
||||
// 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 the PQ KEM handshake on the raw transport before starting the pump.
|
||||
transport, transcript, 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
|
||||
// instead of waiting out its full timeout.
|
||||
s.SendErrorAndTerminate(err)
|
||||
return err
|
||||
}
|
||||
// Publish the transcript so runSync can sign / verify the handshake packet.
|
||||
s.transcript = transcript
|
||||
close(s.transcriptReady)
|
||||
|
||||
go func() {
|
||||
defer close(s.recvChan)
|
||||
for {
|
||||
val, err := stream.Receive()
|
||||
val, err := transport.Receive()
|
||||
if err != nil {
|
||||
s.SendErrorAndTerminate(NewSyncErrorDisconnected(fmt.Errorf("receiving item: %w", err)))
|
||||
return
|
||||
@@ -95,18 +179,109 @@ func (s *bidiSyncCommandStream) ConnectStream(ctx context.Context, stream syncCo
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if err := stream.Send(item); err != nil {
|
||||
if err := transport.Send(item); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = fmt.Errorf("connection failed or dropped: %w", err)
|
||||
}
|
||||
s.SendErrorAndTerminate(err)
|
||||
return err
|
||||
}
|
||||
case err := <-s.terminateWithErrChan:
|
||||
return err // Terminate the stream with the error or nil if no error was sent
|
||||
case <-s.done:
|
||||
return s.Err()
|
||||
case <-ctx.Done():
|
||||
// Context is done, we should stop processing.
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// establishEncryption performs a post-quantum KEM handshake on the raw
|
||||
// transport and returns an encrypted stream wrapper plus the transport
|
||||
// transcript that runSync uses to bind its ed25519 handshake signature to
|
||||
// this specific KEM exchange. 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 AES-256-GCM
|
||||
// per-direction session keys via the HPKE Export interface and compute the
|
||||
// same transcript hash. The application-layer identity handshake (signature
|
||||
// over the transcript) runs over the encrypted channel afterward.
|
||||
//
|
||||
// isInitiator must be true on the connecting side (client) and false on the
|
||||
// accepting side (server).
|
||||
func establishEncryption(stream syncCommandStreamTrait, isInitiator bool) (syncCommandStreamTrait, []byte, error) {
|
||||
if isInitiator {
|
||||
recipient, pubBytes, err := cryptoutil.NewTransportRecipient()
|
||||
if err != nil {
|
||||
return nil, nil, NewSyncErrorInternal(fmt.Errorf("generating ephemeral KEM key: %w", err))
|
||||
}
|
||||
|
||||
if err := stream.Send(&v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_EstablishSharedSecret{
|
||||
EstablishSharedSecret: &v1sync.SyncStreamItem_SyncEstablishSharedSecret{
|
||||
ProtocolVersion: cryptoutil.TransportProtocolVersion,
|
||||
KemPublicKey: pubBytes,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, nil, NewSyncErrorDisconnected(fmt.Errorf("sending KEM public key: %w", err))
|
||||
}
|
||||
|
||||
peerMsg, err := stream.Receive()
|
||||
if err != nil {
|
||||
return nil, nil, NewSyncErrorDisconnected(fmt.Errorf("receiving KEM encapsulation: %w", err))
|
||||
}
|
||||
peerSecret := peerMsg.GetEstablishSharedSecret()
|
||||
if peerSecret == nil {
|
||||
return nil, nil, NewSyncErrorProtocol(fmt.Errorf("expected KEM key exchange, got %T", peerMsg.GetAction()))
|
||||
}
|
||||
if peerSecret.GetProtocolVersion() != cryptoutil.TransportProtocolVersion {
|
||||
return nil, 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, nil, NewSyncErrorProtocol(errors.New("responder did not send KEM encapsulation"))
|
||||
}
|
||||
|
||||
sess, err := recipient.Decapsulate(peerSecret.GetKemEncapsulation())
|
||||
if err != nil {
|
||||
return nil, nil, NewSyncErrorProtocol(fmt.Errorf("decapsulating KEM: %w", err))
|
||||
}
|
||||
|
||||
zap.L().Info("encrypted sync session established (initiator)")
|
||||
return newEncryptedStream(stream, sess.Send, sess.Recv), sess.Transcript(), nil
|
||||
}
|
||||
|
||||
peerMsg, err := stream.Receive()
|
||||
if err != nil {
|
||||
return nil, nil, NewSyncErrorDisconnected(fmt.Errorf("receiving KEM public key: %w", err))
|
||||
}
|
||||
peerSecret := peerMsg.GetEstablishSharedSecret()
|
||||
if peerSecret == nil {
|
||||
return nil, nil, NewSyncErrorProtocol(fmt.Errorf("expected KEM key exchange, got %T", peerMsg.GetAction()))
|
||||
}
|
||||
if peerSecret.GetProtocolVersion() != cryptoutil.TransportProtocolVersion {
|
||||
return nil, 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, nil, NewSyncErrorProtocol(errors.New("initiator did not send KEM public key"))
|
||||
}
|
||||
|
||||
enc, sess, err := cryptoutil.EncapsulateToTransport(peerSecret.GetKemPublicKey())
|
||||
if err != nil {
|
||||
return nil, nil, NewSyncErrorProtocol(fmt.Errorf("encapsulating to KEM public 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, nil, NewSyncErrorDisconnected(fmt.Errorf("sending KEM encapsulation: %w", err))
|
||||
}
|
||||
|
||||
zap.L().Info("encrypted sync session established (responder)")
|
||||
return newEncryptedStream(stream, sess.Send, sess.Recv), sess.Transcript(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/garethgeorge/backrest/gen/go/v1sync"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// encryptedStream wraps a syncCommandStreamTrait with AES-256-GCM encryption
|
||||
// using the per-direction AEADs derived during the transport handshake.
|
||||
//
|
||||
// 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
|
||||
send cipher.AEAD
|
||||
recv cipher.AEAD
|
||||
|
||||
sendMu sync.Mutex
|
||||
sendCounter uint64
|
||||
|
||||
recvMu sync.Mutex
|
||||
recvCounter uint64
|
||||
}
|
||||
|
||||
func newEncryptedStream(inner syncCommandStreamTrait, send, recv cipher.AEAD) *encryptedStream {
|
||||
return &encryptedStream{
|
||||
inner: inner,
|
||||
send: send,
|
||||
recv: recv,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *encryptedStream) Send(item *v1sync.SyncStreamItem) error {
|
||||
plaintext, err := proto.Marshal(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal for encryption: %w", err)
|
||||
}
|
||||
|
||||
s.sendMu.Lock()
|
||||
nonce := makeNonce(s.send.NonceSize(), s.sendCounter)
|
||||
s.sendCounter++
|
||||
s.sendMu.Unlock()
|
||||
|
||||
ciphertext := s.send.Seal(nil, nonce, plaintext, nil)
|
||||
|
||||
return s.inner.Send(&v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_Encrypted{
|
||||
Encrypted: &v1sync.SyncStreamItem_SyncActionEncrypted{
|
||||
Nonce: nonce,
|
||||
Ciphertext: ciphertext,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *encryptedStream) Receive() (*v1sync.SyncStreamItem, error) {
|
||||
envelope, err := s.inner.Receive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encrypted := envelope.GetEncrypted()
|
||||
if encrypted == nil {
|
||||
return nil, fmt.Errorf("expected encrypted message, got %T", envelope.GetAction())
|
||||
}
|
||||
|
||||
s.recvMu.Lock()
|
||||
expectedNonce := makeNonce(s.recv.NonceSize(), s.recvCounter)
|
||||
s.recvCounter++
|
||||
s.recvMu.Unlock()
|
||||
|
||||
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.
|
||||
for i := range expectedNonce {
|
||||
if expectedNonce[i] != encrypted.Nonce[i] {
|
||||
return nil, fmt.Errorf("nonce mismatch: possible replay or reorder attack")
|
||||
}
|
||||
}
|
||||
|
||||
plaintext, err := s.recv.Open(nil, encrypted.Nonce, encrypted.Ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt message: %w", err)
|
||||
}
|
||||
|
||||
var inner v1sync.SyncStreamItem
|
||||
if err := proto.Unmarshal(plaintext, &inner); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal decrypted message: %w", err)
|
||||
}
|
||||
|
||||
return &inner, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/garethgeorge/backrest/gen/go/v1sync"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"github.com/garethgeorge/backrest/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeStream is a pair of in-memory channels simulating a bidirectional transport.
|
||||
type fakeStream struct {
|
||||
sendCh chan *v1sync.SyncStreamItem
|
||||
recvCh chan *v1sync.SyncStreamItem
|
||||
}
|
||||
|
||||
func (f *fakeStream) Send(item *v1sync.SyncStreamItem) error {
|
||||
f.sendCh <- item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStream) Receive() (*v1sync.SyncStreamItem, error) {
|
||||
item := <-f.recvCh
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// newFakeStreamPair creates two connected fakeStreams (A's send is B's recv and vice versa).
|
||||
func newFakeStreamPair() (*fakeStream, *fakeStream) {
|
||||
ab := make(chan *v1sync.SyncStreamItem, 16)
|
||||
ba := make(chan *v1sync.SyncStreamItem, 16)
|
||||
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) {
|
||||
initSess, respSess := runHandshake(t)
|
||||
|
||||
transportA, transportB := newFakeStreamPair()
|
||||
encA := newEncryptedStream(transportA, initSess.Send, initSess.Recv)
|
||||
encB := newEncryptedStream(transportB, respSess.Send, respSess.Recv)
|
||||
|
||||
sendItem := &v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_Heartbeat{
|
||||
Heartbeat: &v1sync.SyncStreamItem_SyncActionHeartbeat{},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := encA.Send(sendItem); err != nil {
|
||||
t.Errorf("send: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
recvItem, err := encB.Receive()
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if recvItem.GetHeartbeat() == nil {
|
||||
t.Fatalf("expected heartbeat, got %T", recvItem.GetAction())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedStream_BidirectionalMultiMessage(t *testing.T) {
|
||||
initSess, respSess := runHandshake(t)
|
||||
|
||||
transportA, transportB := newFakeStreamPair()
|
||||
encA := newEncryptedStream(transportA, initSess.Send, initSess.Recv)
|
||||
encB := newEncryptedStream(transportB, respSess.Send, respSess.Recv)
|
||||
|
||||
heartbeat := &v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_Heartbeat{
|
||||
Heartbeat: &v1sync.SyncStreamItem_SyncActionHeartbeat{},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// A→B direction
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := encA.Send(heartbeat); err != nil {
|
||||
t.Errorf("A send %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := encB.Receive(); err != nil {
|
||||
t.Fatalf("B receive %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// B→A direction
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := encB.Send(heartbeat); err != nil {
|
||||
t.Errorf("B send %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := encA.Receive(); err != nil {
|
||||
t.Fatalf("A receive %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestEstablishEncryption_Integration(t *testing.T) {
|
||||
testutil.InstallZapLogger(t)
|
||||
transportA, transportB := newFakeStreamPair()
|
||||
|
||||
var encA, encB syncCommandStreamTrait
|
||||
var transcriptA, transcriptB []byte
|
||||
var errA, errB error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// A is the initiator (client side).
|
||||
encA, transcriptA, errA = establishEncryption(transportA, true)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// B is the responder (server side).
|
||||
encB, transcriptB, errB = establishEncryption(transportB, false)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if errA != nil {
|
||||
t.Fatalf("establish A: %v", errA)
|
||||
}
|
||||
if errB != nil {
|
||||
t.Fatalf("establish B: %v", errB)
|
||||
}
|
||||
if len(transcriptA) == 0 || len(transcriptB) == 0 {
|
||||
t.Fatal("transcripts must be non-empty after handshake")
|
||||
}
|
||||
if string(transcriptA) != string(transcriptB) {
|
||||
t.Fatal("paired peers must agree on transport transcript")
|
||||
}
|
||||
|
||||
heartbeat := &v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_Heartbeat{
|
||||
Heartbeat: &v1sync.SyncStreamItem_SyncActionHeartbeat{},
|
||||
},
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := encA.Send(heartbeat); err != nil {
|
||||
t.Errorf("send: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
recv, err := encB.Receive()
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if recv.GetHeartbeat() == nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
)
|
||||
|
||||
func newTestIdentity(t *testing.T) *cryptoutil.PrivateKey {
|
||||
t.Helper()
|
||||
proto, err := cryptoutil.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate identity: %v", err)
|
||||
}
|
||||
priv, err := cryptoutil.NewPrivateKey(proto)
|
||||
if err != nil {
|
||||
t.Fatalf("load identity: %v", err)
|
||||
}
|
||||
return priv
|
||||
}
|
||||
|
||||
func freshTranscript(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestHandshake_RoundTrip(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", identity, "", transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("createHandshakePacket: %v", err)
|
||||
}
|
||||
peerKey, err := verifyHandshakePacket(packet, transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHandshakePacket: %v", err)
|
||||
}
|
||||
if peerKey.KeyID() != identity.KeyID() {
|
||||
t.Fatalf("verified key ID mismatch: %s vs %s", peerKey.KeyID(), identity.KeyID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_TranscriptMismatchFails(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcriptA := freshTranscript(t)
|
||||
transcriptB := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", identity, "", transcriptA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifyHandshakePacket(packet, transcriptB); err == nil {
|
||||
t.Fatal("expected handshake to reject mismatched transcript (MITM scenario)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_TamperedSignatureFails(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", identity, "", transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet.GetHandshake().Signature[0] ^= 0xff
|
||||
if _, err := verifyHandshakePacket(packet, transcript); err == nil {
|
||||
t.Fatal("expected tampered signature to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_TamperedInstanceIdFails(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", identity, "", transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet.GetHandshake().InstanceId = "mallory"
|
||||
if _, err := verifyHandshakePacket(packet, transcript); err == nil {
|
||||
t.Fatal("expected tampered instance ID to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_TamperedPairingSecretFails(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", identity, "secret-1", transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet.GetHandshake().PairingSecret = "secret-2"
|
||||
if _, err := verifyHandshakePacket(packet, transcript); err == nil {
|
||||
t.Fatal("expected tampered pairing secret to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_WrongKeyFails(t *testing.T) {
|
||||
signer := newTestIdentity(t)
|
||||
imposter := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
|
||||
packet, err := createHandshakePacket("alice", signer, "", transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Substitute the imposter's public key (and matching keyid) — the
|
||||
// signature was made under the real signer's key, so verification must
|
||||
// fail.
|
||||
packet.GetHandshake().PublicKey = imposter.PublicKey.PublicKeyProto()
|
||||
if _, err := verifyHandshakePacket(packet, transcript); err == nil {
|
||||
t.Fatal("expected wrong public key to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_AuthorizationByKeyID(t *testing.T) {
|
||||
identity := newTestIdentity(t)
|
||||
transcript := freshTranscript(t)
|
||||
packet, err := createHandshakePacket("alice", identity, "", transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peer := &v1.Multihost_Peer{
|
||||
InstanceId: "alice",
|
||||
Keyid: identity.KeyID(),
|
||||
}
|
||||
if err := authorizeHandshakeAsPeer(packet, peer); err != nil {
|
||||
t.Fatalf("authorize matching peer: %v", err)
|
||||
}
|
||||
mismatchedInstance := &v1.Multihost_Peer{InstanceId: "bob", Keyid: identity.KeyID()}
|
||||
if err := authorizeHandshakeAsPeer(packet, mismatchedInstance); err == nil {
|
||||
t.Fatal("expected instance-ID mismatch to fail authorization")
|
||||
}
|
||||
mismatchedKey := &v1.Multihost_Peer{InstanceId: "alice", Keyid: "ed25519.bogus"}
|
||||
if err := authorizeHandshakeAsPeer(packet, mismatchedKey); err == nil {
|
||||
t.Fatal("expected key-ID mismatch to fail authorization")
|
||||
}
|
||||
}
|
||||
@@ -189,14 +189,22 @@ func TestPairingTokenFlow(t *testing.T) {
|
||||
// The client should successfully connect via the pairing token.
|
||||
tryConnect(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0])
|
||||
|
||||
// Verify the host now has the client in its authorized_clients.
|
||||
hostConfig, err := peerHost.configMgr.Get()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get host config: %v", err)
|
||||
}
|
||||
if len(hostConfig.Multihost.AuthorizedClients) != 1 {
|
||||
t.Fatalf("expected 1 authorized client, got %d", len(hostConfig.Multihost.AuthorizedClients))
|
||||
}
|
||||
// The server pairs the client during its own runSync handshake handling, which
|
||||
// runs concurrently with — and may finish slightly after — the client reaching
|
||||
// CONNECTED state. Poll for the host config to reflect the pairing.
|
||||
var hostConfig *v1.Config
|
||||
testutil.Try(t, ctx, func() error {
|
||||
var err error
|
||||
hostConfig, err = peerHost.configMgr.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get host config: %w", err)
|
||||
}
|
||||
if len(hostConfig.Multihost.AuthorizedClients) != 1 {
|
||||
return fmt.Errorf("expected 1 authorized client, got %d", len(hostConfig.Multihost.AuthorizedClients))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ac := hostConfig.Multihost.AuthorizedClients[0]
|
||||
if ac.Keyid != identity2.Keyid {
|
||||
t.Errorf("authorized client keyid = %q, want %q", ac.Keyid, identity2.Keyid)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"time"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
@@ -12,6 +14,69 @@ import (
|
||||
|
||||
const maxSignatureAge = 5 * time.Minute
|
||||
|
||||
// handshakeBindLabel is the domain-separation prefix for the bytes signed in
|
||||
// SyncActionHandshake.signature. Bumping it invalidates all old handshake
|
||||
// signatures, so change it only alongside SyncProtocolVersion.
|
||||
const handshakeBindLabel = "backrest-sync-handshake/v1\x00"
|
||||
|
||||
// computeHandshakeBindInput builds the byte string a peer signs (and the
|
||||
// other peer recomputes locally) for the post-encryption identity exchange.
|
||||
// Every field that influences peer authorization is length-prefixed so that
|
||||
// no two distinct (instance, secret, transcript) tuples can ever produce
|
||||
// the same input. The transport transcript ties the signature to the
|
||||
// specific post-quantum KEM exchange of this connection.
|
||||
func computeHandshakeBindInput(protocolVersion int64, instanceID, pairingSecret string, transcript []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(handshakeBindLabel))
|
||||
var versionBytes [8]byte
|
||||
binary.BigEndian.PutUint64(versionBytes[:], uint64(protocolVersion))
|
||||
h.Write(versionBytes[:])
|
||||
writeLengthPrefixedBytes(h, []byte(instanceID))
|
||||
writeLengthPrefixedBytes(h, []byte(pairingSecret))
|
||||
writeLengthPrefixedBytes(h, transcript)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func writeLengthPrefixedBytes(h hash.Hash, b []byte) {
|
||||
var lenBytes [4]byte
|
||||
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(b)))
|
||||
h.Write(lenBytes[:])
|
||||
h.Write(b)
|
||||
}
|
||||
|
||||
// signHandshake produces an ed25519 signature over the handshake bind input
|
||||
// for the given fields, under the caller's identity key.
|
||||
func signHandshake(protocolVersion int64, instanceID, pairingSecret string, transcript []byte, identity *cryptoutil.PrivateKey) ([]byte, error) {
|
||||
if len(transcript) == 0 {
|
||||
return nil, errors.New("transport transcript must not be empty")
|
||||
}
|
||||
bindInput := computeHandshakeBindInput(protocolVersion, instanceID, pairingSecret, transcript)
|
||||
sig, err := identity.Sign(bindInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing handshake: %w", err)
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// verifyHandshakeSignature recomputes the bind input from the locally-known
|
||||
// transcript and verifies the peer's signature against the peer's claimed
|
||||
// public key. A mismatch can mean: tampering, a MITM whose KEM produced a
|
||||
// different transcript on this leg, or the peer disagreeing on
|
||||
// protocol_version / instance_id / pairing_secret.
|
||||
func verifyHandshakeSignature(protocolVersion int64, instanceID, pairingSecret string, transcript, signature []byte, peerKey *cryptoutil.PublicKey) error {
|
||||
if len(transcript) == 0 {
|
||||
return errors.New("transport transcript must not be empty")
|
||||
}
|
||||
if len(signature) == 0 {
|
||||
return errors.New("handshake signature must not be empty")
|
||||
}
|
||||
bindInput := computeHandshakeBindInput(protocolVersion, instanceID, pairingSecret, transcript)
|
||||
if err := peerKey.Verify(bindInput, signature); err != nil {
|
||||
return fmt.Errorf("handshake signature: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSignedMessage(payload []byte, identity *cryptoutil.PrivateKey) (*v1.SignedMessage, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("payload must not be empty")
|
||||
|
||||
@@ -74,7 +74,7 @@ var (
|
||||
|
||||
func TestConnectionSucceeds(t *testing.T) {
|
||||
testutil.InstallZapLogger(t)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
||||
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
||||
@@ -121,7 +121,7 @@ func TestConnectionSucceeds(t *testing.T) {
|
||||
|
||||
func TestConnectionBadKeyRejected(t *testing.T) {
|
||||
testutil.InstallZapLogger(t)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
||||
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
||||
@@ -270,7 +270,7 @@ func TestSyncConfigChange(t *testing.T) {
|
||||
|
||||
func TestSimpleOperationSync(t *testing.T) {
|
||||
testutil.InstallZapLogger(t)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
||||
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,19 +38,31 @@ func runSync(
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Wait for the post-quantum transport transcript before constructing the
|
||||
// handshake packet. The handshake signature must commit to this transcript
|
||||
// so that an active MITM (which would produce a different transcript on
|
||||
// each leg of its KEM exchange) cannot relay a usable signature.
|
||||
transcript, err := commandStream.AwaitTranscript(ctx)
|
||||
if err != nil {
|
||||
return NewSyncErrorDisconnected(fmt.Errorf("awaiting transport transcript: %w", err))
|
||||
}
|
||||
|
||||
// send the initial handshake packet to the peer to establish the connection.
|
||||
handshakePacket, err := createHandshakePacket(localInstanceID, localKey, pairingSecret)
|
||||
handshakePacket, err := createHandshakePacket(localInstanceID, localKey, pairingSecret, transcript)
|
||||
if err != nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("creating handshake packet: %w", err))
|
||||
}
|
||||
commandStream.Send(handshakePacket)
|
||||
|
||||
// Wait for the handshake packet to be acknowledged by the peer.
|
||||
handshake := commandStream.ReceiveWithinDuration(15 * time.Second)
|
||||
if handshake == nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("no handshake packet received from peer within timeout"))
|
||||
handshake, err := commandStream.ReceiveWithinDuration(ctx, 15*time.Second)
|
||||
if err != nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("waiting for handshake packet from peer: %w", err))
|
||||
}
|
||||
if _, err := verifyHandshakePacket(handshake); err != nil {
|
||||
if handshake == nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("no handshake packet received from peer"))
|
||||
}
|
||||
if _, err := verifyHandshakePacket(handshake, transcript); err != nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("verifying handshake packet: %w", err))
|
||||
}
|
||||
|
||||
@@ -69,7 +81,7 @@ func runSync(
|
||||
}
|
||||
}
|
||||
if peer == nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("peer public key ID %s (instance ID %s) not found in known peers", handshake.GetHandshake().GetPublicKey().GetKeyid(), string(handshake.GetHandshake().GetInstanceId().GetPayload())))
|
||||
return NewSyncErrorAuth(fmt.Errorf("peer public key ID %s (instance ID %s) not found in known peers", handshake.GetHandshake().GetPublicKey().GetKeyid(), handshake.GetHandshake().GetInstanceId()))
|
||||
}
|
||||
|
||||
if err := authorizeHandshakeAsPeer(handshake, peer); err != nil {
|
||||
@@ -135,45 +147,55 @@ func runSync(
|
||||
return nil
|
||||
}
|
||||
|
||||
func createHandshakePacket(instanceID string, identity *cryptoutil.PrivateKey, pairingSecret string) (*v1sync.SyncStreamItem, error) {
|
||||
signedMessage, err := createSignedMessage([]byte(instanceID), identity)
|
||||
// createHandshakePacket builds the post-encryption identity handshake. The
|
||||
// signature binds the local identity, instance ID, pairing secret, and
|
||||
// protocol version to the post-quantum transport transcript provided by the
|
||||
// caller — there is no separate timestamp because freshness is guaranteed
|
||||
// by the ephemeral KEM.
|
||||
func createHandshakePacket(instanceID string, identity *cryptoutil.PrivateKey, pairingSecret string, transcript []byte) (*v1sync.SyncStreamItem, error) {
|
||||
if len(transcript) == 0 {
|
||||
return nil, errors.New("transport transcript must not be empty")
|
||||
}
|
||||
signature, err := signHandshake(SyncProtocolVersion, instanceID, pairingSecret, transcript, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing instance ID: %w", err)
|
||||
return nil, fmt.Errorf("signing handshake: %w", err)
|
||||
}
|
||||
|
||||
return &v1sync.SyncStreamItem{
|
||||
Action: &v1sync.SyncStreamItem_Handshake{
|
||||
Handshake: &v1sync.SyncStreamItem_SyncActionHandshake{
|
||||
ProtocolVersion: SyncProtocolVersion,
|
||||
InstanceId: signedMessage,
|
||||
PublicKey: identity.PublicKeyProto(),
|
||||
InstanceId: instanceID,
|
||||
PairingSecret: pairingSecret,
|
||||
Signature: signature,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyHandshakePacket verifies that
|
||||
// - the signature on the instance ID is valid against the public key provided in the handshake
|
||||
// - that the public key's ID is as attested in the handshake packet e.g. matches handshake.PublicKey.Keyid
|
||||
// verifyHandshakePacket validates the peer's handshake against:
|
||||
// - the expected protocol version
|
||||
// - the consistency of the public key bytes / keyid in the proto
|
||||
// - the ed25519 signature, recomputing the bind input from the locally-known
|
||||
// transport transcript
|
||||
//
|
||||
// To authenticate, the caller must then check that the public key is trusted by checking the key ID against a local list.
|
||||
func verifyHandshakePacket(item *v1sync.SyncStreamItem) (*cryptoutil.PublicKey, error) {
|
||||
// A signature failure can mean tampering, a MITM whose KEM produced a
|
||||
// different transcript on this leg, or a peer disagreement on protocol
|
||||
// version / instance / pairing secret. Authorization (matching the verified
|
||||
// peer key against a trust list) is the caller's responsibility.
|
||||
func verifyHandshakePacket(item *v1sync.SyncStreamItem, transcript []byte) (*cryptoutil.PublicKey, error) {
|
||||
handshake := item.GetHandshake()
|
||||
if handshake == nil {
|
||||
return nil, fmt.Errorf("empty or nil handshake, handshake packet must be sent first")
|
||||
}
|
||||
|
||||
if handshake.ProtocolVersion != SyncProtocolVersion {
|
||||
return nil, fmt.Errorf("protocol version mismatch: expected %d, got %d", SyncProtocolVersion, handshake.ProtocolVersion)
|
||||
}
|
||||
|
||||
if len(handshake.InstanceId.GetPayload()) == 0 || len(handshake.InstanceId.GetSignature()) == 0 {
|
||||
return nil, errors.New("instance ID payload and signature must not be empty")
|
||||
if handshake.GetPublicKey() == nil || len(handshake.GetPublicKey().GetKeyid()) == 0 {
|
||||
return nil, errors.New("public key and key ID must not be empty")
|
||||
}
|
||||
|
||||
if len(handshake.PublicKey.Keyid) == 0 {
|
||||
return nil, errors.New("public key ID must not be empty")
|
||||
if len(handshake.InstanceId) == 0 {
|
||||
return nil, errors.New("instance ID must not be empty")
|
||||
}
|
||||
|
||||
peerKey, err := cryptoutil.NewPublicKey(handshake.PublicKey)
|
||||
@@ -181,8 +203,8 @@ func verifyHandshakePacket(item *v1sync.SyncStreamItem) (*cryptoutil.PublicKey,
|
||||
return nil, fmt.Errorf("loading peer public key: %w", err)
|
||||
}
|
||||
|
||||
if err := verifySignedMessage(handshake.InstanceId, peerKey); err != nil {
|
||||
return nil, fmt.Errorf("verifying instance ID signature: %w", err)
|
||||
if err := verifyHandshakeSignature(handshake.ProtocolVersion, handshake.InstanceId, handshake.PairingSecret, transcript, handshake.Signature, peerKey); err != nil {
|
||||
return nil, fmt.Errorf("verifying handshake: %w", err)
|
||||
}
|
||||
|
||||
return peerKey, nil
|
||||
@@ -195,8 +217,8 @@ func authorizeHandshakeAsPeer(item *v1sync.SyncStreamItem, peer *v1.Multihost_Pe
|
||||
if handshake == nil {
|
||||
return fmt.Errorf("empty or nil handshake, handshake packet must be sent first")
|
||||
}
|
||||
if string(handshake.GetInstanceId().GetPayload()) != peer.InstanceId {
|
||||
return fmt.Errorf("instance ID mismatch: expected %s, got %s", peer.InstanceId, string(handshake.InstanceId.GetPayload()))
|
||||
if handshake.InstanceId != peer.InstanceId {
|
||||
return fmt.Errorf("instance ID mismatch: expected %s, got %s", peer.InstanceId, handshake.InstanceId)
|
||||
}
|
||||
if handshake.GetPublicKey().GetKeyid() != peer.Keyid {
|
||||
return fmt.Errorf("public key ID mismatch: expected %s, got %s", peer.Keyid, handshake.PublicKey.Keyid)
|
||||
|
||||
@@ -18,7 +18,11 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const SyncProtocolVersion = 1
|
||||
// SyncProtocolVersion is the application-layer handshake version. Bumped to 3
|
||||
// when the handshake was redesigned to carry a single signature that binds
|
||||
// the long-term identity to the post-quantum transport transcript (and to
|
||||
// drop the legacy v1.SignedMessage instance_id wrapping).
|
||||
const SyncProtocolVersion = 3
|
||||
|
||||
type BackrestSyncHandler struct {
|
||||
v1syncconnect.UnimplementedBackrestSyncServiceHandler
|
||||
@@ -64,7 +68,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) {
|
||||
@@ -428,6 +432,12 @@ func ValidatePairingSecret(secret string, tokens []*v1.Multihost_PairingToken, n
|
||||
// from the handshake, adds the client to authorized_clients in the config, and consumes the token.
|
||||
// The peer is added to the config BEFORE runSync proceeds with its normal authorization check,
|
||||
// ensuring that runSync's hard gate (peer must be in authorized_clients) is never bypassed.
|
||||
//
|
||||
// runSync has already verified the handshake signature against the transport
|
||||
// transcript before invoking this callback, so signature validity (and
|
||||
// therefore the client's possession of the private key) is established by
|
||||
// the time we get here. The pairing token check below is the only remaining
|
||||
// authorization step.
|
||||
func (h *BackrestSyncHandler) handleUnknownPeerPairing(snapshot *syncConfigSnapshot) onUnknownPeerFunc {
|
||||
return func(handshake *v1sync.SyncStreamItem) (*v1.Multihost_Peer, error) {
|
||||
pairingSecret := handshake.GetHandshake().GetPairingSecret()
|
||||
@@ -435,16 +445,8 @@ func (h *BackrestSyncHandler) handleUnknownPeerPairing(snapshot *syncConfigSnaps
|
||||
return nil, fmt.Errorf("unknown peer and no pairing secret provided")
|
||||
}
|
||||
|
||||
// Defense-in-depth: re-verify the handshake signature to ensure the client
|
||||
// holds the private key for the public key it presents. This is already checked
|
||||
// by verifyHandshakePacket in runSync, but we verify again here since this is
|
||||
// a security-critical path that adds a new authorized client.
|
||||
if _, err := verifyHandshakePacket(handshake); err != nil {
|
||||
return nil, fmt.Errorf("handshake signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
peerKeyID := handshake.GetHandshake().GetPublicKey().GetKeyid()
|
||||
peerInstanceID := string(handshake.GetHandshake().GetInstanceId().GetPayload())
|
||||
peerInstanceID := handshake.GetHandshake().GetInstanceId()
|
||||
|
||||
// Atomically validate the pairing secret and add the client.
|
||||
var newPeer *v1.Multihost_Peer
|
||||
|
||||
@@ -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,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,202 @@
|
||||
package cryptoutil
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 higher-layer 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.
|
||||
//
|
||||
// Identity authentication is the responsibility of the caller. A higher
|
||||
// layer that performs ed25519 (or any other) identity verification should
|
||||
// have each peer sign Transcript() under its long-term key and exchange the
|
||||
// signatures over the encrypted channel; verifying that signature is what
|
||||
// defeats a MITM that completes a separate KEM with each side, since the
|
||||
// two legs of the MITM produce different transcripts and the legitimate
|
||||
// peer's signature only commits to its own transcript.
|
||||
type TransportSession struct {
|
||||
Send cipher.AEAD
|
||||
Recv cipher.AEAD
|
||||
|
||||
transcript [sha256.Size]byte
|
||||
}
|
||||
|
||||
// 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 identity key and send
|
||||
// the signature to the peer to authenticate the channel.
|
||||
func (s *TransportSession) Transcript() []byte {
|
||||
out := make([]byte, len(s.transcript))
|
||||
copy(out, s.transcript[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
i2r, r2i, err := deriveDirectionalAEADs(recipient.Export)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Initiator: Send is initiator-to-responder, Recv is responder-to-initiator.
|
||||
return &TransportSession{
|
||||
Send: i2r,
|
||||
Recv: r2i,
|
||||
transcript: computeTranscript(r.pub, enc),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
i2r, r2i, err := deriveDirectionalAEADs(sender.Export)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Responder: Send is responder-to-initiator, Recv is initiator-to-responder.
|
||||
return enc, &TransportSession{
|
||||
Send: r2i,
|
||||
Recv: i2r,
|
||||
transcript: computeTranscript(peerPubBytes, enc),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deriveDirectionalAEADs exports two independent AES-256-GCM keys from the
|
||||
// HPKE context — one for each traffic direction — and wraps them in AEADs.
|
||||
// The caller assigns them to Send/Recv based on its role.
|
||||
func deriveDirectionalAEADs(exporter func(string, int) ([]byte, error)) (i2r, r2i cipher.AEAD, _ error) {
|
||||
i2rKey, err := exporter(exporterLabelI2R(), transportSessionKeyLen)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("export i2r session key: %w", err)
|
||||
}
|
||||
r2iKey, err := exporter(exporterLabelR2I(), transportSessionKeyLen)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("export r2i session key: %w", err)
|
||||
}
|
||||
i2rAEAD, err := newSessionAEAD(i2rKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
r2iAEAD, err := newSessionAEAD(r2iKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return i2rAEAD, r2iAEAD, 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,194 @@
|
||||
package cryptoutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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)
|
||||
}
|
||||
|
||||
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_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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"github.com/ncruces/go-sqlite3"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
)
|
||||
|
||||
// init kicks off wazero's compilation of the embedded SQLite WASM binary in
|
||||
// the background. The compile is guarded by a sync.Once inside go-sqlite3, so
|
||||
// any later sql.Open call simply waits on the same Once if it isn't done yet.
|
||||
// The goal is to move this multi-second cost off the critical path of the
|
||||
// first sqlite open, which otherwise inflates startup latency and burns into
|
||||
// per-test deadlines (notably under -race).
|
||||
func init() {
|
||||
go sqlite3.Initialize()
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -135,13 +135,50 @@ message SyncStreamItem {
|
||||
SyncActionReceiveLogData receive_log_data = 31;
|
||||
|
||||
SyncActionThrottle throttle = 1000;
|
||||
|
||||
SyncEstablishSharedSecret establish_shared_secret = 2;
|
||||
SyncActionEncrypted encrypted = 5;
|
||||
}
|
||||
|
||||
// SyncActionHandshake is the first message sent by each peer over the
|
||||
// post-quantum encrypted channel. It carries the sender's long-term
|
||||
// ed25519 identity, its instance ID, and a single signature that binds
|
||||
// the identity to *this* transport session.
|
||||
//
|
||||
// The signature covers a domain-separated hash of:
|
||||
// "backrest-sync-handshake/v1\x00"
|
||||
// || protocol_version (8 bytes BE)
|
||||
// || LP(instance_id)
|
||||
// || LP(pairing_secret)
|
||||
// || LP(transport transcript)
|
||||
//
|
||||
// where LP(x) = 4-byte BE length prefix || x, and the transport transcript
|
||||
// is cryptoutil.TransportSession.Transcript() — a hash that commits to
|
||||
// the ephemeral KEM messages of this connection.
|
||||
//
|
||||
// The transcript binding is what defeats a MITM that completes a separate
|
||||
// KEM with each side: each leg has a different transcript, and the
|
||||
// legitimate peer's signature only commits to its own transcript, so the
|
||||
// attacker cannot forward a usable signature to either side.
|
||||
//
|
||||
// Receivers MUST recompute the transcript locally from their TransportSession
|
||||
// and reject the handshake if the signature does not verify against
|
||||
// public_key. There is no timestamp because freshness is provided by the
|
||||
// ephemeral KEM, not by clock comparison.
|
||||
message SyncActionHandshake {
|
||||
int64 protocol_version = 1;
|
||||
v1.PublicKey public_key = 2;
|
||||
v1.SignedMessage instance_id = 3;
|
||||
string pairing_secret = 4; // optional one-time secret from a pairing token, used to auto-authorize a new client
|
||||
v1.PublicKey public_key = 2; // sender's long-term ed25519 identity
|
||||
string instance_id = 3; // covered by signature below
|
||||
string pairing_secret = 4; // optional pairing token; covered by signature below
|
||||
bytes signature = 5; // ed25519(public_key, H(handshake bind input))
|
||||
}
|
||||
|
||||
// SyncActionEncrypted wraps an encrypted SyncStreamItem.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// SyncActionHeartbeat is sent periodically to keep the connection alive.
|
||||
@@ -212,9 +249,19 @@ message SyncStreamItem {
|
||||
int64 delay_ms = 1;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// a one-time-use ECDSA public key with a matching unshared private key. Used to perform a key exchange.
|
||||
// See https://pkg.go.dev/crypto/ecdh#PrivateKey.ECDH .
|
||||
string ecdsa_pub = 2 [json_name="ecdsaPub"]; // base64 encoded public key
|
||||
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