mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
This commit is contained in:
parent
8adafaf515
commit
97da723c5c
10 changed files with 855 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
86
cmd/agentbbs/qrypt.go
Normal file
86
cmd/agentbbs/qrypt.go
Normal file
|
|
@ -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 <user>` 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 <username>")
|
||||
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)
|
||||
}
|
||||
105
docs/qrypt-invites.md
Normal file
105
docs/qrypt-invites.md
Normal file
|
|
@ -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.<payload>.<sig>` 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=<base64 seed>
|
||||
```
|
||||
|
||||
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', '<base64 public key>');
|
||||
```
|
||||
|
||||
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 <name>@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 <username> # 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.
|
||||
74
internal/qryptinvite/config.go
Normal file
74
internal/qryptinvite/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
141
internal/qryptinvite/qryptinvite.go
Normal file
141
internal/qryptinvite/qryptinvite.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
214
internal/qryptinvite/qryptinvite_test.go
Normal file
214
internal/qryptinvite/qryptinvite_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
52
internal/store/store_qrypt_test.go
Normal file
52
internal/store/store_qrypt_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
111
plugins/qryptinvite/qryptinvite.go
Normal file
111
plugins/qryptinvite/qryptinvite.go
Normal file
|
|
@ -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"))
|
||||
)
|
||||
10
setup.sh
10
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=<base64 ed25519 seed from qrypt-issuer-keygen>
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue