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
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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue