diff --git a/gen/go/v1/crypto.pb.go b/gen/go/v1/crypto.pb.go index 86c6d9d1..78679cd8 100644 --- a/gen/go/v1/crypto.pb.go +++ b/gen/go/v1/crypto.pb.go @@ -92,7 +92,7 @@ func (x *SignedMessage) GetTimestampMillis() int64 { type PublicKey struct { state protoimpl.MessageState `protogen:"open.v1"` Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key. - EcdsaPub string `protobuf:"bytes,2,opt,name=ecdsa_pub,json=ecdsaPub,proto3" json:"ecdsa_pub,omitempty"` + Ed25519Pub string `protobuf:"bytes,2,opt,name=ed25519pub,proto3" json:"ed25519pub,omitempty"` // raw base64-encoded ed25519 public key. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -134,18 +134,18 @@ func (x *PublicKey) GetKeyid() string { return "" } -func (x *PublicKey) GetEcdsaPub() string { +func (x *PublicKey) GetEd25519Pub() string { if x != nil { - return x.EcdsaPub + return x.Ed25519Pub } return "" } type PrivateKey struct { state protoimpl.MessageState `protogen:"open.v1"` - Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key - EcdsaPriv string `protobuf:"bytes,2,opt,name=ecdsa_priv,json=ecdsaPriv,proto3" json:"ecdsa_priv,omitempty"` - EcdsaPub string `protobuf:"bytes,3,opt,name=ecdsa_pub,json=ecdsaPub,proto3" json:"ecdsa_pub,omitempty"` + Keyid string `protobuf:"bytes,1,opt,name=keyid,json=keyId,proto3" json:"keyid,omitempty"` // a unique identifier generated as the SHA256 of the public key + Ed25519Priv string `protobuf:"bytes,2,opt,name=ed25519priv,proto3" json:"ed25519priv,omitempty"` // raw base64-encoded ed25519 private key seed. + Ed25519Pub string `protobuf:"bytes,3,opt,name=ed25519pub,proto3" json:"ed25519pub,omitempty"` // raw base64-encoded ed25519 public key. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -187,16 +187,16 @@ func (x *PrivateKey) GetKeyid() string { return "" } -func (x *PrivateKey) GetEcdsaPriv() string { +func (x *PrivateKey) GetEd25519Priv() string { if x != nil { - return x.EcdsaPriv + return x.Ed25519Priv } return "" } -func (x *PrivateKey) GetEcdsaPub() string { +func (x *PrivateKey) GetEd25519Pub() string { if x != nil { - return x.EcdsaPub + return x.Ed25519Pub } return "" } @@ -210,16 +210,19 @@ const file_v1_crypto_proto_rawDesc = "" + "\x05keyid\x18\x01 \x01(\tR\x05keyid\x12\x18\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x1c\n" + "\tsignature\x18\x03 \x01(\fR\tsignature\x12(\n" + - "\x0ftimestampMillis\x18\x04 \x01(\x03R\x0ftimestampMillis\">\n" + + "\x0ftimestampMillis\x18\x04 \x01(\x03R\x0ftimestampMillis\"A\n" + "\tPublicKey\x12\x14\n" + - "\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1b\n" + - "\tecdsa_pub\x18\x02 \x01(\tR\becdsaPub\"^\n" + + "\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1e\n" + + "\n" + + "ed25519pub\x18\x02 \x01(\tR\n" + + "ed25519pub\"d\n" + "\n" + "PrivateKey\x12\x14\n" + - "\x05keyid\x18\x01 \x01(\tR\x05keyId\x12\x1d\n" + + "\x05keyid\x18\x01 \x01(\tR\x05keyId\x12 \n" + + "\ved25519priv\x18\x02 \x01(\tR\ved25519priv\x12\x1e\n" + "\n" + - "ecdsa_priv\x18\x02 \x01(\tR\tecdsaPriv\x12\x1b\n" + - "\tecdsa_pub\x18\x03 \x01(\tR\becdsaPubB,Z*github.com/garethgeorge/backrest/gen/go/v1b\x06proto3" + "ed25519pub\x18\x03 \x01(\tR\n" + + "ed25519pubB,Z*github.com/garethgeorge/backrest/gen/go/v1b\x06proto3" var ( file_v1_crypto_proto_rawDescOnce sync.Once diff --git a/gen/go/v1sync/syncservice.pb.go b/gen/go/v1sync/syncservice.pb.go index 51c23f79..e8d25e66 100644 --- a/gen/go/v1sync/syncservice.pb.go +++ b/gen/go/v1sync/syncservice.pb.go @@ -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, }, diff --git a/go.mod b/go.mod index 62e51fed..f7d8d28d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/garethgeorge/backrest -go 1.25 +go 1.26 require ( al.essio.dev/pkg/shellescape v1.6.0 diff --git a/internal/api/syncapi/cmdstreamutil.go b/internal/api/syncapi/cmdstreamutil.go index 3db793cb..75fe8f90 100644 --- a/internal/api/syncapi/cmdstreamutil.go +++ b/internal/api/syncapi/cmdstreamutil.go @@ -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 +} diff --git a/internal/api/syncapi/encryption.go b/internal/api/syncapi/encryption.go new file mode 100644 index 00000000..2f4cfb23 --- /dev/null +++ b/internal/api/syncapi/encryption.go @@ -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 +} diff --git a/internal/api/syncapi/encryption_test.go b/internal/api/syncapi/encryption_test.go new file mode 100644 index 00000000..5a788dd7 --- /dev/null +++ b/internal/api/syncapi/encryption_test.go @@ -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") + } +} diff --git a/internal/api/syncapi/handshake_test.go b/internal/api/syncapi/handshake_test.go new file mode 100644 index 00000000..8207fe92 --- /dev/null +++ b/internal/api/syncapi/handshake_test.go @@ -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") + } +} diff --git a/internal/api/syncapi/pairing_test.go b/internal/api/syncapi/pairing_test.go index 6cb197e1..683315c9 100644 --- a/internal/api/syncapi/pairing_test.go +++ b/internal/api/syncapi/pairing_test.go @@ -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) diff --git a/internal/api/syncapi/signing.go b/internal/api/syncapi/signing.go index e149618a..8a979eb2 100644 --- a/internal/api/syncapi/signing.go +++ b/internal/api/syncapi/signing.go @@ -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") diff --git a/internal/api/syncapi/syncapi_test.go b/internal/api/syncapi/syncapi_test.go index 01ab300f..d0a4939e 100644 --- a/internal/api/syncapi/syncapi_test.go +++ b/internal/api/syncapi/syncapi_test.go @@ -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) diff --git a/internal/api/syncapi/syncclient.go b/internal/api/syncapi/syncclient.go index e76dbd71..67deb98f 100644 --- a/internal/api/syncapi/syncclient.go +++ b/internal/api/syncapi/syncclient.go @@ -115,7 +115,7 @@ func (c *SyncClient) RunSync(ctx context.Context) { cmdStream.SendErrorAndTerminate(err) }() - connectErr := cmdStream.ConnectStream(ctx, c.client.Sync(ctx)) + connectErr := cmdStream.ConnectStream(ctx, c.client.Sync(ctx), true /* isInitiator: client side */) if connectErr != nil { c.l.Sugar().Infof("lost stream connection to peer %q (%s): %v", c.peer.InstanceId, c.peer.Keyid, connectErr) var syncErr *SyncError diff --git a/internal/api/syncapi/synccommon.go b/internal/api/syncapi/synccommon.go index 30974f83..bf2dfa5e 100644 --- a/internal/api/syncapi/synccommon.go +++ b/internal/api/syncapi/synccommon.go @@ -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) diff --git a/internal/api/syncapi/syncserver.go b/internal/api/syncapi/syncserver.go index c4f6e795..691b09f9 100644 --- a/internal/api/syncapi/syncserver.go +++ b/internal/api/syncapi/syncserver.go @@ -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 diff --git a/internal/config/migrations/006ed25519identity.go b/internal/config/migrations/006ed25519identity.go new file mode 100644 index 00000000..5dfffb67 --- /dev/null +++ b/internal/config/migrations/006ed25519identity.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "strings" + + v1 "github.com/garethgeorge/backrest/gen/go/v1" + "go.uber.org/zap" +) + +// migration006Ed25519Identity drops any existing multihost identity that does +// not use the ed25519 key scheme. The previous ECDSA-based identity used +// PEM-encoded keys with an "ecdsa." keyid prefix; those keys are no longer +// readable by the cryptoutil package. Clearing the identity here lets +// PopulateRequiredFields generate a fresh ed25519 identity on next load. +var migration006Ed25519Identity = func(config *v1.Config) error { + multihost := config.GetMultihost() + if multihost == nil { + return nil + } + identity := multihost.GetIdentity() + if identity == nil { + return nil + } + if strings.HasPrefix(identity.GetKeyid(), "ed25519.") && identity.GetEd25519Priv() != "" && identity.GetEd25519Pub() != "" { + return nil + } + zap.S().Warnf("dropping legacy multihost identity %q; a new ed25519 identity will be generated", identity.GetKeyid()) + multihost.Identity = nil + return nil +} diff --git a/internal/config/migrations/migrations.go b/internal/config/migrations/migrations.go index 41a63b86..bb29b598 100644 --- a/internal/config/migrations/migrations.go +++ b/internal/config/migrations/migrations.go @@ -14,6 +14,7 @@ var migrations = []*func(*v1.Config) error{ &migration003RelativeScheduling, &migration004RepoGuid, &migration005CheckRepoPasswords, + &migration006Ed25519Identity, } var CurrentVersion = int32(len(migrations)) diff --git a/internal/config/networksanitize_test.go b/internal/config/networksanitize_test.go index 341e4e4a..72cca5d0 100644 --- a/internal/config/networksanitize_test.go +++ b/internal/config/networksanitize_test.go @@ -25,8 +25,8 @@ func TestSanitizeForNetwork(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "test-private-key", - EcdsaPub: "test-public-key", + Ed25519Priv: "test-private-key", + Ed25519Pub: "test-public-key", }, }, }, @@ -34,8 +34,8 @@ func TestSanitizeForNetwork(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "", - EcdsaPub: "", + Ed25519Priv: "", + Ed25519Pub: "", }, }, }, @@ -85,8 +85,8 @@ func TestSanitizeForNetwork(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "secret-key", - EcdsaPub: "public-key", + Ed25519Priv: "secret-key", + Ed25519Pub: "public-key", }, }, Auth: &v1.Auth{ @@ -104,8 +104,8 @@ func TestSanitizeForNetwork(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "", - EcdsaPub: "", + Ed25519Priv: "", + Ed25519Pub: "", }, }, Auth: &v1.Auth{ @@ -174,8 +174,8 @@ func TestRehydrateNetworkSanitizedConfig(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "secret-key-data", - EcdsaPub: "public-key-data", + Ed25519Priv: "secret-key-data", + Ed25519Pub: "public-key-data", }, }, }, @@ -183,8 +183,8 @@ func TestRehydrateNetworkSanitizedConfig(t *testing.T) { Multihost: &v1.Multihost{ Identity: &v1.PrivateKey{ Keyid: "test-key-id", - EcdsaPriv: "secret-key-data", - EcdsaPub: "public-key-data", + Ed25519Priv: "secret-key-data", + Ed25519Pub: "public-key-data", }, }, }, diff --git a/internal/cryptoutil/identity.go b/internal/cryptoutil/identity.go index 307d3726..2da9f395 100644 --- a/internal/cryptoutil/identity.go +++ b/internal/cryptoutil/identity.go @@ -1,13 +1,11 @@ package cryptoutil import ( - "crypto/ecdsa" - "crypto/elliptic" + "bytes" + "crypto/ed25519" "crypto/rand" "crypto/sha256" - "crypto/x509" "encoding/base64" - "encoding/pem" "errors" "fmt" @@ -15,38 +13,30 @@ import ( "google.golang.org/protobuf/proto" ) -var ( - curve = elliptic.P256() -) +const keyIDPrefix = "ed25519." type PublicKey struct { proto *v1.PublicKey - publicCryptoKey ecdsa.PublicKey + publicCryptoKey ed25519.PublicKey } func NewPublicKey(pubkey *v1.PublicKey) (*PublicKey, error) { - pubKeyBlock, _ := pem.Decode([]byte(pubkey.EcdsaPub)) - if pubKeyBlock == nil { - return nil, errors.New("no public key found in pem") - } - - pkixPubKey, err := x509.ParsePKIXPublicKey(pubKeyBlock.Bytes) + pubBytes, err := base64.RawStdEncoding.DecodeString(pubkey.Ed25519Pub) if err != nil { - return nil, fmt.Errorf("parse public key: %w", err) + return nil, fmt.Errorf("decode public key: %w", err) + } + if len(pubBytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid ed25519 public key size: got %d, want %d", len(pubBytes), ed25519.PublicKeySize) } - ecdsaPubKey, ok := pkixPubKey.(*ecdsa.PublicKey) - if !ok { - return nil, errors.New("not an ECDSA public key") - } - - if derived := deriveKeyId(ecdsaPubKey); derived != pubkey.Keyid { + edPubKey := ed25519.PublicKey(pubBytes) + if derived := deriveKeyId(edPubKey); derived != pubkey.Keyid { return nil, fmt.Errorf("public key_id provided does not match the derived key: %s != %s", derived, pubkey.Keyid) } return &PublicKey{ proto: pubkey, - publicCryptoKey: *ecdsaPubKey, + publicCryptoKey: edPubKey, }, nil } @@ -58,10 +48,8 @@ func (pk *PublicKey) PublicKeyProto() *v1.PublicKey { return proto.Clone(pk.proto).(*v1.PublicKey) } -// VerifySignature verifies the signature of a message func (pk *PublicKey) Verify(message, sig []byte) error { - hash := sha256.Sum256(message) - if !ecdsa.VerifyASN1(&pk.publicCryptoKey, hash[:], sig) { + if !ed25519.Verify(pk.publicCryptoKey, message, sig) { return errors.New("signature verification failed") } return nil @@ -70,61 +58,49 @@ func (pk *PublicKey) Verify(message, sig []byte) error { type PrivateKey struct { *PublicKey proto *v1.PrivateKey - privateCryptoKey *ecdsa.PrivateKey + privateCryptoKey ed25519.PrivateKey } func NewPrivateKey(privkey *v1.PrivateKey) (*PrivateKey, error) { - privKeyBlock, _ := pem.Decode([]byte(privkey.EcdsaPriv)) - if privKeyBlock == nil { - return nil, errors.New("no private key found in pem") - } - - ecdsaPrivKey, err := x509.ParseECPrivateKey(privKeyBlock.Bytes) + seed, err := base64.RawStdEncoding.DecodeString(privkey.Ed25519Priv) if err != nil { - return nil, fmt.Errorf("parse private key: %w", err) + return nil, fmt.Errorf("decode private key: %w", err) } + if len(seed) != ed25519.SeedSize { + return nil, fmt.Errorf("invalid ed25519 private key seed size: got %d, want %d", len(seed), ed25519.SeedSize) + } + edPrivKey := ed25519.NewKeyFromSeed(seed) pubKey, err := NewPublicKey(&v1.PublicKey{ Keyid: privkey.Keyid, - EcdsaPub: privkey.EcdsaPub, + Ed25519Pub: privkey.Ed25519Pub, }) if err != nil { return nil, err } - if ecdsaPrivKey.PublicKey.X.Cmp(pubKey.publicCryptoKey.X) != 0 || - ecdsaPrivKey.PublicKey.Y.Cmp(pubKey.publicCryptoKey.Y) != 0 { + derivedPub := edPrivKey.Public().(ed25519.PublicKey) + if !bytes.Equal(derivedPub, pubKey.publicCryptoKey) { return nil, errors.New("private key does not match public key") } return &PrivateKey{ PublicKey: pubKey, proto: privkey, - privateCryptoKey: ecdsaPrivKey, + privateCryptoKey: edPrivKey, }, nil } func GeneratePrivateKey() (*v1.PrivateKey, error) { - privKey, err := ecdsa.GenerateKey(curve, rand.Reader) + pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { - return nil, err + return nil, fmt.Errorf("generate ed25519 key: %w", err) } - privateKeyBytes, err := x509.MarshalECPrivateKey(privKey) - if err != nil { - return nil, fmt.Errorf("marshal private key: %w", err) - } - pemPrivateKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE", Bytes: privateKeyBytes}) - publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) - if err != nil { - return nil, fmt.Errorf("marshal public key: %w", err) - } - pemPublicKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PUBLIC", Bytes: publicKeyBytes}) - return &v1.PrivateKey{ - Keyid: deriveKeyId(&privKey.PublicKey), - EcdsaPriv: string(pemPrivateKeyBytes), - EcdsaPub: string(pemPublicKeyBytes), + Keyid: deriveKeyId(pub), + Ed25519Priv: base64.RawStdEncoding.EncodeToString(priv.Seed()), + Ed25519Pub: base64.RawStdEncoding.EncodeToString(pub), }, nil } @@ -132,19 +108,11 @@ func (pk *PrivateKey) PrivateKeyProto() *v1.PrivateKey { return proto.Clone(pk.proto).(*v1.PrivateKey) } -// SignMessage signs a message using the private key func (pk *PrivateKey) Sign(message []byte) ([]byte, error) { - hash := sha256.Sum256(message) - sig, err := ecdsa.SignASN1(rand.Reader, pk.privateCryptoKey, hash[:]) - if err != nil { - return nil, fmt.Errorf("sign message: %w", err) - } - return sig, nil + return ed25519.Sign(pk.privateCryptoKey, message), nil } -func deriveKeyId(key *ecdsa.PublicKey) string { - shasum := sha256.New() - shasum.Write(key.X.Bytes()) - shasum.Write(key.Y.Bytes()) - return "ecdsa." + base64.RawURLEncoding.EncodeToString(shasum.Sum(nil)) +func deriveKeyId(key ed25519.PublicKey) string { + shasum := sha256.Sum256(key) + return keyIDPrefix + base64.RawURLEncoding.EncodeToString(shasum[:]) } diff --git a/internal/cryptoutil/identity_test.go b/internal/cryptoutil/identity_test.go index cb19ebd5..9cd091e5 100644 --- a/internal/cryptoutil/identity_test.go +++ b/internal/cryptoutil/identity_test.go @@ -1,6 +1,7 @@ package cryptoutil import ( + "strings" "testing" ) @@ -10,13 +11,17 @@ func TestGenerateKeypair(t *testing.T) { t.Fatalf("failed to generate key pair: %v", err) } - if len(privateKey.EcdsaPriv) == 0 { + if len(privateKey.Ed25519Priv) == 0 { t.Fatalf("must populate private key") } - if len(privateKey.EcdsaPub) == 0 { + if len(privateKey.Ed25519Pub) == 0 { t.Fatalf("must populate public key") } + + if !strings.HasPrefix(privateKey.Keyid, "ed25519.") { + t.Fatalf("expected keyid to use ed25519. prefix, got %q", privateKey.Keyid) + } } func TestLoadKey(t *testing.T) { diff --git a/internal/cryptoutil/transport.go b/internal/cryptoutil/transport.go new file mode 100644 index 00000000..7df0dfa1 --- /dev/null +++ b/internal/cryptoutil/transport.go @@ -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 +} diff --git a/internal/cryptoutil/transport_test.go b/internal/cryptoutil/transport_test.go new file mode 100644 index 00000000..de692e97 --- /dev/null +++ b/internal/cryptoutil/transport_test.go @@ -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") + } +} diff --git a/internal/oplog/sqlitestore/prewarm.go b/internal/oplog/sqlitestore/prewarm.go new file mode 100644 index 00000000..6f1ab3bb --- /dev/null +++ b/internal/oplog/sqlitestore/prewarm.go @@ -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() +} diff --git a/proto/v1/crypto.proto b/proto/v1/crypto.proto index 778fd15a..9427b065 100644 --- a/proto/v1/crypto.proto +++ b/proto/v1/crypto.proto @@ -13,11 +13,11 @@ message SignedMessage { message PublicKey { string keyid = 1 [json_name="keyId"]; // a unique identifier generated as the SHA256 of the public key. - string ecdsa_pub = 2 [json_name="ecdsaPub"]; + string ed25519pub = 2 [json_name="ed25519pub"]; // raw base64-encoded ed25519 public key. } message PrivateKey { string keyid = 1 [json_name="keyId"]; // a unique identifier generated as the SHA256 of the public key - string ecdsa_priv = 2 [json_name="ecdsaPriv"]; - string ecdsa_pub = 3 [json_name="ecdsaPub"]; + string ed25519priv = 2 [json_name="ed25519priv"]; // raw base64-encoded ed25519 private key seed. + string ed25519pub = 3 [json_name="ed25519pub"]; // raw base64-encoded ed25519 public key. } diff --git a/proto/v1sync/syncservice.proto b/proto/v1sync/syncservice.proto index 00c73437..75b3d885 100644 --- a/proto/v1sync/syncservice.proto +++ b/proto/v1sync/syncservice.proto @@ -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 } } diff --git a/webui/gen/ts/v1/crypto_pb.ts b/webui/gen/ts/v1/crypto_pb.ts index 302bdc9b..3cf05bdc 100644 --- a/webui/gen/ts/v1/crypto_pb.ts +++ b/webui/gen/ts/v1/crypto_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1/crypto.proto. */ export const file_v1_crypto: GenFile = /*@__PURE__*/ - fileDesc("Cg92MS9jcnlwdG8ucHJvdG8SAnYxIlsKDVNpZ25lZE1lc3NhZ2USDQoFa2V5aWQYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIRCglzaWduYXR1cmUYAyABKAwSFwoPdGltZXN0YW1wTWlsbGlzGAQgASgDIjQKCVB1YmxpY0tleRIUCgVrZXlpZBgBIAEoCVIFa2V5SWQSEQoJZWNkc2FfcHViGAIgASgJIkkKClByaXZhdGVLZXkSFAoFa2V5aWQYASABKAlSBWtleUlkEhIKCmVjZHNhX3ByaXYYAiABKAkSEQoJZWNkc2FfcHViGAMgASgJQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z"); + fileDesc("Cg92MS9jcnlwdG8ucHJvdG8SAnYxIlsKDVNpZ25lZE1lc3NhZ2USDQoFa2V5aWQYASABKAkSDwoHcGF5bG9hZBgCIAEoDBIRCglzaWduYXR1cmUYAyABKAwSFwoPdGltZXN0YW1wTWlsbGlzGAQgASgDIjUKCVB1YmxpY0tleRIUCgVrZXlpZBgBIAEoCVIFa2V5SWQSEgoKZWQyNTUxOXB1YhgCIAEoCSJLCgpQcml2YXRlS2V5EhQKBWtleWlkGAEgASgJUgVrZXlJZBITCgtlZDI1NTE5cHJpdhgCIAEoCRISCgplZDI1NTE5cHViGAMgASgJQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z"); /** * @generated from message v1.SignedMessage @@ -64,9 +64,11 @@ export type PublicKey = Message<"v1.PublicKey"> & { keyid: string; /** - * @generated from field: string ecdsa_pub = 2; + * raw base64-encoded ed25519 public key. + * + * @generated from field: string ed25519pub = 2; */ - ecdsaPub: string; + ed25519pub: string; }; /** @@ -88,14 +90,18 @@ export type PrivateKey = Message<"v1.PrivateKey"> & { keyid: string; /** - * @generated from field: string ecdsa_priv = 2; + * raw base64-encoded ed25519 private key seed. + * + * @generated from field: string ed25519priv = 2; */ - ecdsaPriv: string; + ed25519priv: string; /** - * @generated from field: string ecdsa_pub = 3; + * raw base64-encoded ed25519 public key. + * + * @generated from field: string ed25519pub = 3; */ - ecdsaPub: string; + ed25519pub: string; }; /** diff --git a/webui/gen/ts/v1sync/syncservice_pb.ts b/webui/gen/ts/v1sync/syncservice_pb.ts index 96c37b6f..5fbc1bc4 100644 --- a/webui/gen/ts/v1sync/syncservice_pb.ts +++ b/webui/gen/ts/v1sync/syncservice_pb.ts @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1sync/syncservice.proto. */ export const file_v1sync_syncservice: GenFile = /*@__PURE__*/ - fileDesc("Chh2MXN5bmMvc3luY3NlcnZpY2UucHJvdG8SBnYxc3luYyIrChZTeW5jU3RhdGVTdHJlYW1SZXF1ZXN0EhEKCXN1YnNjcmliZRgBIAEoCCKbAgoJUGVlclN0YXRlEhgKEHBlZXJfaW5zdGFuY2VfaWQYASABKAkSEgoKcGVlcl9rZXlpZBgCIAEoCRImCgVzdGF0ZRgDIAEoDjIXLnYxc3luYy5Db25uZWN0aW9uU3RhdGUSFgoOc3RhdHVzX21lc3NhZ2UYBCABKAkSKQoLa25vd25fcGxhbnMYBSADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhEikKC2tub3duX3JlcG9zGAYgAygLMhQudjFzeW5jLlJlcG9NZXRhZGF0YRIrCg1yZW1vdGVfY29uZmlnGAcgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxIdChVsYXN0X2hlYXJ0YmVhdF9taWxsaXMYCCABKAMiPQoTQXV0aGVudGljYXRlUmVxdWVzdBImCgtpbnN0YW5jZV9pZBgBIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2UiPgocR2V0T3BlcmF0aW9uTWV0YWRhdGFSZXNwb25zZRIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDIl0KDExvZ0RhdGFFbnRyeRIOCgZsb2dfaWQYASABKAkSEgoKb3duZXJfb3BpZBgCIAEoAxIaChJleHBpcmF0aW9uX3RzX3VuaXgYAyABKAMSDQoFY2h1bmsYBCABKAwiaAocU2V0QXZhaWxhYmxlUmVzb3VyY2VzUmVxdWVzdBIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5QbGFuTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUmVwb01ldGFkYXRhIigKDFJlcG9NZXRhZGF0YRIKCgJpZBgBIAEoCRIMCgRndWlkGAIgASgJIhoKDFBsYW5NZXRhZGF0YRIKCgJpZBgBIAEoCSJ2ChBTZXRDb25maWdSZXF1ZXN0EhcKBXBsYW5zGAEgAygLMggudjEuUGxhbhIXCgVyZXBvcxgCIAMoCzIILnYxLlJlcG8SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCSKWAQocU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVxdWVzdBISCgpwZWVyX2tleWlkGAEgASgJEhcKBXJlcG9zGAIgAygLMggudjEuUmVwbxIXCgVwbGFucxgDIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAQgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgFIAMoCSIfCh1TZXRSZW1vdGVDbGllbnRDb25maWdSZXNwb25zZSJgCgxSZW1vdGVDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgCIAEoBRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuIl8KEkF1dGhvcml6YXRpb25Ub2tlbhIhCgpwdWJsaWNfa2V5GAEgASgLMg0udjEuUHVibGljS2V5EiYKC2luc3RhbmNlX2lkGAIgASgLMhEudjEuU2lnbmVkTWVzc2FnZSLIEAoOU3luY1N0cmVhbUl0ZW0SKwoOc2lnbmVkX21lc3NhZ2UYASABKAsyES52MS5TaWduZWRNZXNzYWdlSAASPwoJaGFuZHNoYWtlGAMgASgLMioudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25IYW5kc2hha2VIABI/CgloZWFydGJlYXQYBCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkhlYXJ0YmVhdEgAElAKEm9wZXJhdGlvbl9tYW5pZmVzdBgUIAEoCzIyLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uT3BlcmF0aW9uTWFuaWZlc3RIABJQChJyZWNlaXZlX29wZXJhdGlvbnMYFSABKAsyMi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zSAASVwoWcmVxdWVzdF9vcGVyYXRpb25fZGF0YRgWIAEoCzI1LnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdE9wZXJhdGlvbkRhdGFIABJICg5yZWNlaXZlX2NvbmZpZxgXIAEoCzIuLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVjZWl2ZUNvbmZpZ0gAEkAKCnNldF9jb25maWcYGCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblNldENvbmZpZ0gAEk4KEXJlcXVlc3RfcmVzb3VyY2VzGBkgASgLMjEudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25SZXF1ZXN0UmVzb3VyY2VzSAASTgoRcmVjZWl2ZV9yZXNvdXJjZXMYGiABKAsyMS52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVSZXNvdXJjZXNIABJCCgtyZXF1ZXN0X2xvZxgeIAEoCzIrLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdExvZ0gAEksKEHJlY2VpdmVfbG9nX2RhdGEYHyABKAsyLy52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVMb2dEYXRhSAASPgoIdGhyb3R0bGUY6AcgASgLMikudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25UaHJvdHRsZUgAGpIBChNTeW5jQWN0aW9uSGFuZHNoYWtlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAMSIQoKcHVibGljX2tleRgCIAEoCzINLnYxLlB1YmxpY0tleRImCgtpbnN0YW5jZV9pZBgDIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2USFgoOcGFpcmluZ19zZWNyZXQYBCABKAkaFQoTU3luY0FjdGlvbkhlYXJ0YmVhdBo/ChdTeW5jQWN0aW9uUmVjZWl2ZUNvbmZpZxIkCgZjb25maWcYASABKAsyFC52MXN5bmMuUmVtb3RlQ29uZmlnGnkKE1N5bmNBY3Rpb25TZXRDb25maWcSFwoFcmVwb3MYASADKAsyCC52MS5SZXBvEhcKBXBsYW5zGAIgAygLMggudjEuUGxhbhIXCg9yZXBvc190b19kZWxldGUYAyADKAkSFwoPcGxhbnNfdG9fZGVsZXRlGAQgAygJGhwKGlN5bmNBY3Rpb25SZXF1ZXN0UmVzb3VyY2VzGmYKGlN5bmNBY3Rpb25SZWNlaXZlUmVzb3VyY2VzEiMKBXJlcG9zGAEgAygLMhQudjFzeW5jLlJlcG9NZXRhZGF0YRIjCgVwbGFucxgCIAMoCzIULnYxc3luYy5QbGFuTWV0YWRhdGEaKAoVU3luY0FjdGlvbkNvbm5lY3RSZXBvEg8KB3JlcG9faWQYASABKAkaPQobU3luY0FjdGlvbk9wZXJhdGlvbk1hbmlmZXN0Eg4KBm9wX2lkcxgBIAMoAxIOCgZtb2Rub3MYAiADKAMaMAoeU3luY0FjdGlvblJlcXVlc3RPcGVyYXRpb25EYXRhEg4KBm9wX2lkcxgBIAMoAxpAChtTeW5jQWN0aW9uUmVjZWl2ZU9wZXJhdGlvbnMSIQoFZXZlbnQYASABKAsyEi52MS5PcGVyYXRpb25FdmVudBomChRTeW5jQWN0aW9uUmVxdWVzdExvZxIOCgZsb2dfaWQYASABKAkagAEKGFN5bmNBY3Rpb25SZWNlaXZlTG9nRGF0YRIOCgZsb2dfaWQYASABKAkSEgoKb3duZXJfb3BpZBgCIAEoAxIaChJleHBpcmF0aW9uX3RzX3VuaXgYAyABKAMSDQoFY2h1bmsYBCABKAwSFQoNZXJyb3JfbWVzc2FnZRgFIAEoCRomChJTeW5jQWN0aW9uVGhyb3R0bGUSEAoIZGVsYXlfbXMYASABKAMaLgoZU3luY0VzdGFibGlzaFNoYXJlZFNlY3JldBIRCgllY2RzYV9wdWIYAiABKAkitAEKE1JlcG9Db25uZWN0aW9uU3RhdGUSHAoYQ09OTkVDVElPTl9TVEFURV9VTktOT1dOEAASHAoYQ09OTkVDVElPTl9TVEFURV9QRU5ESU5HEAESHgoaQ09OTkVDVElPTl9TVEFURV9DT05ORUNURUQQAhIhCh1DT05ORUNUSU9OX1NUQVRFX1VOQVVUSE9SSVpFRBADEh4KGkNPTk5FQ1RJT05fU1RBVEVfTk9UX0ZPVU5EEARCCAoGYWN0aW9uKpwCCg9Db25uZWN0aW9uU3RhdGUSHAoYQ09OTkVDVElPTl9TVEFURV9VTktOT1dOEAASHAoYQ09OTkVDVElPTl9TVEFURV9QRU5ESU5HEAESHgoaQ09OTkVDVElPTl9TVEFURV9DT05ORUNURUQQAhIhCh1DT05ORUNUSU9OX1NUQVRFX0RJU0NPTk5FQ1RFRBADEh8KG0NPTk5FQ1RJT05fU1RBVEVfUkVUUllfV0FJVBAEEh8KG0NPTk5FQ1RJT05fU1RBVEVfRVJST1JfQVVUSBAKEiMKH0NPTk5FQ1RJT05fU1RBVEVfRVJST1JfUFJPVE9DT0wQCxIjCh9DT05ORUNUSU9OX1NUQVRFX0VSUk9SX0lOVEVSTkFMEAwyUwoTQmFja3Jlc3RTeW5jU2VydmljZRI8CgRTeW5jEhYudjFzeW5jLlN5bmNTdHJlYW1JdGVtGhYudjFzeW5jLlN5bmNTdHJlYW1JdGVtIgAoATABMtQBChhCYWNrcmVzdFN5bmNTdGF0ZVNlcnZpY2USUAoXR2V0UGVlclN5bmNTdGF0ZXNTdHJlYW0SHi52MXN5bmMuU3luY1N0YXRlU3RyZWFtUmVxdWVzdBoRLnYxc3luYy5QZWVyU3RhdGUiADABEmYKFVNldFJlbW90ZUNsaWVudENvbmZpZxIkLnYxc3luYy5TZXRSZW1vdGVDbGllbnRDb25maWdSZXF1ZXN0GiUudjFzeW5jLlNldFJlbW90ZUNsaWVudENvbmZpZ1Jlc3BvbnNlIgBCMFouZ2l0aHViLmNvbS9nYXJldGhnZW9yZ2UvYmFja3Jlc3QvZ2VuL2dvL3Yxc3luY2IGcHJvdG8z", [file_v1_config, file_v1_crypto, file_v1_restic, file_v1_service, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations, file_google_protobuf_any]); + fileDesc("Chh2MXN5bmMvc3luY3NlcnZpY2UucHJvdG8SBnYxc3luYyIrChZTeW5jU3RhdGVTdHJlYW1SZXF1ZXN0EhEKCXN1YnNjcmliZRgBIAEoCCKbAgoJUGVlclN0YXRlEhgKEHBlZXJfaW5zdGFuY2VfaWQYASABKAkSEgoKcGVlcl9rZXlpZBgCIAEoCRImCgVzdGF0ZRgDIAEoDjIXLnYxc3luYy5Db25uZWN0aW9uU3RhdGUSFgoOc3RhdHVzX21lc3NhZ2UYBCABKAkSKQoLa25vd25fcGxhbnMYBSADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhEikKC2tub3duX3JlcG9zGAYgAygLMhQudjFzeW5jLlJlcG9NZXRhZGF0YRIrCg1yZW1vdGVfY29uZmlnGAcgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxIdChVsYXN0X2hlYXJ0YmVhdF9taWxsaXMYCCABKAMiPQoTQXV0aGVudGljYXRlUmVxdWVzdBImCgtpbnN0YW5jZV9pZBgBIAEoCzIRLnYxLlNpZ25lZE1lc3NhZ2UiPgocR2V0T3BlcmF0aW9uTWV0YWRhdGFSZXNwb25zZRIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDIl0KDExvZ0RhdGFFbnRyeRIOCgZsb2dfaWQYASABKAkSEgoKb3duZXJfb3BpZBgCIAEoAxIaChJleHBpcmF0aW9uX3RzX3VuaXgYAyABKAMSDQoFY2h1bmsYBCABKAwiaAocU2V0QXZhaWxhYmxlUmVzb3VyY2VzUmVxdWVzdBIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5QbGFuTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUmVwb01ldGFkYXRhIigKDFJlcG9NZXRhZGF0YRIKCgJpZBgBIAEoCRIMCgRndWlkGAIgASgJIhoKDFBsYW5NZXRhZGF0YRIKCgJpZBgBIAEoCSJ2ChBTZXRDb25maWdSZXF1ZXN0EhcKBXBsYW5zGAEgAygLMggudjEuUGxhbhIXCgVyZXBvcxgCIAMoCzIILnYxLlJlcG8SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCSKWAQocU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVxdWVzdBISCgpwZWVyX2tleWlkGAEgASgJEhcKBXJlcG9zGAIgAygLMggudjEuUmVwbxIXCgVwbGFucxgDIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAQgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgFIAMoCSIfCh1TZXRSZW1vdGVDbGllbnRDb25maWdSZXNwb25zZSJgCgxSZW1vdGVDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgCIAEoBRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuIl8KEkF1dGhvcml6YXRpb25Ub2tlbhIhCgpwdWJsaWNfa2V5GAEgASgLMg0udjEuUHVibGljS2V5EiYKC2luc3RhbmNlX2lkGAIgASgLMhEudjEuU2lnbmVkTWVzc2FnZSLSEgoOU3luY1N0cmVhbUl0ZW0SKwoOc2lnbmVkX21lc3NhZ2UYASABKAsyES52MS5TaWduZWRNZXNzYWdlSAASPwoJaGFuZHNoYWtlGAMgASgLMioudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25IYW5kc2hha2VIABI/CgloZWFydGJlYXQYBCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkhlYXJ0YmVhdEgAElAKEm9wZXJhdGlvbl9tYW5pZmVzdBgUIAEoCzIyLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uT3BlcmF0aW9uTWFuaWZlc3RIABJQChJyZWNlaXZlX29wZXJhdGlvbnMYFSABKAsyMi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zSAASVwoWcmVxdWVzdF9vcGVyYXRpb25fZGF0YRgWIAEoCzI1LnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdE9wZXJhdGlvbkRhdGFIABJICg5yZWNlaXZlX2NvbmZpZxgXIAEoCzIuLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVjZWl2ZUNvbmZpZ0gAEkAKCnNldF9jb25maWcYGCABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblNldENvbmZpZ0gAEk4KEXJlcXVlc3RfcmVzb3VyY2VzGBkgASgLMjEudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25SZXF1ZXN0UmVzb3VyY2VzSAASTgoRcmVjZWl2ZV9yZXNvdXJjZXMYGiABKAsyMS52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVSZXNvdXJjZXNIABJCCgtyZXF1ZXN0X2xvZxgeIAEoCzIrLnYxc3luYy5TeW5jU3RyZWFtSXRlbS5TeW5jQWN0aW9uUmVxdWVzdExvZ0gAEksKEHJlY2VpdmVfbG9nX2RhdGEYHyABKAsyLy52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvblJlY2VpdmVMb2dEYXRhSAASPgoIdGhyb3R0bGUY6AcgASgLMikudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNBY3Rpb25UaHJvdHRsZUgAElMKF2VzdGFibGlzaF9zaGFyZWRfc2VjcmV0GAIgASgLMjAudjFzeW5jLlN5bmNTdHJlYW1JdGVtLlN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXRIABI/CgllbmNyeXB0ZWQYBSABKAsyKi52MXN5bmMuU3luY1N0cmVhbUl0ZW0uU3luY0FjdGlvbkVuY3J5cHRlZEgAGpIBChNTeW5jQWN0aW9uSGFuZHNoYWtlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAMSIQoKcHVibGljX2tleRgCIAEoCzINLnYxLlB1YmxpY0tleRITCgtpbnN0YW5jZV9pZBgDIAEoCRIWCg5wYWlyaW5nX3NlY3JldBgEIAEoCRIRCglzaWduYXR1cmUYBSABKAwaOAoTU3luY0FjdGlvbkVuY3J5cHRlZBINCgVub25jZRgBIAEoDBISCgpjaXBoZXJ0ZXh0GAIgASgMGhUKE1N5bmNBY3Rpb25IZWFydGJlYXQaPwoXU3luY0FjdGlvblJlY2VpdmVDb25maWcSJAoGY29uZmlnGAEgASgLMhQudjFzeW5jLlJlbW90ZUNvbmZpZxp5ChNTeW5jQWN0aW9uU2V0Q29uZmlnEhcKBXJlcG9zGAEgAygLMggudjEuUmVwbxIXCgVwbGFucxgCIAMoCzIILnYxLlBsYW4SFwoPcmVwb3NfdG9fZGVsZXRlGAMgAygJEhcKD3BsYW5zX3RvX2RlbGV0ZRgEIAMoCRocChpTeW5jQWN0aW9uUmVxdWVzdFJlc291cmNlcxpmChpTeW5jQWN0aW9uUmVjZWl2ZVJlc291cmNlcxIjCgVyZXBvcxgBIAMoCzIULnYxc3luYy5SZXBvTWV0YWRhdGESIwoFcGxhbnMYAiADKAsyFC52MXN5bmMuUGxhbk1ldGFkYXRhGigKFVN5bmNBY3Rpb25Db25uZWN0UmVwbxIPCgdyZXBvX2lkGAEgASgJGj0KG1N5bmNBY3Rpb25PcGVyYXRpb25NYW5pZmVzdBIOCgZvcF9pZHMYASADKAMSDgoGbW9kbm9zGAIgAygDGjAKHlN5bmNBY3Rpb25SZXF1ZXN0T3BlcmF0aW9uRGF0YRIOCgZvcF9pZHMYASADKAMaQAobU3luY0FjdGlvblJlY2VpdmVPcGVyYXRpb25zEiEKBWV2ZW50GAEgASgLMhIudjEuT3BlcmF0aW9uRXZlbnQaJgoUU3luY0FjdGlvblJlcXVlc3RMb2cSDgoGbG9nX2lkGAEgASgJGoABChhTeW5jQWN0aW9uUmVjZWl2ZUxvZ0RhdGESDgoGbG9nX2lkGAEgASgJEhIKCm93bmVyX29waWQYAiABKAMSGgoSZXhwaXJhdGlvbl90c191bml4GAMgASgDEg0KBWNodW5rGAQgASgMEhUKDWVycm9yX21lc3NhZ2UYBSABKAkaJgoSU3luY0FjdGlvblRocm90dGxlEhAKCGRlbGF5X21zGAEgASgDGmgKGVN5bmNFc3RhYmxpc2hTaGFyZWRTZWNyZXQSGAoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoDRIWCg5rZW1fcHVibGljX2tleRgCIAEoDBIZChFrZW1fZW5jYXBzdWxhdGlvbhgDIAEoDCK0AQoTUmVwb0Nvbm5lY3Rpb25TdGF0ZRIcChhDT05ORUNUSU9OX1NUQVRFX1VOS05PV04QABIcChhDT05ORUNUSU9OX1NUQVRFX1BFTkRJTkcQARIeChpDT05ORUNUSU9OX1NUQVRFX0NPTk5FQ1RFRBACEiEKHUNPTk5FQ1RJT05fU1RBVEVfVU5BVVRIT1JJWkVEEAMSHgoaQ09OTkVDVElPTl9TVEFURV9OT1RfRk9VTkQQBEIICgZhY3Rpb24qnAIKD0Nvbm5lY3Rpb25TdGF0ZRIcChhDT05ORUNUSU9OX1NUQVRFX1VOS05PV04QABIcChhDT05ORUNUSU9OX1NUQVRFX1BFTkRJTkcQARIeChpDT05ORUNUSU9OX1NUQVRFX0NPTk5FQ1RFRBACEiEKHUNPTk5FQ1RJT05fU1RBVEVfRElTQ09OTkVDVEVEEAMSHwobQ09OTkVDVElPTl9TVEFURV9SRVRSWV9XQUlUEAQSHwobQ09OTkVDVElPTl9TVEFURV9FUlJPUl9BVVRIEAoSIwofQ09OTkVDVElPTl9TVEFURV9FUlJPUl9QUk9UT0NPTBALEiMKH0NPTk5FQ1RJT05fU1RBVEVfRVJST1JfSU5URVJOQUwQDDJTChNCYWNrcmVzdFN5bmNTZXJ2aWNlEjwKBFN5bmMSFi52MXN5bmMuU3luY1N0cmVhbUl0ZW0aFi52MXN5bmMuU3luY1N0cmVhbUl0ZW0iACgBMAEy1AEKGEJhY2tyZXN0U3luY1N0YXRlU2VydmljZRJQChdHZXRQZWVyU3luY1N0YXRlc1N0cmVhbRIeLnYxc3luYy5TeW5jU3RhdGVTdHJlYW1SZXF1ZXN0GhEudjFzeW5jLlBlZXJTdGF0ZSIAMAESZgoVU2V0UmVtb3RlQ2xpZW50Q29uZmlnEiQudjFzeW5jLlNldFJlbW90ZUNsaWVudENvbmZpZ1JlcXVlc3QaJS52MXN5bmMuU2V0UmVtb3RlQ2xpZW50Q29uZmlnUmVzcG9uc2UiAEIwWi5naXRodWIuY29tL2dhcmV0aGdlb3JnZS9iYWNrcmVzdC9nZW4vZ28vdjFzeW5jYgZwcm90bzM", [file_v1_config, file_v1_crypto, file_v1_restic, file_v1_service, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations, file_google_protobuf_any]); /** * @generated from message v1sync.SyncStateStreamRequest @@ -503,6 +503,18 @@ export type SyncStreamItem = Message<"v1sync.SyncStreamItem"> & { */ value: SyncStreamItem_SyncActionThrottle; case: "throttle"; + } | { + /** + * @generated from field: v1sync.SyncStreamItem.SyncEstablishSharedSecret establish_shared_secret = 2; + */ + value: SyncStreamItem_SyncEstablishSharedSecret; + case: "establishSharedSecret"; + } | { + /** + * @generated from field: v1sync.SyncStreamItem.SyncActionEncrypted encrypted = 5; + */ + value: SyncStreamItem_SyncActionEncrypted; + case: "encrypted"; } | { case: undefined; value?: undefined }; }; @@ -514,6 +526,32 @@ export const SyncStreamItemSchema: GenMessage = /*@__PURE__*/ messageDesc(file_v1sync_syncservice, 13); /** + * 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. + * * @generated from message v1sync.SyncStreamItem.SyncActionHandshake */ export type SyncStreamItem_SyncActionHandshake = Message<"v1sync.SyncStreamItem.SyncActionHandshake"> & { @@ -523,21 +561,32 @@ export type SyncStreamItem_SyncActionHandshake = Message<"v1sync.SyncStreamItem. protocolVersion: bigint; /** + * sender's long-term ed25519 identity + * * @generated from field: v1.PublicKey public_key = 2; */ publicKey?: PublicKey; /** - * @generated from field: v1.SignedMessage instance_id = 3; + * covered by signature below + * + * @generated from field: string instance_id = 3; */ - instanceId?: SignedMessage; + instanceId: string; /** - * optional one-time secret from a pairing token, used to auto-authorize a new client + * optional pairing token; covered by signature below * * @generated from field: string pairing_secret = 4; */ pairingSecret: string; + + /** + * ed25519(public_key, H(handshake bind input)) + * + * @generated from field: bytes signature = 5; + */ + signature: Uint8Array; }; /** @@ -547,6 +596,36 @@ export type SyncStreamItem_SyncActionHandshake = Message<"v1sync.SyncStreamItem. export const SyncStreamItem_SyncActionHandshakeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_v1sync_syncservice, 13, 0); +/** + * SyncActionEncrypted wraps an encrypted SyncStreamItem. + * After the post-quantum KEM handshake, all subsequent messages are sent + * inside this envelope. + * + * @generated from message v1sync.SyncStreamItem.SyncActionEncrypted + */ +export type SyncStreamItem_SyncActionEncrypted = Message<"v1sync.SyncStreamItem.SyncActionEncrypted"> & { + /** + * 12-byte GCM nonce + * + * @generated from field: bytes nonce = 1; + */ + nonce: Uint8Array; + + /** + * AES-256-GCM(serialized SyncStreamItem) + * + * @generated from field: bytes ciphertext = 2; + */ + ciphertext: Uint8Array; +}; + +/** + * Describes the message v1sync.SyncStreamItem.SyncActionEncrypted. + * Use `create(SyncStreamItem_SyncActionEncryptedSchema)` to create a new message. + */ +export const SyncStreamItem_SyncActionEncryptedSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1sync_syncservice, 13, 1); + /** * SyncActionHeartbeat is sent periodically to keep the connection alive. * @@ -560,7 +639,7 @@ export type SyncStreamItem_SyncActionHeartbeat = Message<"v1sync.SyncStreamItem. * Use `create(SyncStreamItem_SyncActionHeartbeatSchema)` to create a new message. */ export const SyncStreamItem_SyncActionHeartbeatSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 1); + messageDesc(file_v1sync_syncservice, 13, 2); /** * @generated from message v1sync.SyncStreamItem.SyncActionReceiveConfig @@ -577,7 +656,7 @@ export type SyncStreamItem_SyncActionReceiveConfig = Message<"v1sync.SyncStreamI * Use `create(SyncStreamItem_SyncActionReceiveConfigSchema)` to create a new message. */ export const SyncStreamItem_SyncActionReceiveConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 2); + messageDesc(file_v1sync_syncservice, 13, 3); /** * @generated from message v1sync.SyncStreamItem.SyncActionSetConfig @@ -609,7 +688,7 @@ export type SyncStreamItem_SyncActionSetConfig = Message<"v1sync.SyncStreamItem. * Use `create(SyncStreamItem_SyncActionSetConfigSchema)` to create a new message. */ export const SyncStreamItem_SyncActionSetConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 3); + messageDesc(file_v1sync_syncservice, 13, 4); /** * @generated from message v1sync.SyncStreamItem.SyncActionRequestResources @@ -622,7 +701,7 @@ export type SyncStreamItem_SyncActionRequestResources = Message<"v1sync.SyncStre * Use `create(SyncStreamItem_SyncActionRequestResourcesSchema)` to create a new message. */ export const SyncStreamItem_SyncActionRequestResourcesSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 4); + messageDesc(file_v1sync_syncservice, 13, 5); /** * @generated from message v1sync.SyncStreamItem.SyncActionReceiveResources @@ -644,7 +723,7 @@ export type SyncStreamItem_SyncActionReceiveResources = Message<"v1sync.SyncStre * Use `create(SyncStreamItem_SyncActionReceiveResourcesSchema)` to create a new message. */ export const SyncStreamItem_SyncActionReceiveResourcesSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 5); + messageDesc(file_v1sync_syncservice, 13, 6); /** * @generated from message v1sync.SyncStreamItem.SyncActionConnectRepo @@ -661,7 +740,7 @@ export type SyncStreamItem_SyncActionConnectRepo = Message<"v1sync.SyncStreamIte * Use `create(SyncStreamItem_SyncActionConnectRepoSchema)` to create a new message. */ export const SyncStreamItem_SyncActionConnectRepoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 6); + messageDesc(file_v1sync_syncservice, 13, 7); /** * @generated from message v1sync.SyncStreamItem.SyncActionOperationManifest @@ -683,7 +762,7 @@ export type SyncStreamItem_SyncActionOperationManifest = Message<"v1sync.SyncStr * Use `create(SyncStreamItem_SyncActionOperationManifestSchema)` to create a new message. */ export const SyncStreamItem_SyncActionOperationManifestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 7); + messageDesc(file_v1sync_syncservice, 13, 8); /** * @generated from message v1sync.SyncStreamItem.SyncActionRequestOperationData @@ -700,7 +779,7 @@ export type SyncStreamItem_SyncActionRequestOperationData = Message<"v1sync.Sync * Use `create(SyncStreamItem_SyncActionRequestOperationDataSchema)` to create a new message. */ export const SyncStreamItem_SyncActionRequestOperationDataSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 8); + messageDesc(file_v1sync_syncservice, 13, 9); /** * @generated from message v1sync.SyncStreamItem.SyncActionReceiveOperations @@ -717,7 +796,7 @@ export type SyncStreamItem_SyncActionReceiveOperations = Message<"v1sync.SyncStr * Use `create(SyncStreamItem_SyncActionReceiveOperationsSchema)` to create a new message. */ export const SyncStreamItem_SyncActionReceiveOperationsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 9); + messageDesc(file_v1sync_syncservice, 13, 10); /** * @generated from message v1sync.SyncStreamItem.SyncActionRequestLog @@ -734,7 +813,7 @@ export type SyncStreamItem_SyncActionRequestLog = Message<"v1sync.SyncStreamItem * Use `create(SyncStreamItem_SyncActionRequestLogSchema)` to create a new message. */ export const SyncStreamItem_SyncActionRequestLogSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 10); + messageDesc(file_v1sync_syncservice, 13, 11); /** * @generated from message v1sync.SyncStreamItem.SyncActionReceiveLogData @@ -781,7 +860,7 @@ export type SyncStreamItem_SyncActionReceiveLogData = Message<"v1sync.SyncStream * Use `create(SyncStreamItem_SyncActionReceiveLogDataSchema)` to create a new message. */ export const SyncStreamItem_SyncActionReceiveLogDataSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 11); + messageDesc(file_v1sync_syncservice, 13, 12); /** * @generated from message v1sync.SyncStreamItem.SyncActionThrottle @@ -798,21 +877,43 @@ export type SyncStreamItem_SyncActionThrottle = Message<"v1sync.SyncStreamItem.S * Use `create(SyncStreamItem_SyncActionThrottleSchema)` to create a new message. */ export const SyncStreamItem_SyncActionThrottleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 12); + messageDesc(file_v1sync_syncservice, 13, 13); /** + * 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. + * * @generated from message v1sync.SyncStreamItem.SyncEstablishSharedSecret */ export type SyncStreamItem_SyncEstablishSharedSecret = Message<"v1sync.SyncStreamItem.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 . + * current: 1 * - * base64 encoded public key - * - * @generated from field: string ecdsa_pub = 2; + * @generated from field: uint32 protocol_version = 1; */ - ecdsaPub: string; + protocolVersion: number; + + /** + * set by initiator + * + * @generated from field: bytes kem_public_key = 2; + */ + kemPublicKey: Uint8Array; + + /** + * set by responder + * + * @generated from field: bytes kem_encapsulation = 3; + */ + kemEncapsulation: Uint8Array; }; /** @@ -820,7 +921,7 @@ export type SyncStreamItem_SyncEstablishSharedSecret = Message<"v1sync.SyncStrea * Use `create(SyncStreamItem_SyncEstablishSharedSecretSchema)` to create a new message. */ export const SyncStreamItem_SyncEstablishSharedSecretSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1sync_syncservice, 13, 13); + messageDesc(file_v1sync_syncservice, 13, 14); /** * @generated from enum v1sync.SyncStreamItem.RepoConnectionState