double ratchet first implementation
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
ycc
2026-03-04 22:30:22 +01:00
parent c0dcfe997c
commit f4fb42d72e
11 changed files with 375 additions and 14 deletions

174
client/drsession.go Normal file
View File

@@ -0,0 +1,174 @@
package client
import (
"encoding/hex"
"encoding/json"
"encoding/base64"
"fmt"
doubleratchet "github.com/status-im/doubleratchet"
)
// drLocalPair implements doubleratchet.DHPair using raw byte slices.
type drLocalPair struct {
priv doubleratchet.Key
pub doubleratchet.Key
}
func (p drLocalPair) PrivateKey() doubleratchet.Key { return p.priv }
func (p drLocalPair) PublicKey() doubleratchet.Key { return p.pub }
// serializedDRState is an intermediate JSON-friendly representation of doubleratchet.State.
type serializedDRState struct {
DHrPublic []byte `json:"dhr_pub"`
DHsPrivate []byte `json:"dhs_priv"`
DHsPublic []byte `json:"dhs_pub"`
RootChCK []byte `json:"root_ch_ck"`
SendChCK []byte `json:"send_ch_ck"`
SendChN uint32 `json:"send_ch_n"`
RecvChCK []byte `json:"recv_ch_ck"`
RecvChN uint32 `json:"recv_ch_n"`
PN uint32 `json:"pn"`
MkSkipped map[string]map[uint][]byte `json:"mk_skipped"`
MaxSkip uint `json:"max_skip"`
MaxKeep uint `json:"max_keep"`
MaxMessageKeysPerSession int `json:"max_mks_per_session"`
Step uint `json:"step"`
KeysCount uint `json:"keys_count"`
}
// drSessionStorage implements doubleratchet.SessionStorage, persisting state into peer.DrStateJson.
type drSessionStorage struct{ peer *Peer }
func (s *drSessionStorage) Save(id []byte, state *doubleratchet.State) error {
all, err := state.MkSkipped.All()
if err != nil {
return fmt.Errorf("drSessionStorage.Save: MkSkipped.All: %w", err)
}
mkSkipped := make(map[string]map[uint][]byte, len(all))
for k, msgs := range all {
inner := make(map[uint][]byte, len(msgs))
for num, mk := range msgs {
inner[num] = []byte(mk)
}
mkSkipped[k] = inner
}
ss := serializedDRState{
DHrPublic: []byte(state.DHr),
DHsPrivate: []byte(state.DHs.PrivateKey()),
DHsPublic: []byte(state.DHs.PublicKey()),
RootChCK: []byte(state.RootCh.CK),
SendChCK: []byte(state.SendCh.CK),
SendChN: state.SendCh.N,
RecvChCK: []byte(state.RecvCh.CK),
RecvChN: state.RecvCh.N,
PN: state.PN,
MkSkipped: mkSkipped,
MaxSkip: state.MaxSkip,
MaxKeep: state.MaxKeep,
MaxMessageKeysPerSession: state.MaxMessageKeysPerSession,
Step: state.Step,
KeysCount: state.KeysCount,
}
b, err := json.Marshal(ss)
if err != nil {
return fmt.Errorf("drSessionStorage.Save: json.Marshal: %w", err)
}
s.peer.DrStateJson = string(b)
return nil
}
func (s *drSessionStorage) Load(id []byte) (*doubleratchet.State, error) {
if s.peer.DrStateJson == "" {
return nil, nil
}
var ss serializedDRState
if err := json.Unmarshal([]byte(s.peer.DrStateJson), &ss); err != nil {
return nil, fmt.Errorf("drSessionStorage.Load: json.Unmarshal: %w", err)
}
c := doubleratchet.DefaultCrypto{}
mkStorage := &doubleratchet.KeysStorageInMemory{}
seq := uint(0)
for k, msgs := range ss.MkSkipped {
pubKey, err := hex.DecodeString(k)
if err != nil {
return nil, fmt.Errorf("drSessionStorage.Load: decode skipped key hex: %w", err)
}
for num, mk := range msgs {
if err := mkStorage.Put(id, doubleratchet.Key(pubKey), num, doubleratchet.Key(mk), seq); err != nil {
return nil, fmt.Errorf("drSessionStorage.Load: Put: %w", err)
}
seq++
}
}
state := &doubleratchet.State{
Crypto: c,
DHr: doubleratchet.Key(ss.DHrPublic),
DHs: drLocalPair{priv: doubleratchet.Key(ss.DHsPrivate), pub: doubleratchet.Key(ss.DHsPublic)},
PN: ss.PN,
MkSkipped: mkStorage,
MaxSkip: ss.MaxSkip,
MaxKeep: ss.MaxKeep,
MaxMessageKeysPerSession: ss.MaxMessageKeysPerSession,
Step: ss.Step,
KeysCount: ss.KeysCount,
}
state.RootCh.CK = doubleratchet.Key(ss.RootChCK)
state.RootCh.Crypto = c
state.SendCh.CK = doubleratchet.Key(ss.SendChCK)
state.SendCh.N = ss.SendChN
state.SendCh.Crypto = c
state.RecvCh.CK = doubleratchet.Key(ss.RecvChCK)
state.RecvCh.N = ss.RecvChN
state.RecvCh.Crypto = c
return state, nil
}
// GetDRSession returns an active DR session for the peer, creating one if needed.
func (p *Peer) GetDRSession() (doubleratchet.Session, error) {
store := &drSessionStorage{peer: p}
// If we already have a saved state, load it
if p.DrStateJson != "" {
return doubleratchet.Load([]byte(p.Uid), store)
}
// Initiator: has own DH keypair + root key, no state yet
if p.DrInitiator && p.DrKpPrivate != "" {
privBytes, err := base64.StdEncoding.DecodeString(p.DrKpPrivate)
if err != nil {
return nil, fmt.Errorf("GetDRSession: decode DrKpPrivate: %w", err)
}
pubBytes, err := base64.StdEncoding.DecodeString(p.DrKpPublic)
if err != nil {
return nil, fmt.Errorf("GetDRSession: decode DrKpPublic: %w", err)
}
rootKeyBytes, err := base64.StdEncoding.DecodeString(p.DrRootKey)
if err != nil {
return nil, fmt.Errorf("GetDRSession: decode DrRootKey: %w", err)
}
kp := drLocalPair{priv: doubleratchet.Key(privBytes), pub: doubleratchet.Key(pubBytes)}
return doubleratchet.New([]byte(p.Uid), doubleratchet.Key(rootKeyBytes), kp, store)
}
// Responder: has remote DH public key + root key
if !p.DrInitiator && p.ContactDrPublicKey != "" {
remotePubBytes, err := base64.StdEncoding.DecodeString(p.ContactDrPublicKey)
if err != nil {
return nil, fmt.Errorf("GetDRSession: decode ContactDrPublicKey: %w", err)
}
rootKeyBytes, err := base64.StdEncoding.DecodeString(p.DrRootKey)
if err != nil {
return nil, fmt.Errorf("GetDRSession: decode DrRootKey: %w", err)
}
return doubleratchet.NewWithRemoteKey([]byte(p.Uid), doubleratchet.Key(rootKeyBytes), doubleratchet.Key(remotePubBytes), store)
}
return nil, fmt.Errorf("GetDRSession: peer %s has no DR keys configured", p.Uid)
}

