Skip to content

Commit 97da723

Browse files
ralyodioclaude
andcommitted
feat(qrypt): qrypt.chat anonymous-invite issuer
AgentBBS becomes a trusted Ed25519 issuer for qrypt.chat anonymous accounts. Members mint a signed, single-use qci1 token (per the shared invite contract) that qrypt.chat verifies and redeems. - internal/qryptinvite: Mint / GenerateIssuerKey / ParsePrivateKey + Config (AGENTBBS_QRYPT_* env) with unit tests (independent verify, payload assertions, jti uniqueness, tamper rejection, seed/full key). - store: qrypt_invites table + QryptInviteCount / RecordQryptInvite (per-member quota, enforced in a tx; ErrQuotaExceeded) + test. - plugins/qryptinvite: hub plugin (members only) — checks quota, mints, records, prints token + redeem URL. - cmd/agentbbs: `qrypt-invite <user>` and `qrypt-issuer-keygen` subcommands wired into dispatch. - setup.sh env template + docs/qrypt-invites.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8adafaf commit 97da723

10 files changed

Lines changed: 855 additions & 1 deletion

File tree

cmd/agentbbs/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
2020
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
2121
// agentbbs mint-token NAME issue a WebSocket API token for NAME
22+
// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
23+
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
2224
package main
2325

2426
import (
@@ -64,6 +66,7 @@ import (
6466
"github.com/profullstack/agentbbs/plugins/about"
6567
"github.com/profullstack/agentbbs/plugins/agentgames"
6668
"github.com/profullstack/agentbbs/plugins/arcade"
69+
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
6770
)
6871

6972
func env(k, def string) string {
@@ -121,6 +124,14 @@ func main() {
121124
mintToken(st, os.Args[2:])
122125
return
123126
}
127+
if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" {
128+
qryptInviteCmd(st, os.Args[2:])
129+
return
130+
}
131+
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
132+
qryptIssuerKeygen()
133+
return
134+
}
124135

125136
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
126137
fe := forwardemail.ConfigFromEnv()
@@ -141,7 +152,7 @@ func main() {
141152
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
142153
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
143154
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
144-
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}
155+
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
145156

146157
// Custom domains: maintain the symlink farm Caddy serves and answer its
147158
// on-demand-TLS "ask" query so certs are only issued for mapped domains.

cmd/agentbbs/qrypt.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
10+
"github.com/profullstack/agentbbs/internal/store"
11+
)
12+
13+
// qryptInviteCmd is the ops side of qrypt.chat invites:
14+
// `agentbbs qrypt-invite <user>` mints a single-use anonymous invite on behalf
15+
// of an existing member, respecting their per-account quota, and prints the
16+
// token + redeem URL (docs/qrypt-invites.md).
17+
func qryptInviteCmd(st store.Store, args []string) {
18+
if len(args) < 1 {
19+
fmt.Fprintln(os.Stderr, "usage: agentbbs qrypt-invite <username>")
20+
os.Exit(2)
21+
}
22+
name := strings.ToLower(args[0])
23+
if _, found, err := st.UserByName(name); err != nil {
24+
fmt.Fprintln(os.Stderr, "lookup:", err)
25+
os.Exit(1)
26+
} else if !found {
27+
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
28+
os.Exit(1)
29+
}
30+
31+
cfg := qi.ConfigFromEnv()
32+
priv, err := cfg.PrivateKey()
33+
if err != nil {
34+
fmt.Fprintln(os.Stderr, err)
35+
os.Exit(1)
36+
}
37+
38+
token, jti, err := qi.Mint(cfg.IssuerID, priv, cfg.TTL)
39+
if err != nil {
40+
fmt.Fprintln(os.Stderr, "mint:", err)
41+
os.Exit(1)
42+
}
43+
if err := st.RecordQryptInvite(name, jti, cfg.Quota); err != nil {
44+
if errors.Is(err, store.ErrQuotaExceeded) {
45+
used, _ := st.QryptInviteCount(name)
46+
fmt.Fprintf(os.Stderr, "%s is at their invite quota (%d/%d)\n", name, used, cfg.Quota)
47+
os.Exit(1)
48+
}
49+
fmt.Fprintln(os.Stderr, "record:", err)
50+
os.Exit(1)
51+
}
52+
53+
used, _ := st.QryptInviteCount(name)
54+
remaining := cfg.Quota - used
55+
if remaining < 0 {
56+
remaining = 0
57+
}
58+
fmt.Printf("qrypt.chat invite for %s (issuer %s, expires in %s, single-use):\n\n", name, cfg.IssuerID, cfg.TTL)
59+
fmt.Printf(" redeem %s\n", cfg.RedeemURLFor(token))
60+
fmt.Printf(" token %s\n", token)
61+
fmt.Printf(" jti %s\n\n", jti)
62+
if cfg.Quota > 0 {
63+
fmt.Printf("invites left for %s: %d/%d\n", name, remaining, cfg.Quota)
64+
}
65+
}
66+
67+
// qryptIssuerKeygen prints a fresh Ed25519 issuer keypair for first-time setup:
68+
// the base64 seed (private — goes in AGENTBBS_QRYPT_ISSUER_KEY) and the base64
69+
// raw public key (goes in qrypt.chat's invite_issuers row).
70+
func qryptIssuerKeygen() {
71+
seed, pub, err := qi.GenerateIssuerKey()
72+
if err != nil {
73+
fmt.Fprintln(os.Stderr, "keygen:", err)
74+
os.Exit(1)
75+
}
76+
cfg := qi.ConfigFromEnv()
77+
fmt.Println("Fresh qrypt.chat invite-issuer keypair:")
78+
fmt.Println()
79+
fmt.Println(" 1) On agentbbs, set the PRIVATE seed (keep it secret):")
80+
fmt.Printf(" AGENTBBS_QRYPT_ISSUER_KEY=%s\n\n", seed)
81+
fmt.Println(" 2) In qrypt.chat, insert an invite_issuers row with the PUBLIC key:")
82+
fmt.Printf(" id %s\n", cfg.IssuerID)
83+
fmt.Printf(" ed25519_public_key %s\n\n", pub)
84+
fmt.Printf(" INSERT INTO invite_issuers (id, name, ed25519_public_key)\n")
85+
fmt.Printf(" VALUES ('%s', 'AgentBBS', '%s');\n", cfg.IssuerID, pub)
86+
}

