diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ecd776a..a300559 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -19,6 +19,8 @@ // agentbbs map-domain DOMAIN NAME map a custom domain to a homepage // agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping // agentbbs mint-token NAME issue a WebSocket API token for NAME +// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME +// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key package main import ( @@ -64,6 +66,7 @@ import ( "github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) func env(k, def string) string { @@ -121,6 +124,14 @@ func main() { mintToken(st, os.Args[2:]) return } + if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" { + qryptInviteCmd(st, os.Args[2:]) + return + } + if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" { + qryptIssuerKeygen() + return + } host := env("AGENTBBS_HOST", "bbs.profullstack.com") fe := forwardemail.ConfigFromEnv() @@ -141,7 +152,7 @@ func main() { a.mm = games.NewMatchmaker(a.gamesReg, a.st, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) - a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}} + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. diff --git a/cmd/agentbbs/qrypt.go b/cmd/agentbbs/qrypt.go new file mode 100644 index 0000000..461a715 --- /dev/null +++ b/cmd/agentbbs/qrypt.go @@ -0,0 +1,86 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + qi "github.com/profullstack/agentbbs/internal/qryptinvite" + "github.com/profullstack/agentbbs/internal/store" +) + +// qryptInviteCmd is the ops side of qrypt.chat invites: +// `agentbbs qrypt-invite ` mints a single-use anonymous invite on behalf +// of an existing member, respecting their per-account quota, and prints the +// token + redeem URL (docs/qrypt-invites.md). +func qryptInviteCmd(st store.Store, args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: agentbbs qrypt-invite ") + os.Exit(2) + } + name := strings.ToLower(args[0]) + if _, found, err := st.UserByName(name); err != nil { + fmt.Fprintln(os.Stderr, "lookup:", err) + os.Exit(1) + } else if !found { + fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name) + os.Exit(1) + } + + cfg := qi.ConfigFromEnv() + priv, err := cfg.PrivateKey() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + token, jti, err := qi.Mint(cfg.IssuerID, priv, cfg.TTL) + if err != nil { + fmt.Fprintln(os.Stderr, "mint:", err) + os.Exit(1) + } + if err := st.RecordQryptInvite(name, jti, cfg.Quota); err != nil { + if errors.Is(err, store.ErrQuotaExceeded) { + used, _ := st.QryptInviteCount(name) + fmt.Fprintf(os.Stderr, "%s is at their invite quota (%d/%d)\n", name, used, cfg.Quota) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "record:", err) + os.Exit(1) + } + + used, _ := st.QryptInviteCount(name) + remaining := cfg.Quota - used + if remaining < 0 { + remaining = 0 + } + fmt.Printf("qrypt.chat invite for %s (issuer %s, expires in %s, single-use):\n\n", name, cfg.IssuerID, cfg.TTL) + fmt.Printf(" redeem %s\n", cfg.RedeemURLFor(token)) + fmt.Printf(" token %s\n", token) + fmt.Printf(" jti %s\n\n", jti) + if cfg.Quota > 0 { + fmt.Printf("invites left for %s: %d/%d\n", name, remaining, cfg.Quota) + } +} + +// qryptIssuerKeygen prints a fresh Ed25519 issuer keypair for first-time setup: +// the base64 seed (private — goes in AGENTBBS_QRYPT_ISSUER_KEY) and the base64 +// raw public key (goes in qrypt.chat's invite_issuers row). +func qryptIssuerKeygen() { + seed, pub, err := qi.GenerateIssuerKey() + if err != nil { + fmt.Fprintln(os.Stderr, "keygen:", err) + os.Exit(1) + } + cfg := qi.ConfigFromEnv() + fmt.Println("Fresh qrypt.chat invite-issuer keypair:") + fmt.Println() + fmt.Println(" 1) On agentbbs, set the PRIVATE seed (keep it secret):") + fmt.Printf(" AGENTBBS_QRYPT_ISSUER_KEY=%s\n\n", seed) + fmt.Println(" 2) In qrypt.chat, insert an invite_issuers row with the PUBLIC key:") + fmt.Printf(" id %s\n", cfg.IssuerID) + fmt.Printf(" ed25519_public_key %s\n\n", pub) + fmt.Printf(" INSERT INTO invite_issuers (id, name, ed25519_public_key)\n") + fmt.Printf(" VALUES ('%s', 'AgentBBS', '%s');\n", cfg.IssuerID, pub) +} diff --git a/docs/qrypt-invites.md b/docs/qrypt-invites.md new file mode 100644 index 0000000..3ccbd63 --- /dev/null +++ b/docs/qrypt-invites.md @@ -0,0 +1,105 @@ +# qrypt.chat anonymous invites + +AgentBBS is a **trusted issuer** for [qrypt.chat](https://qrypt.chat) anonymous +accounts. A verified AgentBBS member can mint a signed, single-use invite token; +the separate qrypt.chat app verifies the signature and redeems the token into an +**anonymous** account (no phone number). AgentBBS holds the private key and +signs; qrypt.chat only ever sees the public key. + +This is additive and isolated: it touches nothing in the join/SMS/pod paths. + +## The bridge + +``` +member --ssh--> AgentBBS (issuer, has Ed25519 priv key) --signed token--> qrypt.chat (verifier, has pub key) +``` + +- AgentBBS mints `qci1..` tokens with `crypto/ed25519`. +- qrypt.chat looks up the issuer's public key by `payload.iss` in its + `invite_issuers` table, verifies the signature, checks expiry, and burns the + `jti` (single use) on redeem. + +## Token format (v1) + +A single fixed algorithm (Ed25519) — not a JWT, no `alg` field. + +``` +token = "qci1." + b64url(payloadJSON) + "." + b64url(sig) +signing input = "qci1." + b64url(payloadJSON) # the first two segments +sig = Ed25519.Sign(issuerPriv, []byte(signingInput)) +b64url = base64 URL-encoding, NO padding +``` + +`payloadJSON`: + +```json +{ "jti": "16-random-bytes-hex", "iss": "agentbbs", "tier": "anonymous", + "iat": 1700000000, "exp": 1700604800, "uses": 1 } +``` + +Implemented in [`internal/qryptinvite`](../internal/qryptinvite). Verifier rules +(qrypt.chat side): exactly 3 segments; `segment[0] == "qci1"`; known/enabled +issuer; valid signature; `now <= exp`; `jti` not already redeemed. + +## Setup (operator, once) + +1. Generate a keypair on the AgentBBS host: + + ```bash + agentbbs qrypt-issuer-keygen + ``` + + It prints a **private seed** (base64) and a **public key** (base64). + +2. Set the private seed in `agentbbs.env` (see `setup.sh`) and restart: + + ``` + AGENTBBS_QRYPT_ISSUER_KEY= + ``` + + Until this is set, minting is disabled (the plugin says "not configured"). + +3. Register the **public** key in qrypt.chat (service-role / SQL): + + ```sql + INSERT INTO invite_issuers (id, name, ed25519_public_key) + VALUES ('agentbbs', 'AgentBBS', ''); + ``` + + The `id` must match `AGENTBBS_QRYPT_ISSUER_ID` (default `agentbbs`). + +## Config (env) + +| Var | Default | Meaning | +|---|---|---| +| `AGENTBBS_QRYPT_ISSUER_ID` | `agentbbs` | issuer id; must match qrypt's `invite_issuers.id` | +| `AGENTBBS_QRYPT_ISSUER_KEY` | unset | base64 Ed25519 seed (32B) or full key (64B); **required to mint** | +| `AGENTBBS_QRYPT_INVITE_TTL` | `168h` | token lifetime (Go duration) | +| `AGENTBBS_QRYPT_REDEEM_URL` | `https://qrypt.chat/anon?invite=` | redeem URL; the token is appended | +| `AGENTBBS_QRYPT_INVITE_QUOTA` | `5` | per-member cap (0 = unlimited) | + +## Member usage (over SSH) + +From the hub, pick **"qrypt.chat invite"**: + +```bash +ssh @bbs.profullstack.com # hub → "qrypt.chat invite" +``` + +It checks the member's quota, mints a single-use token, records it (incrementing +the quota), and prints the redeem URL plus the raw token. The member opens the +URL on qrypt.chat to create their anonymous account. + +## Ops usage (CLI) + +```bash +agentbbs qrypt-invite # mint on behalf of a member (respects quota) +agentbbs qrypt-issuer-keygen # print a fresh seed + public key (first-time setup) +``` + +## Quota storage + +Per-member issuance is counted in the AgentBBS SQLite `qrypt_invites` table +(`jti` PRIMARY KEY, `username`, `created_at`). `RecordQryptInvite` enforces the +cap inside a transaction, so concurrent mints can't both exceed it. The stored +`jti` values are also an audit trail of what AgentBBS handed out. diff --git a/internal/qryptinvite/config.go b/internal/qryptinvite/config.go new file mode 100644 index 0000000..462b22d --- /dev/null +++ b/internal/qryptinvite/config.go @@ -0,0 +1,74 @@ +package qryptinvite + +import ( + "crypto/ed25519" + "errors" + "os" + "strconv" + "time" +) + +// Config is the resolved qrypt.chat invite-issuer configuration, read from the +// environment (see docs/qrypt-invites.md). It is shared by the SSH plugin and +// the ops CLI so they mint identical tokens. +type Config struct { + IssuerID string // AGENTBBS_QRYPT_ISSUER_ID (default "agentbbs") + Key string // AGENTBBS_QRYPT_ISSUER_KEY (base64 seed/priv) + TTL time.Duration // AGENTBBS_QRYPT_INVITE_TTL (default 168h) + RedeemURL string // AGENTBBS_QRYPT_REDEEM_URL (default https://qrypt.chat/anon?invite=) + Quota int // AGENTBBS_QRYPT_INVITE_QUOTA (default 5) +} + +// DefaultIssuerID, DefaultRedeemURL, DefaultTTL and DefaultQuota are the +// fallbacks when the corresponding env var is unset. +const ( + DefaultIssuerID = "agentbbs" + DefaultRedeemURL = "https://qrypt.chat/anon?invite=" + DefaultTTL = 168 * time.Hour + DefaultQuota = 5 +) + +// ConfigFromEnv reads the AGENTBBS_QRYPT_* environment variables, applying +// defaults. The key is not validated here; call PrivateKey to parse it. +func ConfigFromEnv() Config { + c := Config{ + IssuerID: DefaultIssuerID, + Key: os.Getenv("AGENTBBS_QRYPT_ISSUER_KEY"), + TTL: DefaultTTL, + RedeemURL: DefaultRedeemURL, + Quota: DefaultQuota, + } + if v := os.Getenv("AGENTBBS_QRYPT_ISSUER_ID"); v != "" { + c.IssuerID = v + } + if v := os.Getenv("AGENTBBS_QRYPT_REDEEM_URL"); v != "" { + c.RedeemURL = v + } + if v := os.Getenv("AGENTBBS_QRYPT_INVITE_TTL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + c.TTL = d + } + } + if v := os.Getenv("AGENTBBS_QRYPT_INVITE_QUOTA"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.Quota = n + } + } + return c +} + +// ErrNoKey means AGENTBBS_QRYPT_ISSUER_KEY is unset, so no tokens can be minted. +var ErrNoKey = errors.New("qryptinvite: AGENTBBS_QRYPT_ISSUER_KEY is not set (run: agentbbs qrypt-issuer-keygen)") + +// PrivateKey parses the configured issuer key, or returns ErrNoKey if unset. +func (c Config) PrivateKey() (ed25519.PrivateKey, error) { + if c.Key == "" { + return nil, ErrNoKey + } + return ParsePrivateKey(c.Key) +} + +// RedeemLink returns the full URL a member opens to redeem token. +func (c Config) RedeemURLFor(token string) string { + return c.RedeemURL + token +} diff --git a/internal/qryptinvite/qryptinvite.go b/internal/qryptinvite/qryptinvite.go new file mode 100644 index 0000000..aad090c --- /dev/null +++ b/internal/qryptinvite/qryptinvite.go @@ -0,0 +1,141 @@ +// Package qryptinvite mints single-use, Ed25519-signed invite tokens that the +// qrypt.chat app accepts to create an ANONYMOUS account. AgentBBS is the +// trusted issuer: it holds the private key and signs tokens; qrypt.chat verifies +// them against the issuer's public key registered in its invite_issuers table. +// +// Token format (v1, see the shared qrypt-invite contract): +// +// token = "qci1." + b64url(payloadJSON) + "." + b64url(sig) +// signing input = "qci1." + b64url(payloadJSON) (the first two segments) +// sig = Ed25519.Sign(issuerPriv, []byte(signing input)) +// b64url = base64 URL-encoding, NO padding +// +// There is no alg field and this is not a JWT: Ed25519 is the single fixed +// algorithm. +package qryptinvite + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Prefix is segment[0] of every v1 token; verifiers must reject anything else. +const Prefix = "qci1" + +// b64 is the URL-safe, unpadded base64 alphabet the contract mandates. +var b64 = base64.RawURLEncoding + +// Payload is the JSON body carried in segment[1] of a token. Field tags match +// the contract exactly; qrypt.chat decodes the same shape. +type Payload struct { + JTI string `json:"jti"` // 16 random bytes, hex (32 chars); burns the token + Iss string `json:"iss"` // issuer id, e.g. "agentbbs" + Tier string `json:"tier"` // always "anonymous" in v1 + Iat int64 `json:"iat"` // issued-at (unix seconds) + Exp int64 `json:"exp"` // expiry (unix seconds) + Uses int `json:"uses"` // single-use: 1 +} + +// Mint produces a signed single-use anonymous invite token for issuerID, valid +// for ttl. It returns the token string and its jti (the unique id qrypt.chat +// stores on redeem to prevent double-spend). +func Mint(issuerID string, priv ed25519.PrivateKey, ttl time.Duration) (token string, jti string, err error) { + if issuerID == "" { + return "", "", errors.New("qryptinvite: empty issuer id") + } + if len(priv) != ed25519.PrivateKeySize { + return "", "", fmt.Errorf("qryptinvite: private key is %d bytes, want %d", len(priv), ed25519.PrivateKeySize) + } + if ttl <= 0 { + return "", "", errors.New("qryptinvite: ttl must be positive") + } + + jti, err = newJTI() + if err != nil { + return "", "", err + } + now := time.Now() + payload := Payload{ + JTI: jti, + Iss: issuerID, + Tier: "anonymous", + Iat: now.Unix(), + Exp: now.Add(ttl).Unix(), + Uses: 1, + } + pj, err := json.Marshal(payload) + if err != nil { + return "", "", err + } + signingInput := Prefix + "." + b64.EncodeToString(pj) + sig := ed25519.Sign(priv, []byte(signingInput)) + return signingInput + "." + b64.EncodeToString(sig), jti, nil +} + +// newJTI returns 16 random bytes hex-encoded (32 chars), per the contract. +func newJTI() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// GenerateIssuerKey creates a fresh Ed25519 issuer keypair for first-time setup. +// seedB64 is the 32-byte seed (the PRIVATE half — set it as AGENTBBS_QRYPT_ISSUER_KEY); +// publicKeyB64 is the raw 32-byte public key (register it in qrypt.chat's +// invite_issuers.ed25519_public_key). Both are standard base64. +func GenerateIssuerKey() (seedB64 string, publicKeyB64 string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + seed := priv.Seed() // 32 bytes + return base64.StdEncoding.EncodeToString(seed), + base64.StdEncoding.EncodeToString(pub), nil +} + +// ParsePrivateKey decodes a base64 issuer key into an ed25519.PrivateKey. It +// accepts either a 32-byte seed (preferred, what GenerateIssuerKey emits) or a +// full 64-byte private key. Base64 may be standard or URL-encoded, padded or not. +func ParsePrivateKey(b64key string) (ed25519.PrivateKey, error) { + raw, err := decodeBase64Any(b64key) + if err != nil { + return nil, fmt.Errorf("qryptinvite: decode private key: %w", err) + } + switch len(raw) { + case ed25519.SeedSize: // 32 + return ed25519.NewKeyFromSeed(raw), nil + case ed25519.PrivateKeySize: // 64 + return ed25519.PrivateKey(raw), nil + default: + return nil, fmt.Errorf("qryptinvite: private key is %d bytes, want %d (seed) or %d (full key)", + len(raw), ed25519.SeedSize, ed25519.PrivateKeySize) + } +} + +// PublicKeyB64 returns the raw 32-byte public key (standard base64) for a +// private key — the value an operator registers in qrypt.chat. +func PublicKeyB64(priv ed25519.PrivateKey) string { + pub := priv.Public().(ed25519.PublicKey) + return base64.StdEncoding.EncodeToString(pub) +} + +// decodeBase64Any tries the four base64 variants the contract may produce. +func decodeBase64Any(s string) ([]byte, error) { + for _, enc := range []*base64.Encoding{ + base64.StdEncoding, base64.RawStdEncoding, + base64.URLEncoding, base64.RawURLEncoding, + } { + if b, err := enc.DecodeString(s); err == nil { + return b, nil + } + } + return nil, errors.New("not valid base64") +} diff --git a/internal/qryptinvite/qryptinvite_test.go b/internal/qryptinvite/qryptinvite_test.go new file mode 100644 index 0000000..9348549 --- /dev/null +++ b/internal/qryptinvite/qryptinvite_test.go @@ -0,0 +1,214 @@ +package qryptinvite + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +// verifyAsQrypt independently checks a token the way the qrypt.chat backend +// would: split into 3 segments, require segment[0] == "qci1", verify the +// Ed25519 signature over "qci1."+payloadSeg with the issuer's public key, and +// decode the payload. It deliberately does NOT reuse Mint's internals. +func verifyAsQrypt(t *testing.T, token string, pub ed25519.PublicKey) Payload { + t.Helper() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d segments, want 3", len(parts)) + } + if parts[0] != "qci1" { + t.Fatalf("segment[0] = %q, want qci1", parts[0]) + } + signingInput := parts[0] + "." + parts[1] + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if !ed25519.Verify(pub, []byte(signingInput), sig) { + t.Fatal("signature did not verify") + } + pj, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var p Payload + if err := json.Unmarshal(pj, &p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return p +} + +func TestMintAndVerify(t *testing.T) { + seedB64, pubB64, err := GenerateIssuerKey() + if err != nil { + t.Fatal(err) + } + priv, err := ParsePrivateKey(seedB64) + if err != nil { + t.Fatalf("ParsePrivateKey(seed): %v", err) + } + pubRaw, err := base64.StdEncoding.DecodeString(pubB64) + if err != nil { + t.Fatal(err) + } + pub := ed25519.PublicKey(pubRaw) + + before := time.Now() + token, jti, err := Mint("agentbbs", priv, 168*time.Hour) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + p := verifyAsQrypt(t, token, pub) + + if p.Iss != "agentbbs" { + t.Errorf("iss = %q, want agentbbs", p.Iss) + } + if p.Tier != "anonymous" { + t.Errorf("tier = %q, want anonymous", p.Tier) + } + if p.Uses != 1 { + t.Errorf("uses = %d, want 1", p.Uses) + } + if p.JTI != jti { + t.Errorf("payload jti %q != returned jti %q", p.JTI, jti) + } + if len(p.JTI) != 32 { + t.Errorf("jti len = %d, want 32 hex chars", len(p.JTI)) + } + if p.Exp <= time.Now().Unix() { + t.Errorf("exp %d is not in the future", p.Exp) + } + if p.Iat < before.Unix()-1 || p.Iat > time.Now().Unix()+1 { + t.Errorf("iat %d outside the mint window", p.Iat) + } + // exp == iat + ttl + if got, want := p.Exp-p.Iat, int64((168 * time.Hour).Seconds()); got != want { + t.Errorf("exp-iat = %d, want %d", got, want) + } +} + +func TestJTIUnique(t *testing.T) { + seedB64, _, err := GenerateIssuerKey() + if err != nil { + t.Fatal(err) + } + priv, err := ParsePrivateKey(seedB64) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for i := 0; i < 100; i++ { + _, jti, err := Mint("agentbbs", priv, time.Hour) + if err != nil { + t.Fatal(err) + } + if seen[jti] { + t.Fatalf("duplicate jti %q on iteration %d", jti, i) + } + seen[jti] = true + } +} + +func TestTamperedTokenFails(t *testing.T) { + seedB64, pubB64, err := GenerateIssuerKey() + if err != nil { + t.Fatal(err) + } + priv, _ := ParsePrivateKey(seedB64) + pubRaw, _ := base64.StdEncoding.DecodeString(pubB64) + pub := ed25519.PublicKey(pubRaw) + + token, _, err := Mint("agentbbs", priv, time.Hour) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(token, ".") + + // Tamper with the payload: flip the tier to "verified" and re-encode. The + // signature was made over the original payload, so verification must fail. + pj, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var p Payload + if err := json.Unmarshal(pj, &p); err != nil { + t.Fatal(err) + } + p.Tier = "verified" + p.Uses = 9999 + tj, _ := json.Marshal(p) + tampered := parts[0] + "." + base64.RawURLEncoding.EncodeToString(tj) + "." + parts[2] + + if got := verifySig(tampered, pub); got { + t.Fatal("tampered token verified but should have failed") + } + // The untouched token still verifies, proving the key is right. + if !verifySig(token, pub) { + t.Fatal("original token failed to verify") + } + + // Tampering with the signature segment must also fail. + badSig := parts[0] + "." + parts[1] + "." + flipLastChar(parts[2]) + if verifySig(badSig, pub) { + t.Fatal("token with corrupted signature verified but should have failed") + } +} + +// verifySig is a minimal boolean form of the qrypt verify path. +func verifySig(token string, pub ed25519.PublicKey) bool { + parts := strings.Split(token, ".") + if len(parts) != 3 || parts[0] != "qci1" { + return false + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + return ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) +} + +func flipLastChar(s string) string { + if s == "" { + return s + } + b := []byte(s) + last := b[len(b)-1] + if last == 'A' { + b[len(b)-1] = 'B' + } else { + b[len(b)-1] = 'A' + } + return string(b) +} + +func TestParsePrivateKeyAcceptsSeedAndFull(t *testing.T) { + _, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + seedB64 := base64.StdEncoding.EncodeToString(priv.Seed()) + fullB64 := base64.StdEncoding.EncodeToString(priv) + + fromSeed, err := ParsePrivateKey(seedB64) + if err != nil { + t.Fatalf("seed: %v", err) + } + fromFull, err := ParsePrivateKey(fullB64) + if err != nil { + t.Fatalf("full: %v", err) + } + if !fromSeed.Equal(fromFull) { + t.Fatal("seed and full-key parses produced different keys") + } + if !fromSeed.Equal(priv) { + t.Fatal("parsed key differs from original") + } + + if _, err := ParsePrivateKey("not-base64-@@@"); err == nil { + t.Error("expected error on garbage input") + } + if _, err := ParsePrivateKey(base64.StdEncoding.EncodeToString([]byte("short"))); err == nil { + t.Error("expected error on wrong-length key") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index b11b776..0e168ef 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -152,6 +152,15 @@ type Store interface { // UserByToken resolves an API token to its account name. UserByToken(token string) (string, bool, error) + // qrypt.chat anonymous-invite issuance (docs/qrypt-invites.md). + + // QryptInviteCount reports how many qrypt.chat invites username has issued. + QryptInviteCount(username string) (int, error) + // RecordQryptInvite records one issued invite (its jti, for audit and as + // the per-member quota counter) against username. It returns + // ErrQuotaExceeded if the member is already at or above quota. + RecordQryptInvite(username, jti string, quota int) error + Close() error } @@ -226,6 +235,9 @@ var ErrKeyMismatch = errors.New("username registered with a different key") // ErrDomainTaken means a domain is already mapped to a different member. var ErrDomainTaken = errors.New("domain already mapped to another account") +// ErrQuotaExceeded means a member has hit their qrypt.chat invite quota. +var ErrQuotaExceeded = errors.New("qrypt invite quota exceeded") + type sqliteStore struct{ db *sql.DB } // Open opens (and migrates) the SQLite store at path. @@ -381,6 +393,12 @@ CREATE TABLE IF NOT EXISTS api_tokens ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username); +CREATE TABLE IF NOT EXISTS qrypt_invites ( + jti TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { @@ -807,4 +825,36 @@ func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error { return err } +func (s *sqliteStore) QryptInviteCount(username string) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n) + if err != nil { + return 0, err + } + return n, nil +} + +// RecordQryptInvite atomically enforces the per-member quota and records the +// invite's jti. The count check and the insert run in one transaction so two +// concurrent issuances can't both slip past the cap. +func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n); err != nil { + return err + } + if quota > 0 && n >= quota { + return ErrQuotaExceeded + } + if _, err := tx.Exec(`INSERT INTO qrypt_invites (jti, username) VALUES (?,?)`, jti, username); err != nil { + return err + } + return tx.Commit() +} + func (s *sqliteStore) Close() error { return s.db.Close() } diff --git a/internal/store/store_qrypt_test.go b/internal/store/store_qrypt_test.go new file mode 100644 index 0000000..31acca3 --- /dev/null +++ b/internal/store/store_qrypt_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestQryptInviteQuota(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer st.Close() + + const quota = 3 + + if n, err := st.QryptInviteCount("alice"); err != nil || n != 0 { + t.Fatalf("initial count = %d, %v; want 0, nil", n, err) + } + + for i := 0; i < quota; i++ { + jti := "jti-alice-" + string(rune('a'+i)) + if err := st.RecordQryptInvite("alice", jti, quota); err != nil { + t.Fatalf("record %d: %v", i, err) + } + } + if n, err := st.QryptInviteCount("alice"); err != nil || n != quota { + t.Fatalf("count after fill = %d, %v; want %d", n, err, quota) + } + + // One over the cap must be rejected with ErrQuotaExceeded and not stored. + if err := st.RecordQryptInvite("alice", "jti-over", quota); !errors.Is(err, ErrQuotaExceeded) { + t.Fatalf("over-quota err = %v; want ErrQuotaExceeded", err) + } + if n, _ := st.QryptInviteCount("alice"); n != quota { + t.Fatalf("count after rejected insert = %d; want %d", n, quota) + } + + // Quotas are per-member: bob is unaffected. + if err := st.RecordQryptInvite("bob", "jti-bob", quota); err != nil { + t.Fatalf("bob record: %v", err) + } + if n, _ := st.QryptInviteCount("bob"); n != 1 { + t.Fatalf("bob count = %d; want 1", n) + } + + // quota <= 0 means unlimited. + if err := st.RecordQryptInvite("carol", "jti-carol-1", 0); err != nil { + t.Fatalf("unlimited record: %v", err) + } +} diff --git a/plugins/qryptinvite/qryptinvite.go b/plugins/qryptinvite/qryptinvite.go new file mode 100644 index 0000000..2b35680 --- /dev/null +++ b/plugins/qryptinvite/qryptinvite.go @@ -0,0 +1,111 @@ +// Package qryptinvite is the hub plugin that lets an authenticated member mint +// a single-use qrypt.chat anonymous invite. AgentBBS is the trusted issuer: the +// plugin signs a token with the operator's Ed25519 key, records it against the +// member's per-account quota, and prints the token + redeem URL. The separate +// qrypt.chat app verifies the signature and burns the jti on redeem. +// +// See internal/qryptinvite for the token format and docs/qrypt-invites.md. +package qryptinvite + +import ( + "errors" + "strconv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" + qi "github.com/profullstack/agentbbs/internal/qryptinvite" + "github.com/profullstack/agentbbs/internal/store" +) + +// Plugin is the hub registration. It admits members only (guests have no +// account to quota against). +type Plugin struct{} + +func (Plugin) ID() string { return "qrypt-invite" } +func (Plugin) Title() string { return "qrypt.chat invite" } +func (Plugin) Description() string { return "Mint an anonymous qrypt.chat signup invite" } +func (Plugin) RequiresAuth() bool { return true } + +func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { + cfg := qi.ConfigFromEnv() + m := model{user: user, store: ctx.Store, cfg: cfg} + m.issue() // do the work once up front; the view just reports the result + return m +} + +type model struct { + user auth.User + store store.Store + cfg qi.Config + body string // rendered result, ready to display +} + +// issue runs the full flow: check quota, mint, record, build the output. +func (m *model) issue() { + if m.user.Kind == auth.Guest || m.user.Name == "" { + m.body = errStyle.Render("Sign in with your SSH key to mint an invite.") + return + } + priv, err := m.cfg.PrivateKey() + if err != nil { + m.body = errStyle.Render("Invites are not configured on this host yet.\n") + + dStyle.Render(" ("+err.Error()+")") + return + } + used, err := m.store.QryptInviteCount(m.user.Name) + if err != nil { + m.body = errStyle.Render("Couldn't read your invite count: " + err.Error()) + return + } + if m.cfg.Quota > 0 && used >= m.cfg.Quota { + m.body = errStyle.Render("You've used all your invites ") + + dStyle.Render("("+strconv.Itoa(used)+"/"+strconv.Itoa(m.cfg.Quota)+"). Ask an operator for more.") + return + } + + token, jti, err := qi.Mint(m.cfg.IssuerID, priv, m.cfg.TTL) + if err != nil { + m.body = errStyle.Render("Mint failed: " + err.Error()) + return + } + if err := m.store.RecordQryptInvite(m.user.Name, jti, m.cfg.Quota); err != nil { + if errors.Is(err, store.ErrQuotaExceeded) { + m.body = errStyle.Render("You've used all your invites. Ask an operator for more.") + return + } + m.body = errStyle.Render("Couldn't record the invite: " + err.Error()) + return + } + + remaining := m.cfg.Quota - (used + 1) + m.body = hStyle.Render("Your qrypt.chat anonymous invite") + "\n\n" + + " Redeem at:\n" + + urlStyle.Render(" "+m.cfg.RedeemURLFor(token)) + "\n\n" + + dStyle.Render(" Token (same thing, if you'd rather paste it):") + "\n" + + " " + token + "\n\n" + + dStyle.Render(" Single-use · expires in "+m.cfg.TTL.String()+" · invites left: "+strconv.Itoa(max(remaining, 0))) +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + return m, plugin.Exit + } + return m, nil +} + +func (m model) View() string { + return lipgloss.NewStyle().Padding(1, 2).Render( + m.body + "\n\n" + dStyle.Render("press any key to return")) +} + +var ( + hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")) +) diff --git a/setup.sh b/setup.sh index db5a01a..ec43b0e 100755 --- a/setup.sh +++ b/setup.sh @@ -264,6 +264,16 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # Agent chat backend (agent@), stdin->stdout, e.g. "claude -p": # AGENTBBS_AGENT_CMD= + +# qrypt.chat anonymous-invite issuer (docs/qrypt-invites.md). Members mint a +# signed single-use token here that qrypt.chat redeems into an anon account. +# Run \`agentbbs qrypt-issuer-keygen\` once: paste the seed below, register the +# public key in qrypt.chat's invite_issuers row. Without the key, minting is off. +# AGENTBBS_QRYPT_ISSUER_KEY= +# AGENTBBS_QRYPT_ISSUER_ID=agentbbs +# AGENTBBS_QRYPT_INVITE_TTL=168h +# AGENTBBS_QRYPT_REDEEM_URL=https://qrypt.chat/anon?invite= +# AGENTBBS_QRYPT_INVITE_QUOTA=5 ENV chmod 0640 "$ENV_DIR/agentbbs.env" fi