owpengram-server/cmd/telegramloginkeygen/main.go

391 lines
12 KiB
Go

// Command telegramloginkeygen initializes and rotates telesrv Telegram Login
// key files without ever writing secret material to stdout.
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
telegramlogin "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
)
const maxManifestBytes = 1 << 20
const signingRetirementMargin = 10 * time.Minute
type signingManifest struct {
Version int `json:"version"`
Keys []signingManifestKey `json:"keys"`
}
type signingManifestKey struct {
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
KeyID string `json:"kid"`
PrivateKeyFile string `json:"private_key_file"`
Active bool `json:"active"`
PublishUntil string `json:"publish_until,omitempty"`
}
type codeManifest struct {
Version int `json:"version"`
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
type options struct {
mode string
dir string
algorithm domain.TelegramLoginSigningAlgorithm
publishFor time.Duration
idTokenTTL time.Duration
now func() time.Time
}
func main() {
mode := flag.String("mode", "init", "init, rotate-signing, or rotate-code")
dir := flag.String("dir", "data/telegram-login", "key directory")
algorithm := flag.String("algorithm", "RS256", "signing algorithm to rotate: RS256, ES256, or EdDSA")
publishFor := flag.Duration("publish-for", 2*time.Hour, "how long the retiring public key remains in JWKS")
idTokenTTL := flag.Duration("id-token-ttl", time.Hour, "configured TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL")
flag.Parse()
if flag.NArg() != 0 {
fatal(errors.New("positional arguments are not accepted"))
}
opts := options{
mode: strings.ToLower(strings.TrimSpace(*mode)), dir: strings.TrimSpace(*dir),
algorithm: domain.TelegramLoginSigningAlgorithm(strings.ToUpper(strings.TrimSpace(*algorithm))),
publishFor: *publishFor, idTokenTTL: *idTokenTTL, now: time.Now,
}
if err := run(opts); err != nil {
fatal(err)
}
fmt.Printf("Telegram Login key operation %s completed in %s; restart all instances to load one consistent key ring.\n", opts.mode, opts.dir)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "telegramloginkeygen:", err)
os.Exit(1)
}
func run(opts options) error {
if opts.now == nil {
opts.now = time.Now
}
if opts.dir == "" {
return errors.New("key directory is required")
}
absDir, err := filepath.Abs(opts.dir)
if err != nil {
return fmt.Errorf("resolve key directory: %w", err)
}
if err := os.MkdirAll(absDir, 0o700); err != nil {
return fmt.Errorf("create key directory: %w", err)
}
if err := os.Chmod(absDir, 0o700); err != nil {
return fmt.Errorf("restrict key directory: %w", err)
}
lockPath := filepath.Join(absDir, ".keygen.lock")
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("acquire key operation lock: %w", err)
}
_ = lock.Close()
defer func() { _ = os.Remove(lockPath) }()
switch opts.mode {
case "init":
return initialize(absDir, opts.now().UTC())
case "rotate-signing":
if opts.idTokenTTL < time.Minute || opts.idTokenTTL > 24*time.Hour {
return errors.New("id-token-ttl must match the configured 1m..24h ID-token TTL")
}
if opts.publishFor < opts.idTokenTTL+signingRetirementMargin || opts.publishFor > 90*24*time.Hour {
return fmt.Errorf("publish-for must be at least id-token-ttl plus %s and at most 2160h", signingRetirementMargin)
}
return rotateSigning(absDir, opts.algorithm, opts.publishFor, opts.now().UTC())
case "rotate-code":
return rotateCode(absDir, opts.now().UTC())
default:
return errors.New("mode must be init, rotate-signing, or rotate-code")
}
}
func initialize(dir string, now time.Time) error {
for _, name := range []string{"signing-keys.json", "code-keys.json", "client-secret-pepper"} {
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
return fmt.Errorf("refusing to overwrite existing %s", name)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect %s: %w", name, err)
}
}
manifest := signingManifest{Version: 1}
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
domain.TelegramLoginSigningRS256,
domain.TelegramLoginSigningES256,
domain.TelegramLoginSigningEdDSA,
} {
entry, err := generateSigningKey(dir, algorithm, now)
if err != nil {
return err
}
manifest.Keys = append(manifest.Keys, entry)
}
if err := writeSigningManifest(dir, manifest); err != nil {
return err
}
codeID, err := newKeyID("code", now)
if err != nil {
return err
}
codeKey, err := randomBytes(32)
if err != nil {
return err
}
if err := writeCodeManifest(dir, codeManifest{
Version: 1, Active: codeID,
Keys: map[string]string{codeID: base64.RawURLEncoding.EncodeToString(codeKey)},
}); err != nil {
return err
}
pepper, err := randomBytes(32)
if err != nil {
return err
}
if err := writeExclusive(filepath.Join(dir, "client-secret-pepper"), []byte(base64.RawURLEncoding.EncodeToString(pepper)+"\n")); err != nil {
return fmt.Errorf("write client-secret pepper: %w", err)
}
_, err = telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper"))
return err
}
func rotateSigning(dir string, algorithm domain.TelegramLoginSigningAlgorithm, publishFor time.Duration, now time.Time) error {
if algorithm != domain.TelegramLoginSigningRS256 && algorithm != domain.TelegramLoginSigningES256 && algorithm != domain.TelegramLoginSigningEdDSA {
return errors.New("default keygen supports RS256, ES256, and EdDSA; ES256K requires an explicit jwx_es256k build and external JWK lifecycle")
}
path := filepath.Join(dir, "signing-keys.json")
var manifest signingManifest
if err := readStrictJSON(path, &manifest); err != nil {
return fmt.Errorf("read signing manifest: %w", err)
}
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) >= 32 {
return errors.New("signing manifest version or key count is invalid")
}
foundActive := false
kept := make([]signingManifestKey, 0, len(manifest.Keys)+1)
for _, key := range manifest.Keys {
if !key.Active && key.PublishUntil != "" {
until, err := time.Parse(time.RFC3339, key.PublishUntil)
if err != nil {
return fmt.Errorf("parse retiring key %s: %w", key.KeyID, err)
}
if !now.Before(until) {
continue
}
}
if key.Algorithm == algorithm && key.Active {
if foundActive {
return fmt.Errorf("multiple active %s keys", algorithm)
}
foundActive = true
key.Active = false
key.PublishUntil = now.Add(publishFor).UTC().Format(time.RFC3339)
}
kept = append(kept, key)
}
if !foundActive {
return fmt.Errorf("no active %s key to rotate", algorithm)
}
entry, err := generateSigningKey(dir, algorithm, now)
if err != nil {
return err
}
manifest.Keys = append(kept, entry)
return writeSigningManifest(dir, manifest)
}
func rotateCode(dir string, now time.Time) error {
path := filepath.Join(dir, "code-keys.json")
var manifest codeManifest
if err := readStrictJSON(path, &manifest); err != nil {
return fmt.Errorf("read code-key manifest: %w", err)
}
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) >= 16 {
return errors.New("code-key manifest is invalid or at its 16-key safety limit")
}
keyID, err := newKeyID("code", now)
if err != nil {
return err
}
key, err := randomBytes(32)
if err != nil {
return err
}
manifest.Active = keyID
manifest.Keys[keyID] = base64.RawURLEncoding.EncodeToString(key)
return writeCodeManifest(dir, manifest)
}
func generateSigningKey(dir string, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (signingManifestKey, error) {
keyID, err := newKeyID(strings.ToLower(string(algorithm)), now)
if err != nil {
return signingManifestKey{}, err
}
var privateKey any
switch algorithm {
case domain.TelegramLoginSigningRS256:
privateKey, err = rsa.GenerateKey(rand.Reader, 3072)
case domain.TelegramLoginSigningES256:
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case domain.TelegramLoginSigningEdDSA:
_, privateKey, err = ed25519.GenerateKey(rand.Reader)
default:
return signingManifestKey{}, fmt.Errorf("unsupported keygen algorithm %s", algorithm)
}
if err != nil {
return signingManifestKey{}, fmt.Errorf("generate %s key: %w", algorithm, err)
}
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return signingManifestKey{}, fmt.Errorf("marshal %s key: %w", algorithm, err)
}
filename := "signing-" + keyID + ".pem"
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := writeExclusive(filepath.Join(dir, filename), pemBytes); err != nil {
return signingManifestKey{}, fmt.Errorf("write %s key: %w", algorithm, err)
}
return signingManifestKey{
Algorithm: algorithm, KeyID: keyID, PrivateKeyFile: filename, Active: true,
}, nil
}
func newKeyID(prefix string, now time.Time) (string, error) {
raw, err := randomBytes(8)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", prefix, now.UTC().Format("20060102T150405Z"), base64.RawURLEncoding.EncodeToString(raw)), nil
}
func randomBytes(size int) ([]byte, error) {
raw := make([]byte, size)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("read cryptographic randomness: %w", err)
}
return raw, nil
}
func writeSigningManifest(dir string, manifest signingManifest) error {
return writeValidatedManifest(filepath.Join(dir, "signing-keys.json"), manifest, func(path string) error {
_, err := telegramlogin.LoadSigningKeyRing(path, time.Now)
return err
})
}
func writeCodeManifest(dir string, manifest codeManifest) error {
return writeValidatedManifest(filepath.Join(dir, "code-keys.json"), manifest, func(path string) error {
_, err := telegramlogin.LoadCodeSealer(path)
return err
})
}
func writeValidatedManifest(path string, value any, validate func(string) error) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal manifest: %w", err)
}
data = append(data, '\n')
temp, err := os.CreateTemp(filepath.Dir(path), ".telegram-login-manifest-*")
if err != nil {
return fmt.Errorf("create temporary manifest: %w", err)
}
tempPath := temp.Name()
defer func() { _ = os.Remove(tempPath) }()
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := validate(tempPath); err != nil {
return fmt.Errorf("validate generated manifest: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("atomically replace manifest: %w", err)
}
return nil
}
func writeExclusive(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
if _, err := file.Write(data); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
ok = true
return nil
}
func readStrictJSON(path string, target any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Size() > maxManifestBytes {
return errors.New("manifest must be a bounded regular file")
}
decoder := json.NewDecoder(io.LimitReader(file, maxManifestBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("manifest contains multiple JSON values")
}
return err
}
return nil
}