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:
Anthony Ettinger 2026-06-14 11:35:05 +00:00
parent 8adafaf515
commit 97da723c5c
10 changed files with 855 additions and 1 deletions

View 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
}

View 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")
}

View 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")
}
}

View file

@ -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() }

View 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)
}
}