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>
205 lines
6.5 KiB
Go
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
|
|
}
|
|
}
|