cleaner invitation step messages

This commit is contained in:
yc
2026-04-14 19:12:09 +02:00
parent 327bd390c4
commit 00e4e6b046
9 changed files with 208 additions and 179 deletions
+78 -84
View File
@@ -29,114 +29,108 @@ func setupIdentity(t *testing.T, nickname string) (*client.Identity, func()) {
return id, cleanup
}
// TestStep2ProducesPackedUserMessage verifies that Step2 returns a PackedUserMessage
// (not just a peer) and that the message is encrypted with the initiator's temp key
// so Step3 can decrypt it.
func TestStep2ProducesPackedUserMessage(t *testing.T) {
// TestStep1ReturnsBinaryPayload verifies that Step1 returns non-empty bytes that
// deserialise to a valid InvitationInitPayload, and that the pending peer is stored
// with only a temp keypair (no real identity keys yet).
func TestStep1ReturnsBinaryPayload(t *testing.T) {
cfg := client.GetConfig()
cfg.SetMemPass("testpass") //nolint:errcheck
// --- STEP 1: initiator creates temp keypair and payload ---
initiator, cleanInit := setupIdentity(t, "alice")
defer cleanInit()
payload, initPeer, err := messages.Step1InitiatorCreatesInviteeAndTempKey("Bob", "Alice", "Hello!", nil)
step1Bytes, err := messages.Step1InitiatorCreatesInviteeAndTempKey("Bob", "Alice", "Hello!", nil)
require.NoError(t, err)
require.NotNil(t, payload)
require.NotEmpty(t, step1Bytes)
var payload meowlib.InvitationInitPayload
require.NoError(t, proto.Unmarshal(step1Bytes, &payload))
assert.NotEmpty(t, payload.Uuid)
assert.NotEmpty(t, payload.PublicKey)
assert.Equal(t, "Alice", payload.Name)
assert.Equal(t, "Hello!", payload.InvitationMessage)
// Peer is saved with temp keypair only — no real identity keys yet.
initPeer := initiator.Peers.GetFromName("Bob")
require.NotNil(t, initPeer)
// Initiator has only the temp keypair at this stage.
assert.Nil(t, initPeer.MyIdentity)
assert.NotNil(t, initPeer.InvitationKp)
}
// --- STEP 2: invitee receives payload, creates peer, returns packed message ---
_, cleanInvitee := setupIdentity(t, "bob")
// TestFullInvitationFlow runs all four steps end-to-end, passing the binary output of
// each step directly to the next, and verifies that both peers end up with each other's
// real keys after the exchange completes.
func TestFullInvitationFlow(t *testing.T) {
cfg := client.GetConfig()
cfg.SetMemPass("testpass") //nolint:errcheck
// --- STEP 1: initiator creates temp keypair, gets binary payload ---
initiator, cleanInit := setupIdentity(t, "alice2")
defer cleanInit()
step1Bytes, err := messages.Step1InitiatorCreatesInviteeAndTempKey("Bob", "Alice", "", nil)
require.NoError(t, err)
require.NotEmpty(t, step1Bytes)
// --- STEP 2: invitee creates peer, returns serialized Invitation (step=2) ---
invitee, cleanInvitee := setupIdentity(t, "bob2")
defer cleanInvitee()
packed, inviteePeer, err := messages.Step2InviteeCreatesInitiatorAndEncryptedContactCard(
payload, "Alice", "Bob", nil,
)
step2Bytes, err := messages.Step2InviteeCreatesInitiatorAndEncryptedContactCard(step1Bytes, "Alice", "Bob", nil)
require.NoError(t, err)
require.NotNil(t, packed, "step2 must return a PackedUserMessage, not just a peer")
require.NotEmpty(t, step2Bytes, "step2 must return non-empty invitation bytes")
// Invitee now has a peer with full keypairs.
inviteePeer := invitee.Peers.GetFromName("Alice")
require.NotNil(t, inviteePeer)
// The packed message destination is the initiator's temp key (used as lookup key).
assert.Equal(t, payload.PublicKey, packed.Destination)
// The invitee peer has full keypairs now.
assert.NotNil(t, inviteePeer.MyIdentity)
assert.NotNil(t, inviteePeer.MyEncryptionKp)
assert.NotNil(t, inviteePeer.MyLookupKp)
// --- STEP 3: initiator decrypts invitee's packed message and finalises ---
// The step-2 wire format is a serialized Invitation.
var inv2 meowlib.Invitation
require.NoError(t, proto.Unmarshal(step2Bytes, &inv2))
assert.EqualValues(t, 2, inv2.Step)
assert.NotEmpty(t, inv2.Uuid)
assert.Equal(t, inviteePeer.MyIdentity.Public, inv2.From)
assert.NotEmpty(t, inv2.Payload)
// --- STEP 3: initiator decrypts invitee's card, returns serialized Invitation (step=3) ---
cfg.SetIdentity(initiator)
// Simulate how the server delivers the step-2 answer: marshal the PackedUserMessage
// into an Invitation.Payload.
packedBytes, err := proto.Marshal(packed)
step3Bytes, err := messages.Step3InitiatorFinalizesInviteeAndCreatesContactCard(step2Bytes)
require.NoError(t, err)
require.NotEmpty(t, step3Bytes)
invitation := &meowlib.Invitation{
Uuid: payload.Uuid,
Step: 2,
From: inviteePeer.MyIdentity.Public,
Payload: packedBytes,
}
// Initiator's peer must now hold invitee's real keys; temp keypair must be gone.
initPeer := initiator.Peers.GetFromName("Bob")
require.NotNil(t, initPeer)
assert.Equal(t, inviteePeer.MyIdentity.Public, initPeer.ContactPublicKey)
assert.Equal(t, inviteePeer.MyEncryptionKp.Public, initPeer.ContactEncryption)
assert.Equal(t, inviteePeer.MyLookupKp.Public, initPeer.ContactLookupKey)
assert.Nil(t, initPeer.InvitationKp, "temp keypair must be cleared after step3")
assert.NotEmpty(t, initPeer.DrKpPublic)
assert.NotEmpty(t, initPeer.DrRootKey)
peer, myCC, err := messages.Step3InitiatorFinalizesInviteeAndCreatesContactCard(invitation)
// The step-3 wire format is a serialized Invitation.
var inv3 meowlib.Invitation
require.NoError(t, proto.Unmarshal(step3Bytes, &inv3))
assert.EqualValues(t, 3, inv3.Step)
assert.NotEmpty(t, inv3.Uuid)
assert.NotEmpty(t, inv3.Payload)
// --- STEP 4: invitee finalises initiator ---
cfg.SetIdentity(invitee)
finalPeer, err := messages.Step4InviteeFinalizesInitiator(step3Bytes)
require.NoError(t, err)
require.NotNil(t, peer)
require.NotNil(t, myCC, "step3 must produce the initiator's ContactCard to send to invitee")
require.NotNil(t, finalPeer)
// Initiator's peer must now hold invitee's real keys.
assert.Equal(t, inviteePeer.MyIdentity.Public, peer.ContactPublicKey)
assert.Equal(t, inviteePeer.MyEncryptionKp.Public, peer.ContactEncryption)
assert.Equal(t, inviteePeer.MyLookupKp.Public, peer.ContactLookupKey)
assert.Nil(t, peer.InvitationKp, "temp keypair must be cleared after step3")
assert.NotEmpty(t, myCC.DrRootKey)
assert.NotEmpty(t, myCC.DrPublicKey)
}
// TestStep2Step3RoundTripPayload verifies that the PackedUserMessage produced by step2
// actually carries the invitee's ContactCard when decrypted by the initiator.
func TestStep2Step3RoundTripPayload(t *testing.T) {
cfg := client.GetConfig()
cfg.SetMemPass("testpass") //nolint:errcheck
initiator, cleanInit := setupIdentity(t, "alice2")
defer cleanInit()
payload, _, err := messages.Step1InitiatorCreatesInviteeAndTempKey("Bob", "Alice", "", nil)
require.NoError(t, err)
_, cleanInvitee := setupIdentity(t, "bob2")
defer cleanInvitee()
packed, inviteePeer, err := messages.Step2InviteeCreatesInitiatorAndEncryptedContactCard(payload, "Alice", "Bob", nil)
require.NoError(t, err)
// Confirm the message serialises cleanly (transport simulation).
packedBytes, err := proto.Marshal(packed)
require.NoError(t, err)
assert.NotEmpty(t, packedBytes)
// Switch back to initiator and run step3.
cfg.SetIdentity(initiator)
var roundTripped meowlib.PackedUserMessage
require.NoError(t, proto.Unmarshal(packedBytes, &roundTripped))
invitation := &meowlib.Invitation{
Uuid: payload.Uuid,
Step: 2,
From: inviteePeer.MyIdentity.Public,
Payload: packedBytes,
}
_, myCC, err := messages.Step3InitiatorFinalizesInviteeAndCreatesContactCard(invitation)
require.NoError(t, err)
require.NotNil(t, myCC)
// The initiator's CC must reference the invitee's invitation ID so the invitee
// can match it when step4 arrives.
assert.Equal(t, payload.Uuid, myCC.InvitationId)
// Invitee's peer must now hold initiator's real keys and the invitation must be complete.
assert.Equal(t, initPeer.MyIdentity.Public, finalPeer.ContactPublicKey)
assert.Equal(t, initPeer.MyEncryptionKp.Public, finalPeer.ContactEncryption)
assert.Equal(t, initPeer.MyLookupKp.Public, finalPeer.ContactLookupKey)
assert.False(t, finalPeer.InvitationPending(), "invitation must be fully finalized")
assert.NotEmpty(t, finalPeer.DrRootKey)
assert.NotEmpty(t, finalPeer.ContactDrPublicKey)
}
+8 -7
View File
@@ -1,22 +1,23 @@
package messages
import (
"forge.redroom.link/yves/meowlib"
"forge.redroom.link/yves/meowlib/client"
"google.golang.org/protobuf/proto"
)
// Step1InitiatorCreatesInviteeAndTempKey creates a minimal pending peer and a temporary
// keypair, and returns the InvitationInitPayload to be transmitted to the invitee
// via any transport (file, QR, server…).
func Step1InitiatorCreatesInviteeAndTempKey(contactName string, myNickname string, invitationMessage string, serverUids []string) (*meowlib.InvitationInitPayload, *client.Peer, error) {
// keypair, and returns the serialized InvitationInitPayload bytes to be transmitted to
// the invitee via any transport (file, QR, server…). The peer is already persisted by
// InvitationStep1 so no peer reference is returned.
func Step1InitiatorCreatesInviteeAndTempKey(contactName string, myNickname string, invitationMessage string, serverUids []string) ([]byte, error) {
mynick := myNickname
if mynick == "" {
mynick = client.GetConfig().GetIdentity().Nickname
}
payload, peer, err := client.GetConfig().GetIdentity().InvitationStep1(mynick, contactName, serverUids, invitationMessage)
payload, _, err := client.GetConfig().GetIdentity().InvitationStep1(mynick, contactName, serverUids, invitationMessage)
if err != nil {
return nil, nil, err
return nil, err
}
client.GetConfig().GetIdentity().Save()
return payload, peer, nil
return proto.Marshal(payload)
}
+26 -12
View File
@@ -3,31 +3,45 @@ package messages
import (
"forge.redroom.link/yves/meowlib"
"forge.redroom.link/yves/meowlib/client"
"google.golang.org/protobuf/proto"
)
// Step2InviteeCreatesInitiatorAndEncryptedContactCard creates the invitee's peer entry
// from an InvitationInitPayload, then builds the invitee's ContactCard and returns it
// as a PackedUserMessage asymmetrically encrypted with the initiator's temporary public
// key. The packed message is ready to be transmitted to the initiator via any transport
// (file, QR, server…); Step3InitiatorFinalizesInviteeAndCreatesContactCard on the
// initiator side will decrypt and process it.
func Step2InviteeCreatesInitiatorAndEncryptedContactCard(payload *meowlib.InvitationInitPayload, nickname string, myNickname string, serverUids []string) (*meowlib.PackedUserMessage, *client.Peer, error) {
// Step2InviteeCreatesInitiatorAndEncryptedContactCard deserialises the step-1 payload bytes,
// creates the invitee's peer entry, builds and encrypts the invitee's ContactCard, and returns
// a serialized Invitation (step=2) whose Payload is the PackedUserMessage encrypted with the
// initiator's temporary public key. The bytes are transport-ready and consumed directly by
// Step3InitiatorFinalizesInviteeAndCreatesContactCard.
func Step2InviteeCreatesInitiatorAndEncryptedContactCard(payloadBytes []byte, nickname string, myNickname string, serverUids []string) ([]byte, error) {
mynick := myNickname
if mynick == "" {
mynick = client.GetConfig().GetIdentity().Nickname
}
peer, err := client.GetConfig().GetIdentity().InvitationStep2(mynick, nickname, serverUids, payload)
var payload meowlib.InvitationInitPayload
if err := proto.Unmarshal(payloadBytes, &payload); err != nil {
return nil, err
}
peer, err := client.GetConfig().GetIdentity().InvitationStep2(mynick, nickname, serverUids, &payload)
if err != nil {
return nil, nil, err
return nil, err
}
usermsg, err := peer.BuildInvitationStep2Message(peer.GetMyContact())
if err != nil {
return nil, nil, err
return nil, err
}
packed, err := peer.ProcessOutboundUserMessage(usermsg)
if err != nil {
return nil, nil, err
return nil, err
}
packedBytes, err := proto.Marshal(packed)
if err != nil {
return nil, err
}
inv := &meowlib.Invitation{
Uuid: payload.Uuid,
Step: 2,
From: peer.MyIdentity.Public,
Payload: packedBytes,
}
client.GetConfig().GetIdentity().Save()
return packed, peer, nil
return proto.Marshal(inv)
}
+29 -13
View File
@@ -8,39 +8,55 @@ import (
"google.golang.org/protobuf/proto"
)
// Step3InitiatorFinalizesInviteeAndCreatesContactCard is called by the initiator when a
// step-2 answer (invitee's encrypted ContactCard) arrives. It decrypts the card, upgrades
// the invitee's peer entry with the real keys, and returns the initiator's own ContactCard
// ready to be sent to the invitee via any transport.
func Step3InitiatorFinalizesInviteeAndCreatesContactCard(invitation *meowlib.Invitation) (*client.Peer, *meowlib.ContactCard, error) {
// Step3InitiatorFinalizesInviteeAndCreatesContactCard is called by the initiator when the
// step-2 answer (serialized Invitation bytes) arrives. It decrypts the invitee's ContactCard,
// upgrades the pending peer with the invitee's real keys, and returns a serialized Invitation
// (step=3) whose Payload is the initiator's ContactCard, ready to be consumed directly by
// Step4InviteeFinalizesInitiator on the invitee side.
func Step3InitiatorFinalizesInviteeAndCreatesContactCard(invitationBytes []byte) ([]byte, error) {
var invitation meowlib.Invitation
if err := proto.Unmarshal(invitationBytes, &invitation); err != nil {
return nil, err
}
var invitationAnswer meowlib.PackedUserMessage
if err := proto.Unmarshal(invitation.Payload, &invitationAnswer); err != nil {
return nil, nil, err
return nil, err
}
peer := client.GetConfig().GetIdentity().Peers.GetFromInvitationId(invitation.Uuid)
if peer == nil {
return nil, nil, errors.New("no peer for invitation uuid " + invitation.Uuid)
return nil, errors.New("no peer for invitation uuid " + invitation.Uuid)
}
// Guard against duplicate delivery (e.g., same answer from multiple servers).
if peer.InvitationKp == nil {
return nil, nil, nil
return nil, nil
}
usermsg, err := peer.ProcessInboundStep2UserMessage(&invitationAnswer, invitation.From)
if err != nil {
return nil, nil, err
return nil, err
}
var inviteeCC meowlib.ContactCard
if err := proto.Unmarshal(usermsg.Invitation.Payload, &inviteeCC); err != nil {
return nil, nil, err
return nil, err
}
myCC, peer, err := client.GetConfig().GetIdentity().InvitationStep3(&inviteeCC)
myCC, _, err := client.GetConfig().GetIdentity().InvitationStep3(&inviteeCC)
if err != nil {
return nil, nil, err
return nil, err
}
client.GetConfig().GetIdentity().Save()
return peer, myCC, nil
ccBytes, err := proto.Marshal(myCC)
if err != nil {
return nil, err
}
inv := &meowlib.Invitation{
Uuid: myCC.InvitationId,
Step: 3,
Payload: ccBytes,
}
return proto.Marshal(inv)
}
+11 -13
View File
@@ -1,27 +1,25 @@
package messages
import (
"errors"
"forge.redroom.link/yves/meowlib"
"forge.redroom.link/yves/meowlib/client"
"google.golang.org/protobuf/proto"
)
// Step4InviteeFinalizesInitiator is called by the invitee's message processor when a
// UserMessage with invitation.step == 3 arrives. It unmarshals the initiator's ContactCard
// and completes the invitee's peer entry with the initiator's real keys.
func Step4InviteeFinalizesInitiator(usermsg *meowlib.UserMessage) (*client.Peer, error) {
if usermsg.Invitation == nil || usermsg.Invitation.Step != 3 {
return nil, errors.New("expected invitation step 3")
}
var initiatorCC meowlib.ContactCard
if err := proto.Unmarshal(usermsg.Invitation.Payload, &initiatorCC); err != nil {
// Step4InviteeFinalizesInitiator is called by the invitee when the step-3 answer
// (serialized Invitation bytes) arrives. It unmarshals the initiator's ContactCard and
// completes the invitee's peer entry with the initiator's real keys.
func Step4InviteeFinalizesInitiator(invitationBytes []byte) (*client.Peer, error) {
var inv meowlib.Invitation
if err := proto.Unmarshal(invitationBytes, &inv); err != nil {
return nil, err
}
var initiatorCC meowlib.ContactCard
if err := proto.Unmarshal(inv.Payload, &initiatorCC); err != nil {
return nil, err
}
// Patch the invitation ID from the outer message in case it was not set in the CC.
if initiatorCC.InvitationId == "" {
initiatorCC.InvitationId = usermsg.Invitation.Uuid
initiatorCC.InvitationId = inv.Uuid
}
if err := client.GetConfig().GetIdentity().InvitationStep4(&initiatorCC); err != nil {
return nil, err