Files
waste-go/internal/invite/invite.go
Fredrik Johansson 31e13fd509 Add signed invites, hang links, multi-share, and EXTENSIONS.md
- Signed invites: waste: URI gains inviter+sig fields (Ed25519); hello
  carries the invite so receiving peers can verify against known keys
- RequireInvite per-network flag: rejects peers without valid signed invite
- Hash-based hang links: #waste:base64 fragment pre-fills join form without
  server-side leakage of network name
- Multi-share: shares.json (daemon) + waste_shares localStorage (browser);
  IPC add_share/remove_share/list_shares commands
- EXTENSIONS.md: addendum documenting all waste-go protocol deviations from
  YAW/2; all extensions are additive and backward compatible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:05:56 +02:00

131 lines
4.2 KiB
Go

// Package invite encodes and decodes waste invite strings.
//
// Format: "waste:<url-safe-base64(json)>"
//
// The JSON payload is compatible with yaw2: the `net` field carries the full
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
// the same network without knowing the plaintext name.
//
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
// the invite was issued by a known peer. Receiving peers that enforce
// RequireInvite will reject hellos that carry no valid signed invite.
// YAW/2-only peers ignore both fields.
package invite
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)
const prefix = "waste:"
// Invite holds the information needed to join a network.
type Invite struct {
Anchor string `json:"anchor"` // WebSocket anchor URL
Network string `json:"network"` // plaintext network name
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
}
// IsSigned reports whether the invite carries a signature.
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
// Signer can sign data and report its own peer ID.
type Signer interface {
Sign(data []byte) string
PeerIDHex() string
}
// Verifier verifies an Ed25519 signature given a hex public key.
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
// Encode returns an unsigned waste: invite string (backward compatible).
func Encode(anchor, network string) (string, error) {
return marshal(Invite{
Anchor: anchor,
Network: network,
Net: NetHash(network),
})
}
// EncodeSigned returns a signed waste: invite string.
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
if anchor == "" {
return "", fmt.Errorf("anchor URL is required")
}
if network == "" {
return "", fmt.Errorf("network name is required")
}
inviter := signer.PeerIDHex()
net := NetHash(network)
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
return marshal(Invite{
Anchor: anchor,
Network: network,
Net: net,
Inviter: inviter,
Sig: sig,
})
}
// Verify checks the invite signature and that the inviter is in the trusted set.
// Unsigned invites return nil — the caller decides whether to accept them.
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
if !inv.IsSigned() {
return nil
}
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
return fmt.Errorf("invite signature invalid: %w", err)
}
if !trusted[inv.Inviter] {
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
}
return nil
}
// Decode parses a waste: invite string and returns the Invite.
func Decode(s string) (Invite, error) {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, prefix) {
return Invite{}, fmt.Errorf("not a waste invite (expected 'waste:' prefix)")
}
b, err := base64.URLEncoding.DecodeString(strings.TrimPrefix(s, prefix))
if err != nil {
return Invite{}, fmt.Errorf("invalid invite: %w", err)
}
var inv Invite
if err := json.Unmarshal(b, &inv); err != nil {
return Invite{}, fmt.Errorf("invalid invite payload: %w", err)
}
if inv.Anchor == "" || inv.Network == "" {
return Invite{}, fmt.Errorf("invite is missing anchor or network name")
}
return inv, nil
}
// NetHash returns the full 64-char hex network hash for the given name.
func NetHash(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:])
}
func marshal(inv Invite) (string, error) {
b, err := json.Marshal(inv)
if err != nil {
return "", err
}
return prefix + base64.URLEncoding.EncodeToString(b), nil
}
func sigPayload(anchor, network, net, inviter string) []byte {
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
}