feat(webgw): web gateway peer (REST + SSE) for the chat SPA
Add cmd/webgw: a single Go binary that holds the operator's bus identity,
connects to the bus as a real authenticated peer (pkg/client), and exposes a
small REST + SSE API the browser consumes. The browser never signs, never
speaks NATS, and never sees a private key.
Endpoints (all under /api, gated by a session cookie except login):
POST /api/login unlock a session with the operator passphrase
POST /api/logout
GET /api/me operator identity the gateway acts as
GET /api/rooms ListMyRooms
POST /api/rooms CreateRoom (default policy: encrypted+persisted+signed)
POST /api/rooms/{id}/join Join (fetch room key)
POST /api/rooms/{id}/send Publish (sealed + signed by the peer)
GET /api/rooms/{id}/stream SSE of decrypted frames (history then live)
Design notes:
- One fan-out hub per room: a single bus subscription is multiplexed to N SSE
clients, avoiding the per-(room,endpoint) durable-consumer contention that
multiple Subscribe calls would cause.
- Posture seam mirrors unibus_admin/clientcheck: empty --ca = plaintext dev,
non-empty = TLS+nkey on both planes; RefreshSession after a membership change
only under the secured (ACL) posture.
- Identity loaded from `pass` or a 0600 file, held only in memory.
- Session auth: passphrase compared in constant time; opaque HttpOnly cookie
so EventSource (which cannot set headers) can authenticate the stream.
TRUST MODEL: room content stays end-to-end encrypted on the bus. The gateway
reads plaintext only because it acts AS the operator's client — a legitimate
member of each room holding the room key. The per-browser wallet (WebCrypto)
that moves decryption into the browser is phase 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
)
|
||||
|
||||
// identityJSON mirrors the on-disk / pass-stored identity format shared across
|
||||
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
|
||||
// shape the bus client persists (pkg/client identity file) and the operator's
|
||||
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
|
||||
// identity without a divergent serialization. Kept in lockstep with
|
||||
// unibus_admin/internal/admin/identity.go.
|
||||
type identityJSON struct {
|
||||
SignPub string `json:"sign_pub"`
|
||||
SignPriv string `json:"sign_priv"`
|
||||
KexPub string `json:"kex_pub"`
|
||||
KexPriv string `json:"kex_priv"`
|
||||
}
|
||||
|
||||
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
|
||||
// halves stay only in memory; this never writes them anywhere.
|
||||
func decodeIdentity(raw []byte) (cs.Identity, error) {
|
||||
var f identityJSON
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
|
||||
}
|
||||
dec := base64.StdEncoding.DecodeString
|
||||
signPub, err := dec(f.SignPub)
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
|
||||
}
|
||||
signPriv, err := dec(f.SignPriv)
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
|
||||
}
|
||||
kexPub, err := dec(f.KexPub)
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
|
||||
}
|
||||
kexPriv, err := dec(f.KexPriv)
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
|
||||
}
|
||||
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
|
||||
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
|
||||
}
|
||||
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
|
||||
}
|
||||
|
||||
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
|
||||
// client writes) and decodes it. Used on a deploy host where `pass` is not
|
||||
// available and the operator identity is delivered as a protected file.
|
||||
func loadIdentityFromFile(path string) (cs.Identity, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
|
||||
}
|
||||
return decodeIdentity(raw)
|
||||
}
|
||||
|
||||
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
|
||||
// identity it returns. The secret is held only in memory; this process never
|
||||
// writes it to disk or argv. Used in local operator workflows where the GNU
|
||||
// password store holds unibus/operator-identity.
|
||||
func loadIdentityFromPass(entry string) (cs.Identity, error) {
|
||||
out, err := exec.Command("pass", "show", entry).Output()
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
||||
}
|
||||
return decodeIdentity(out)
|
||||
}
|
||||
|
||||
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
|
||||
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
|
||||
// no error, so callers can treat "no pass entry configured" as "not set".
|
||||
func loadPassValue(entry string) (string, error) {
|
||||
if entry == "" {
|
||||
return "", nil
|
||||
}
|
||||
out, err := exec.Command("pass", "show", entry).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
|
||||
}
|
||||
s := string(out)
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' || s[i] == '\r' {
|
||||
return s[:i], nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user