// auth.go: authentication between the hub and its peers. // // The transport is NOT assumed trustworthy. It could be Tailscale, // ZeroTier, WireGuard, or a port opened straight to the internet: that's // up to whoever runs it, not us. So the connection defends itself. // // The token is REQUIRED and generated by the program (nobody gets to pick // "1234"), and it never travels over the network: the hub sends a random // challenge and the peer replies with an HMAC of it, keyed by the token. // Anyone listening to the traffic doesn't get the token, and can't replay // an old response because the challenge changes on every connection. // // This proves the PEER's identity to the hub. It doesn't encrypt // anything by itself — that's TLS's job now, one layer up (see // tlscert.go/pin.go), which in turn proves the HUB's identity to the // peer via certificate pinning. Neither layer replaces the other. package main import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" ) const ( tokenBytes = 16 // 128 bits nonceBytes = 16 // Deadline to authenticate. A connection that goes quiet after the // challenge gets closed instead of holding a slot forever. authTimeoutSeconds = 10 // Cap on simultaneous connections: keeps someone from starving the // port of resources by opening sockets. maxPeerConns = 8 ) func tokenPath() string { if dir, ok := exeDir(); ok { return filepath.Join(dir, "token.txt") } return "token.txt" } // tokenFile is token.txt's on-disk shape. type tokenFile struct { Token string `json:"token"` } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // resolveToken decides the effective token. If the config has one, that // wins (for anyone who wants to pick it by hand or share a pre-agreed // one). Otherwise it uses the one in token.txt, generating and saving one // if that doesn't exist either. // // Also returns whether it was just generated, so it can be announced // loudly on the console: that's what needs to be handed to your partner. func resolveToken(cfg config) (token string, generated bool, err error) { if t := strings.TrimSpace(cfg.Token); t != "" { return t, false, nil } path := tokenPath() if data, rerr := os.ReadFile(path); rerr == nil { var tf tokenFile if jerr := json.Unmarshal(data, &tf); jerr == nil { if t := strings.TrimSpace(tf.Token); t != "" { return t, false, nil } } } t, gerr := randomHex(tokenBytes) if gerr != nil { return "", false, fmt.Errorf("couldn't generate a token: %w", gerr) } blob, merr := json.MarshalIndent(tokenFile{Token: t}, "", " ") if merr != nil { return "", false, fmt.Errorf("couldn't encode token.txt: %w", merr) } if werr := os.WriteFile(path, blob, 0600); werr != nil { return "", false, fmt.Errorf("couldn't save token.txt: %w", werr) } return t, true, nil } // proofFor computes the answer to the challenge: HMAC-SHA256(token, nonce). func proofFor(token, nonce string) string { m := hmac.New(sha256.New, []byte(token)) m.Write([]byte(nonce)) return hex.EncodeToString(m.Sum(nil)) } // proofValid compares in constant time. With == the comparison short- // circuits at the first differing byte, which leaks information via timing. func proofValid(token, nonce, got string) bool { want := proofFor(token, nonce) return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 } func logTokenBanner(token string, generated bool) { if generated { log.Printf("generated a new token and saved it to token.txt") } log.Printf("connection token: %s", token) log.Printf("your partner needs to put THAT exact token in their config.toml; without it, their connection gets rejected") }