Files
flit/cli/internal/crypto/crypto.go
Fredrik Johansson a4bee36f45 Fix multi-room anchor collision via per-room derived identities
The anchor keys its clients map by peer ID. With the daemon joining one
room per trusted peer, both sessions authenticated with the same real
Ed25519 ID — the second register() overwrote the first, orphaning the
earlier room's WS connection.

Fix mirrors waste-go/internal/netmgr: derive a deterministic per-room
Ed25519 keypair from HKDF(master_private_key, room_hash). Same inputs
always produce the same derived ID; different rooms produce different
IDs. No anchor changes required.

cli/internal/crypto: add DeriveForNetwork (HKDF-SHA256, same KDF as
waste-go) so the daemon can derive stable per-room signaling identities.

cli/internal/transport: Join derives a per-room signaling identity
before calling dialSignaling. Real identity is still used for hello
verification and seal/open crypto inside the DataChannel.

pwa/src/transport/flit.ts: split PeerConn.peerId into signalingId
(anchor routing) and cryptoId (seal/open, hello). In pair-room mode
the daemon's signaling ID differs from its real ID, so connectTo and
_onFrom now use trustedPeerId as the cryptoId regardless of what the
anchor reports as the sender. offerByOrder comparison uses real IDs
(trustedPeerId ?? signalingId) so both sides agree. hello verification
now also checks the Ed25519 signature, not just the claimed ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:26:19 +02:00

205 lines
6.5 KiB
Go

// Package crypto implements flit's identity and signaling crypto: Ed25519
// device identity, X25519 ECDH (static, derived from Ed25519; and ephemeral,
// yaw/2.1 forward-secret signaling), and the nacl/box signaling seal.
// Ported and trimmed from waste-go/internal/crypto — dropped chat/backup
// helpers not needed for 1:1 file transfer.
package crypto
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"filippo.io/edwards25519"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/box"
)
var b64 = base64.RawURLEncoding
// ── Identity ──────────────────────────────────────────────────────────────────
type Identity struct {
privateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
type identityFile struct {
PrivateKeyB64 string `json:"private_key"`
}
// LoadOrCreate loads the identity from dataDir/identity.json, or generates a
// fresh keypair if none exists yet.
func LoadOrCreate(dataDir string) (*Identity, error) {
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating data dir: %w", err)
}
path := filepath.Join(dataDir, "identity.json")
data, err := os.ReadFile(path)
if err == nil {
var f identityFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing identity file: %w", err)
}
privBytes, err := b64.DecodeString(f.PrivateKeyB64)
if err != nil {
return nil, fmt.Errorf("decoding private key: %w", err)
}
priv := ed25519.PrivateKey(privBytes)
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("reading identity file: %w", err)
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating keypair: %w", err)
}
f := identityFile{PrivateKeyB64: b64.EncodeToString(priv)}
raw, _ := json.MarshalIndent(f, "", " ")
if err := os.WriteFile(path, raw, 0600); err != nil {
return nil, fmt.Errorf("saving identity: %w", err)
}
return &Identity{privateKey: priv, PublicKey: pub}, nil
}
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key.
func (id *Identity) PeerID() string { return hex.EncodeToString(id.PublicKey) }
func (id *Identity) Sign(data []byte) string {
return hex.EncodeToString(ed25519.Sign(id.privateKey, data))
}
func Verify(publicKeyHex string, data []byte, sigHex string) error {
pubBytes, err := hex.DecodeString(publicKeyHex)
if err != nil {
return fmt.Errorf("decoding public key: %w", err)
}
sigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("decoding signature: %w", err)
}
if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) {
return errors.New("signature verification failed")
}
return nil
}
// DeriveForNetwork returns a deterministic in-memory Identity derived from the
// master key and the given network hash (hex). Same inputs always produce the
// same keypair, so the peer ID is stable across restarts. Different network
// hashes produce different peer IDs, preventing the anchor's peer-ID map from
// colliding when the same device joins multiple rooms simultaneously.
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
var seed [32]byte
if _, err := io.ReadFull(r, seed[:]); err != nil {
return nil, fmt.Errorf("deriving network identity: %w", err)
}
priv := ed25519.NewKeyFromSeed(seed[:])
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
}
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
func (id *Identity) CurvePublicKey() *[32]byte {
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
mont := edPoint.BytesMontgomery()
var out [32]byte
copy(out[:], mont)
return &out
}
// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity.
func (id *Identity) CurvePrivateKey() *[32]byte {
h := sha512.Sum512(id.privateKey[:32])
h[0] &= 248
h[31] &= 127
h[31] |= 64
var out [32]byte
copy(out[:], h[:32])
return &out
}
// CurveFromPeerID derives the X25519 public key from a peer's hex Ed25519 id.
func CurveFromPeerID(idHex string) (*[32]byte, error) {
pubBytes, err := hex.DecodeString(idHex)
if err != nil || len(pubBytes) != 32 {
return nil, fmt.Errorf("invalid peer id %q", idHex)
}
edPoint, err := new(edwards25519.Point).SetBytes(pubBytes)
if err != nil {
return nil, fmt.Errorf("ed25519 point: %w", err)
}
mont := edPoint.BytesMontgomery()
var out [32]byte
copy(out[:], mont)
return &out, nil
}
// ── Signaling seal (nacl/box, base64 std-padded) ──────────────────────────────
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
var nonce [24]byte
rand.Read(nonce[:]) //nolint:errcheck
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
return base64.StdEncoding.EncodeToString(ct)
}
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(b64box)
if err != nil || len(raw) < 24 {
return nil, errors.New("invalid box")
}
var nonce [24]byte
copy(nonce[:], raw[:24])
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
if !ok {
return nil, errors.New("box open failed")
}
return out, nil
}
// ── X25519 ephemeral keys (yaw/2.1 forward secrecy) ───────────────────────────
type EphemeralKey struct {
private [32]byte
public [32]byte
}
func GenerateEphemeral() (*EphemeralKey, error) {
ek := &EphemeralKey{}
if _, err := rand.Read(ek.private[:]); err != nil {
return nil, fmt.Errorf("generating ephemeral key: %w", err)
}
ek.private[0] &= 248
ek.private[31] &= 127
ek.private[31] |= 64
pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint)
if err != nil {
return nil, fmt.Errorf("computing public key: %w", err)
}
copy(ek.public[:], pub)
return ek, nil
}
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
func (ek *EphemeralKey) Wipe() {
for i := range ek.private {
ek.private[i] = 0
}
}