View File

@@ -143,7 +143,7 @@ func ConsumeInboxFile(messageFilename string) ([]string, []string, string, error
return nil, nil, "ReadMessage: GetFromMyLookupKey", errors.New("no visible peer for that message")
}
// Unpack the message
usermsg, err := peer.ProcessInboundUserMessage(packedUserMessage.Payload, packedUserMessage.Signature)
usermsg, err := peer.ProcessInboundUserMessage(packedUserMessage)
if err != nil {
return nil, nil, "ReadMessage: ProcessInboundUserMessage", err
}
@@ -188,6 +188,13 @@ func ConsumeInboxFile(messageFilename string) ([]string, []string, string, error
}
filenames = []string{}
// Persist peer to save updated DR state (DrStateJson)
if peer.DrRootKey != "" {
if storeErr := identity.Peers.StorePeer(peer); storeErr != nil {
logger.Warn().Err(storeErr).Str("peer", peer.Uid).Msg("ConsumeInboxFile: StorePeer (DR state)")
}
}
// Send delivery ack if the peer requested it
if peer.SendDeliveryAck && usermsg.Status.Uuid != "" {
storagePath := filepath.Join(client.GetConfig().StoragePath, identity.Uuid)

View File

@@ -28,7 +28,7 @@ func invitationGetAnswerReadResponse(invitation *meowlib.Invitation) (*client.Pe
if peer != nil {
// process the packed user message
usermsg, err := peer.ProcessInboundUserMessage(invitationAnswer.Payload, invitationAnswer.Signature)
usermsg, err := peer.ProcessInboundUserMessage(&invitationAnswer)
if err != nil {
return nil, "InvitationGetAnswerReadResponse: ProcessInboundUserMessage", err
}

View File

@@ -81,6 +81,13 @@ func CreateAndStoreUserMessage(message string, peer_uid string, replyToUid strin
return nil, "", 0, "messageBuildPostprocess : ProcessOutboundUserMessage", err
}
// Persist peer to save updated DR state (DrStateJson)
if peer.DrRootKey != "" {
if storeErr := client.GetConfig().GetIdentity().Peers.StorePeer(peer); storeErr != nil {
logger.Warn().Err(storeErr).Str("peer", peer.Uid).Msg("messageBuildPostprocess: StorePeer (DR state)")
}
}
return packedMsg, dbFile, dbId, "", nil
}

View File

@@ -15,6 +15,7 @@ import (
"forge.redroom.link/yves/meowlib"
"github.com/ProtonMail/gopenpgp/v2/helper"
"github.com/google/uuid"
doubleratchet "github.com/status-im/doubleratchet"
)
const maxHiddenCount = 30
@@ -122,6 +123,21 @@ func (id *Identity) InvitePeer(MyName string, ContactName string, MessageServerU
peer.MyPullServers = MessageServerUids
peer.MyName = MyName
peer.InvitationMessage = InvitationMessage
// Generate DR keypair and root key for the initiator side
drKp, err := doubleratchet.DefaultCrypto{}.GenerateDH()
if err != nil {
return nil, err
}
peer.DrKpPrivate = base64.StdEncoding.EncodeToString(drKp.PrivateKey())
peer.DrKpPublic = base64.StdEncoding.EncodeToString(drKp.PublicKey())
drRootKey := make([]byte, 32)
if _, err = rand.Read(drRootKey); err != nil {
return nil, err
}
peer.DrRootKey = base64.StdEncoding.EncodeToString(drRootKey)
peer.DrInitiator = true
id.Peers.StorePeer(&peer)
return &peer, nil
@@ -187,6 +203,10 @@ func (id *Identity) AnswerInvitation(MyName string, ContactName string, MessageS
peer.MyPullServers = MessageServerIdxs
peer.MyName = MyName
peer.InvitationId = ReceivedContact.InvitationId
// Adopt DR material from the initiator's ContactCard
peer.DrRootKey = ReceivedContact.DrRootKey
peer.ContactDrPublicKey = ReceivedContact.DrPublicKey
peer.DrInitiator = false
id.Peers.StorePeer(&peer)
return &peer, nil

View File

@@ -1,11 +1,13 @@
package client
import (
"encoding/json"
"io"
"os"
"time"
"forge.redroom.link/yves/meowlib"
doubleratchet "github.com/status-im/doubleratchet"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
@@ -54,7 +56,14 @@ type Peer struct {
DbIds []string `json:"db_ids,omitempty"`
Type string `json:"type,omitempty"`
PersonnaeDbId string `json:"personnae_db_id,omitempty"`
dbPassword string
// Double Ratchet state
DrKpPublic string `json:"dr_kp_public,omitempty"`
DrKpPrivate string `json:"dr_kp_private,omitempty"`
DrRootKey string `json:"dr_root_key,omitempty"`
DrInitiator bool `json:"dr_initiator,omitempty"`
ContactDrPublicKey string `json:"contact_dr_public_key,omitempty"`
DrStateJson string `json:"dr_state_json,omitempty"`
dbPassword string
}
//
@@ -74,6 +83,8 @@ func (p *Peer) GetMyContact() *meowlib.ContactCard {
c.InvitationMessage = p.InvitationMessage
c.Name = p.MyName
c.SymetricKey = p.MySymKey
c.DrRootKey = p.DrRootKey
c.DrPublicKey = p.DrKpPublic
return &c
}
@@ -283,24 +294,61 @@ func (p *Peer) ProcessOutboundUserMessage(usermessage *meowlib.UserMessage) (*me
if err != nil {
return nil, err
}
// Symmetric encryption (outer layer, if symkey is configured)
// Symmetric encryption (middle layer, if symkey is configured)
symEncrypted, err := p.SymEncryptPayload(enc.Data)
if err != nil {
return nil, err
}
// Packing it
// Double Ratchet encryption (outermost layer, if DR is configured)
if p.DrRootKey != "" {
session, err := p.GetDRSession()
if err != nil {
return nil, err
}
drMsg, err := session.RatchetEncrypt(symEncrypted, nil)
if err != nil {
return nil, err
}
headerBytes, err := json.Marshal(drMsg.Header)
if err != nil {
return nil, err
}
packed := p.PackUserMessage(drMsg.Ciphertext, enc.Signature)
packed.DrHeader = headerBytes
return packed, nil
}
// No DR layer
packedMsg := p.PackUserMessage(symEncrypted, enc.Signature)
return packedMsg, nil
}
// ProcessInboundUserMessage is a helper function that decrypts and deserializes a user message
func (p *Peer) ProcessInboundUserMessage(message []byte, signature []byte) (*meowlib.UserMessage, error) {
// Symmetric decryption (outer layer, if symkey is configured)
symDecrypted, err := p.SymDecryptPayload(message)
func (p *Peer) ProcessInboundUserMessage(packed *meowlib.PackedUserMessage) (*meowlib.UserMessage, error) {
payload := packed.Payload
// Double Ratchet decryption (outermost layer), only when DR is configured and header present
if p.DrRootKey != "" && len(packed.DrHeader) > 0 {
session, err := p.GetDRSession()
if err != nil {
return nil, err
}
var header doubleratchet.MessageHeader
if err := json.Unmarshal(packed.DrHeader, &header); err != nil {
return nil, err
}
payload, err = session.RatchetDecrypt(
doubleratchet.Message{Header: header, Ciphertext: packed.Payload},
nil,
)
if err != nil {
return nil, err
}
}
// Symmetric decryption (middle layer, if symkey is configured)
symDecrypted, err := p.SymDecryptPayload(payload)
if err != nil {
return nil, err
}
dec, err := p.AsymDecryptMessage(symDecrypted, signature)
dec, err := p.AsymDecryptMessage(symDecrypted, packed.Signature)
if err != nil {
return nil, err
}

View File

@@ -1,11 +1,14 @@
package client
import (
"crypto/rand"
"encoding/base64"
"os"
"strconv"
"testing"
"forge.redroom.link/yves/meowlib"
doubleratchet "github.com/status-im/doubleratchet"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/proto"
@@ -421,7 +424,7 @@ func TestProcessOutboundInbound_RoundTrip(t *testing.T) {
assert.NotEmpty(t, packed.Signature)
assert.Equal(t, bob.MyLookupKp.Public, packed.Destination)
received, err := bob.ProcessInboundUserMessage(packed.Payload, packed.Signature)
received, err := bob.ProcessInboundUserMessage(packed)
assert.NoError(t, err)
assert.Equal(t, []byte("end to end test"), received.Data)
assert.Equal(t, alice.MyIdentity.Public, received.From)
@@ -436,7 +439,7 @@ func TestProcessOutboundInbound_EmptyMessage(t *testing.T) {
packed, err := alice.ProcessOutboundUserMessage(userMsg)
assert.NoError(t, err)
received, err := bob.ProcessInboundUserMessage(packed.Payload, packed.Signature)
received, err := bob.ProcessInboundUserMessage(packed)
assert.NoError(t, err)
assert.Empty(t, received.Data)
}
@@ -452,6 +455,74 @@ func TestProcessOutboundUserMessage_InvalidKey(t *testing.T) {
assert.Error(t, err)
}
// ---------------------------------------------------------------------------
// DR-encrypted round-trip
// ---------------------------------------------------------------------------
func makeDRPeerPair(t *testing.T) (alice *Peer, bob *Peer) {
t.Helper()
alice, bob = makePeerPair(t)
// Generate DR keypair for alice (initiator)
drKp, err := doubleratchet.DefaultCrypto{}.GenerateDH()
if err != nil {
t.Fatal(err)
}
drRootKeyBytes := make([]byte, 32)
if _, err = rand.Read(drRootKeyBytes); err != nil {
t.Fatal(err)
}
drRootKey := base64.StdEncoding.EncodeToString(drRootKeyBytes)
alice.DrKpPrivate = base64.StdEncoding.EncodeToString(drKp.PrivateKey())
alice.DrKpPublic = base64.StdEncoding.EncodeToString(drKp.PublicKey())
alice.DrRootKey = drRootKey
alice.DrInitiator = true
bob.DrRootKey = drRootKey
bob.ContactDrPublicKey = alice.DrKpPublic
bob.DrInitiator = false
return alice, bob
}
func TestProcessOutboundInbound_DR_RoundTrip(t *testing.T) {
alice, bob := makeDRPeerPair(t)
userMsg, err := alice.BuildSimpleUserMessage([]byte("dr round trip test"))
assert.NoError(t, err)
packed, err := alice.ProcessOutboundUserMessage(userMsg)
assert.NoError(t, err)
assert.NotEmpty(t, packed.DrHeader, "DR header should be set")
received, err := bob.ProcessInboundUserMessage(packed)
assert.NoError(t, err)
assert.Equal(t, []byte("dr round trip test"), received.Data)
// Verify DR state was updated
assert.NotEmpty(t, alice.DrStateJson, "alice DR state should be persisted")
assert.NotEmpty(t, bob.DrStateJson, "bob DR state should be persisted")
}
func TestProcessOutboundInbound_DR_MultipleMessages(t *testing.T) {
alice, bob := makeDRPeerPair(t)
for i := 0; i < 5; i++ {
msg := []byte("message " + strconv.Itoa(i))
userMsg, err := alice.BuildSimpleUserMessage(msg)
assert.NoError(t, err)
packed, err := alice.ProcessOutboundUserMessage(userMsg)
assert.NoError(t, err)
assert.NotEmpty(t, packed.DrHeader)
received, err := bob.ProcessInboundUserMessage(packed)
assert.NoError(t, err)
assert.Equal(t, msg, received.Data)
}
}
// ---------------------------------------------------------------------------
// GetConversationRequest
// ---------------------------------------------------------------------------