feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
391
cmd/telegramloginkeygen/main.go
Normal file
391
cmd/telegramloginkeygen/main.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue