agentbbs/internal/qryptinvite/qryptinvite.go
Anthony Ettinger 97da723c5c 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>
2026-06-14 11:35:05 +00:00

141 lines
4.9 KiB
Go

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