owpengram-server/internal/app/telegramlogin/keyfiles.go

186 lines
6.2 KiB
Go

package telegramlogin
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"telesrv/internal/domain"
)
const (
maxTelegramLoginManifestBytes = 1 << 20
maxTelegramLoginKeyBytes = 256 << 10
)
type signingKeyManifest struct {
Version int `json:"version"`
Keys []signingKeyManifestEntry `json:"keys"`
}
type signingKeyManifestEntry struct {
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
KeyID string `json:"kid,omitempty"`
PrivateKeyFile string `json:"private_key_file"`
Active bool `json:"active"`
PublishUntil string `json:"publish_until,omitempty"`
}
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
// Relative key paths are resolved against the manifest directory. The caller
// should atomically replace files and rebuild/swap the ring when rotating.
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
var manifest signingKeyManifest
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
}
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
return nil, errors.New("telegram login signing manifest has invalid version or key count")
}
baseDir := filepath.Dir(path)
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
for index, entry := range manifest.Keys {
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
if !entry.Algorithm.Valid() || keyPath == "" {
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
}
if !filepath.IsAbs(keyPath) {
keyPath = filepath.Join(baseDir, keyPath)
}
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
if err != nil {
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
}
var parsed jwk.Key
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
parsed, err = jwk.ParseKey(data)
} else {
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
}
if err != nil {
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
}
var raw any
if err := jwk.Export(parsed, &raw); err != nil {
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
}
var publishUntil time.Time
if entry.PublishUntil != "" {
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
if err != nil {
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
}
}
if entry.Active && !publishUntil.IsZero() {
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
}
if !entry.Active && publishUntil.IsZero() {
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
}
materials = append(materials, SigningKeyMaterial{
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
Active: entry.Active, PublishUntil: publishUntil,
})
}
return NewSigningKeyRing(materials, now)
}
type codeKeyManifest struct {
Version int `json:"version"`
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
func LoadCodeSealer(path string) (*CodeSealer, error) {
var manifest codeKeyManifest
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
}
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
}
keys := make(map[string][]byte, len(manifest.Keys))
for keyID, encoded := range manifest.Keys {
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
return nil, errors.New("telegram login code-key manifest has invalid key id")
}
raw, err := decodeBase64Key(encoded)
if err != nil || len(raw) != 32 {
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
}
keys[keyID] = raw
}
return NewCodeSealer(manifest.Active, keys)
}
func LoadClientSecretPepper(path string) ([]byte, error) {
data, err := readBoundedFile(path, 4096)
if err != nil {
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
}
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
if err != nil || len(raw) != 32 {
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
}
return raw, nil
}
func decodeBase64Key(value string) ([]byte, error) {
value = strings.TrimSpace(value)
for _, encoding := range []*base64.Encoding{
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
} {
if raw, err := encoding.DecodeString(value); err == nil {
return raw, nil
}
}
return nil, errors.New("invalid base64")
}
func readStrictJSONFile(path string, maxBytes int64, target any) error {
data, err := readBoundedFile(path, maxBytes)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
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("multiple JSON values")
}
return err
}
return nil
}
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
return nil, errors.New("invalid file path or size bound")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > maxBytes {
return nil, errors.New("file is not regular or exceeds size bound")
}
return io.ReadAll(io.LimitReader(file, maxBytes+1))
}