2026-06-30 19:23:04 +02:00
|
|
|
// 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"
|
2026-07-02 18:26:19 +02:00
|
|
|
"crypto/sha256"
|
2026-06-30 19:23:04 +02:00
|
|
|
"crypto/sha512"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-07-02 18:26:19 +02:00
|
|
|
"io"
|
2026-06-30 19:23:04 +02:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
|
|
|
|
"filippo.io/edwards25519"
|
|
|
|
|
"golang.org/x/crypto/curve25519"
|
2026-07-02 18:26:19 +02:00
|
|
|
"golang.org/x/crypto/hkdf"
|
2026-06-30 19:23:04 +02:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 18:26:19 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 19:23:04 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|