mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
141 lines
4.9 KiB
Go
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")
|
|
}
|