docs/qrypt-invites.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# qrypt.chat anonymous invites
2+
3+
AgentBBS is a **trusted issuer** for [qrypt.chat](https://qrypt.chat) anonymous
4+
accounts. A verified AgentBBS member can mint a signed, single-use invite token;
5+
the separate qrypt.chat app verifies the signature and redeems the token into an
6+
**anonymous** account (no phone number). AgentBBS holds the private key and
7+
signs; qrypt.chat only ever sees the public key.
8+
9+
This is additive and isolated: it touches nothing in the join/SMS/pod paths.
10+
11+
## The bridge
12+
13+
```
14+
member --ssh--> AgentBBS (issuer, has Ed25519 priv key) --signed token--> qrypt.chat (verifier, has pub key)
15+
```
16+
17+
- AgentBBS mints `qci1.<payload>.<sig>` tokens with `crypto/ed25519`.
18+
- qrypt.chat looks up the issuer's public key by `payload.iss` in its
19+
`invite_issuers` table, verifies the signature, checks expiry, and burns the
20+
`jti` (single use) on redeem.
21+
22+
## Token format (v1)
23+
24+
A single fixed algorithm (Ed25519) — not a JWT, no `alg` field.
25+
26+
```
27+
token = "qci1." + b64url(payloadJSON) + "." + b64url(sig)
28+
signing input = "qci1." + b64url(payloadJSON) # the first two segments
29+
sig = Ed25519.Sign(issuerPriv, []byte(signingInput))
30+
b64url = base64 URL-encoding, NO padding
31+
```
32+
33+
`payloadJSON`:
34+
35+
```json
36+
{ "jti": "16-random-bytes-hex", "iss": "agentbbs", "tier": "anonymous",
37+
"iat": 1700000000, "exp": 1700604800, "uses": 1 }
38+
```
39+
40+
Implemented in [`internal/qryptinvite`](../internal/qryptinvite). Verifier rules
41+
(qrypt.chat side): exactly 3 segments; `segment[0] == "qci1"`; known/enabled
42+
issuer; valid signature; `now <= exp`; `jti` not already redeemed.
43+
44+
## Setup (operator, once)
45+
46+
1. Generate a keypair on the AgentBBS host:
47+
48+
```bash
49+
agentbbs qrypt-issuer-keygen
50+
```
51+
52+
It prints a **private seed** (base64) and a **public key** (base64).
53+
54+
2. Set the private seed in `agentbbs.env` (see `setup.sh`) and restart:
55+
56+
```
57+
AGENTBBS_QRYPT_ISSUER_KEY=<base64 seed>
58+
```
59+
60+
Until this is set, minting is disabled (the plugin says "not configured").
61+
62+
3. Register the **public** key in qrypt.chat (service-role / SQL):
63+
64+
```sql
65+
INSERT INTO invite_issuers (id, name, ed25519_public_key)
66+
VALUES ('agentbbs', 'AgentBBS', '<base64 public key>');
67+
```
68+
69+
The `id` must match `AGENTBBS_QRYPT_ISSUER_ID` (default `agentbbs`).
70+
71+
## Config (env)
72+
73+
| Var | Default | Meaning |
74+
|---|---|---|
75+
| `AGENTBBS_QRYPT_ISSUER_ID` | `agentbbs` | issuer id; must match qrypt's `invite_issuers.id` |
76+
| `AGENTBBS_QRYPT_ISSUER_KEY` | unset | base64 Ed25519 seed (32B) or full key (64B); **required to mint** |
77+
| `AGENTBBS_QRYPT_INVITE_TTL` | `168h` | token lifetime (Go duration) |
78+
| `AGENTBBS_QRYPT_REDEEM_URL` | `https://qrypt.chat/anon?invite=` | redeem URL; the token is appended |
79+
| `AGENTBBS_QRYPT_INVITE_QUOTA` | `5` | per-member cap (0 = unlimited) |
80+
81+
## Member usage (over SSH)
82+
83+
From the hub, pick **"qrypt.chat invite"**:
84+
85+
```bash
86+
ssh <name>@bbs.profullstack.com # hub → "qrypt.chat invite"
87+
```
88+
89+
It checks the member's quota, mints a single-use token, records it (incrementing
90+
the quota), and prints the redeem URL plus the raw token. The member opens the
91+
URL on qrypt.chat to create their anonymous account.
92+
93+
## Ops usage (CLI)
94+
95+
```bash
96+
agentbbs qrypt-invite <username> # mint on behalf of a member (respects quota)
97+
agentbbs qrypt-issuer-keygen # print a fresh seed + public key (first-time setup)
98+
```
99+
100+
## Quota storage
101+
102+
Per-member issuance is counted in the AgentBBS SQLite `qrypt_invites` table
103+
(`jti` PRIMARY KEY, `username`, `created_at`). `RecordQryptInvite` enforces the
104+
cap inside a transaction, so concurrent mints can't both exceed it. The stored
105+
`jti` values are also an audit trail of what AgentBBS handed out.

