391 lines
12 KiB
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
|
|
}
|