Add yaw2 invite interoperability

Protocol:
- invite.go: include full 64-char `net` hash in waste: invite blob
  (matches yaw2's `net` field — any client parsing the base64 JSON can
  join without knowing the plaintext name). Expose NetHash() helper.
- netmgr: add JoinByHash() — join via full 64-char hex hash alone,
  storing the short ID as display name. Enables joining yaw2 networks
  from a URL that only carries the hash.
- anchor: expose RunByHash() so netmgr can pass a pre-computed hash
  directly without a name→hash roundtrip.
- ipc/proto: add network_hash field to join_network — routes to
  JoinByHash when present and network_name is absent.

Web UI:
- Parse ?net=<64hex> (yaw2 URL param) and ?a=<anchor> in addition to
  existing ?n= / ?invite= params. Hash-only joins send network_hash.
- Sidebar shows yaw: contact card (yaw:<masterID>?n=<alias>) using the
  master identity — compatible with yaw2 contact card format. Click to
  copy to clipboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 23:29:45 +02:00
parent 7fe02e9463
commit add7c5fea8
8 changed files with 193 additions and 33 deletions

View File

@@ -60,7 +60,12 @@ func (s *peerSession) close() {
// Run connects to anchorURL, joins networkName, and blocks handling signaling.
// Reconnects automatically on disconnect. Cancel ctx to stop.
func Run(ctx context.Context, anchorURL, networkName string, id *crypto.Identity, m *mesh.Mesh) {
netHash := hashNetName(networkName)
RunByHash(ctx, anchorURL, hashNetName(networkName), id, m)
}
// RunByHash is like Run but accepts the pre-computed full 64-char hex network hash
// directly. Use this when joining by hash rather than by name.
func RunByHash(ctx context.Context, anchorURL, netHash string, id *crypto.Identity, m *mesh.Mesh) {
for {
if err := runOnce(ctx, anchorURL, netHash, id, m); err != nil {
if ctx.Err() != nil {

View File

@@ -1,10 +1,17 @@
// Package invite encodes and decodes waste invite strings.
// An invite carries the anchor URL and network name needed to join a network.
//
// 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.
package invite
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -14,8 +21,9 @@ 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
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
}
// Encode returns a waste: invite string for the given anchor URL and network name.
@@ -26,7 +34,12 @@ func Encode(anchor, network string) (string, error) {
if network == "" {
return "", fmt.Errorf("network name is required")
}
b, err := json.Marshal(Invite{Anchor: anchor, Network: network})
h := sha256.Sum256([]byte("yaw2-net:" + network))
b, err := json.Marshal(Invite{
Anchor: anchor,
Network: network,
Net: hex.EncodeToString(h[:]),
})
if err != nil {
return "", err
}
@@ -52,3 +65,10 @@ func Decode(s string) (Invite, error) {
}
return inv, nil
}
// NetHash returns the full 64-char hex network hash for the given name
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
func NetHash(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:])
}

View File

@@ -137,16 +137,25 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
switch cmd.Type {
case proto.CmdJoinNetwork:
if cmd.NetworkName == "" {
send(errMsg("join_network: network_name is required"))
var (
netID string
err error
)
switch {
case cmd.NetworkName != "":
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
case len(cmd.NetworkHash) == 64:
// yaw2-compatible: join by full 64-char hex hash (net field)
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
default:
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
continue
}
netID, err := mgr.Join(cmd.NetworkName, cmd.ShareDir)
if err != nil {
send(errMsg(fmt.Sprintf("join_network: %v", err)))
continue
}
// network_joined event (with share_dir) is emitted by Manager.Join.
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash.
_ = netID
case proto.CmdLeaveNetwork:

View File

@@ -147,6 +147,88 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
return netID, nil
}
// JoinByHash joins a network using its pre-computed full 64-char hex hash
// (yaw2 `net` field) instead of the plaintext name. The network is stored
// with an empty name; the network_id (first 8 bytes) is used for display.
// This enables joining networks whose names are unknown — e.g. from a yaw2
// invite URL that only contains the hash.
func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
if len(netHash64) != 64 {
return "", fmt.Errorf("netHash must be 64 hex chars, got %d", len(netHash64))
}
netID := netHash64[:16] // first 8 bytes = 16 hex chars
mgr.mu.Lock()
if _, exists := mgr.networks[netID]; exists {
mgr.mu.Unlock()
return netID, nil
}
mgr.mu.Unlock()
derived, err := crypto.DeriveForNetwork(mgr.cfg.MasterIdentity, netHash64)
if err != nil {
return "", fmt.Errorf("derive identity for net %s: %w", netID, err)
}
dbPath := filepath.Join(mgr.cfg.StoreDir, "messages-"+netID+".db")
st, err := store.Open(dbPath)
if err != nil {
return "", fmt.Errorf("open store for net %s: %w", netID, err)
}
m := mesh.New(derived, st)
if shareDir != "" {
m.ShareDir = shareDir
} else if mgr.cfg.ShareDir != "" {
m.ShareDir = mgr.cfg.ShareDir
}
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
meshEvents := m.Subscribe()
go func() {
for evt := range meshEvents {
evt.NetworkID = netID
mgr.emit(evt)
}
}()
ctx, cancel := context.WithCancel(context.Background())
net := &Network{
ID: netID,
Name: netID, // display as short hash when name is unknown
Hash: netHash64,
Identity: derived,
Mesh: m,
Store: st,
cancel: cancel,
}
mgr.mu.Lock()
mgr.networks[netID] = net
mgr.order = append(mgr.order, netID)
mgr.mu.Unlock()
log.Printf("netmgr: joining network by hash id=%s peer=%s", netID, derived.PeerID().Short())
if mgr.cfg.AnchorURL != "" {
go func() {
anchor.RunByHash(ctx, mgr.cfg.AnchorURL, netHash64, derived, m)
log.Printf("netmgr: left network %s", netID)
}()
}
mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined,
NetworkID: netID,
NetworkName: netID,
LocalPeer: peerPtr(derived.PeerInfo()),
ShareDir: m.ShareDir,
})
return netID, nil
}
// Leave cancels a network context by ID. Closes its store.
func (mgr *Manager) Leave(netID string) {
mgr.mu.Lock()

View File

@@ -265,7 +265,8 @@ type IpcMessage struct {
// join_network / leave_network
NetworkName string `json:"network_name,omitempty"`
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
// send_file / set_share_dir / file_complete path
Path string `json:"path,omitempty"`