internal/qryptinvite/config.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package qryptinvite
2+
3+
import (
4+
"crypto/ed25519"
5+
"errors"
6+
"os"
7+
"strconv"
8+
"time"
9+
)
10+
11+
// Config is the resolved qrypt.chat invite-issuer configuration, read from the
12+
// environment (see docs/qrypt-invites.md). It is shared by the SSH plugin and
13+
// the ops CLI so they mint identical tokens.
14+
type Config struct {
15+
IssuerID string // AGENTBBS_QRYPT_ISSUER_ID (default "agentbbs")
16+
Key string // AGENTBBS_QRYPT_ISSUER_KEY (base64 seed/priv)
17+
TTL time.Duration // AGENTBBS_QRYPT_INVITE_TTL (default 168h)
18+
RedeemURL string // AGENTBBS_QRYPT_REDEEM_URL (default https://qrypt.chat/anon?invite=)
19+
Quota int // AGENTBBS_QRYPT_INVITE_QUOTA (default 5)
20+
}
21+
22+
// DefaultIssuerID, DefaultRedeemURL, DefaultTTL and DefaultQuota are the
23+
// fallbacks when the corresponding env var is unset.
24+
const (
25+
DefaultIssuerID = "agentbbs"
26+
DefaultRedeemURL = "https://qrypt.chat/anon?invite="
27+
DefaultTTL = 168 * time.Hour
28+
DefaultQuota = 5
29+
)
30+
31+
// ConfigFromEnv reads the AGENTBBS_QRYPT_* environment variables, applying
32+
// defaults. The key is not validated here; call PrivateKey to parse it.
33+
func ConfigFromEnv() Config {
34+
c := Config{
35+
IssuerID: DefaultIssuerID,
36+
Key: os.Getenv("AGENTBBS_QRYPT_ISSUER_KEY"),
37+
TTL: DefaultTTL,
38+
RedeemURL: DefaultRedeemURL,
39+
Quota: DefaultQuota,
40+
}
41+
if v := os.Getenv("AGENTBBS_QRYPT_ISSUER_ID"); v != "" {
42+
c.IssuerID = v
43+
}
44+
if v := os.Getenv("AGENTBBS_QRYPT_REDEEM_URL"); v != "" {
45+
c.RedeemURL = v
46+
}
47+
if v := os.Getenv("AGENTBBS_QRYPT_INVITE_TTL"); v != "" {
48+
if d, err := time.ParseDuration(v); err == nil {
49+
c.TTL = d
50+
}
51+
}
52+
if v := os.Getenv("AGENTBBS_QRYPT_INVITE_QUOTA"); v != "" {
53+
if n, err := strconv.Atoi(v); err == nil {
54+
c.Quota = n
55+
}
56+
}
57+
return c
58+
}
59+
60+
// ErrNoKey means AGENTBBS_QRYPT_ISSUER_KEY is unset, so no tokens can be minted.
61+
var ErrNoKey = errors.New("qryptinvite: AGENTBBS_QRYPT_ISSUER_KEY is not set (run: agentbbs qrypt-issuer-keygen)")
62+
63+
// PrivateKey parses the configured issuer key, or returns ErrNoKey if unset.
64+
func (c Config) PrivateKey() (ed25519.PrivateKey, error) {
65+
if c.Key == "" {
66+
return nil, ErrNoKey
67+
}
68+
return ParsePrivateKey(c.Key)
69+
}
70+
71+
// RedeemLink returns the full URL a member opens to redeem token.
72+
func (c Config) RedeemURLFor(token string) string {
73+
return c.RedeemURL + token
74+
}

0 commit comments

Comments
 (0)