406 lines
12 KiB
Go
406 lines
12 KiB
Go
package telegramlogin
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const defaultIDTokenTTL = time.Hour
|
|
|
|
type SigningKeyMaterial struct {
|
|
Algorithm domain.TelegramLoginSigningAlgorithm
|
|
KeyID string
|
|
PrivateKey any
|
|
Active bool
|
|
PublishUntil time.Time
|
|
}
|
|
|
|
type signingKey struct {
|
|
algorithm domain.TelegramLoginSigningAlgorithm
|
|
jwaAlgorithm jwa.SignatureAlgorithm
|
|
keyID string
|
|
private jwk.Key
|
|
public jwk.Key
|
|
active bool
|
|
publishUntil time.Time
|
|
}
|
|
|
|
// SigningKeyRing owns no mutable crypto state. Rotation is performed by
|
|
// constructing a new ring containing the new active key and old public keys
|
|
// with a PublishUntil at least as long as the maximum ID-token lifetime.
|
|
type SigningKeyRing struct {
|
|
keys []signingKey
|
|
active map[domain.TelegramLoginSigningAlgorithm]signingKey
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) {
|
|
if len(materials) == 0 {
|
|
return nil, errors.New("telegram login signing key ring is empty")
|
|
}
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
ring := &SigningKeyRing{
|
|
keys: make([]signingKey, 0, len(materials)),
|
|
active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey),
|
|
now: now,
|
|
}
|
|
seenKeyIDs := make(map[string]struct{}, len(materials))
|
|
for _, material := range materials {
|
|
key, err := importSigningKey(material)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, duplicate := seenKeyIDs[key.keyID]; duplicate {
|
|
return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID)
|
|
}
|
|
seenKeyIDs[key.keyID] = struct{}{}
|
|
if key.active {
|
|
if _, duplicate := ring.active[key.algorithm]; duplicate {
|
|
return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm)
|
|
}
|
|
ring.active[key.algorithm] = key
|
|
}
|
|
ring.keys = append(ring.keys, key)
|
|
}
|
|
if len(ring.active) == 0 {
|
|
return nil, errors.New("telegram login signing key ring has no active key")
|
|
}
|
|
return ring, nil
|
|
}
|
|
|
|
func importSigningKey(material SigningKeyMaterial) (signingKey, error) {
|
|
if !material.Algorithm.Valid() || material.PrivateKey == nil {
|
|
return signingKey{}, fmt.Errorf("invalid telegram login signing key material")
|
|
}
|
|
if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled {
|
|
return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k")
|
|
}
|
|
if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil {
|
|
return signingKey{}, err
|
|
}
|
|
privateKey, err := jwk.Import(material.PrivateKey)
|
|
if err != nil {
|
|
return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err)
|
|
}
|
|
if err := privateKey.Validate(); err != nil {
|
|
return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err)
|
|
}
|
|
publicKey, err := privateKey.PublicKey()
|
|
if err != nil {
|
|
return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err)
|
|
}
|
|
thumbprint, err := publicKey.Thumbprint(crypto.SHA256)
|
|
if err != nil {
|
|
return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err)
|
|
}
|
|
keyID := strings.TrimSpace(material.KeyID)
|
|
if keyID == "" {
|
|
keyID = base64.RawURLEncoding.EncodeToString(thumbprint)
|
|
}
|
|
if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
|
|
return signingKey{}, fmt.Errorf("invalid telegram login signing kid")
|
|
}
|
|
jwaAlgorithm, err := telegramLoginJWA(material.Algorithm)
|
|
if err != nil {
|
|
return signingKey{}, err
|
|
}
|
|
for _, key := range []jwk.Key{privateKey, publicKey} {
|
|
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
|
return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err)
|
|
}
|
|
if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil {
|
|
return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err)
|
|
}
|
|
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
|
|
return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err)
|
|
}
|
|
}
|
|
return signingKey{
|
|
algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID,
|
|
private: privateKey, public: publicKey, active: material.Active,
|
|
publishUntil: material.PublishUntil.UTC(),
|
|
}, nil
|
|
}
|
|
|
|
func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error {
|
|
switch algorithm {
|
|
case domain.TelegramLoginSigningRS256:
|
|
key, ok := rsaPrivateKey(raw)
|
|
if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
|
|
return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits")
|
|
}
|
|
if err := key.Validate(); err != nil {
|
|
return fmt.Errorf("validate telegram login RSA private key: %w", err)
|
|
}
|
|
case domain.TelegramLoginSigningES256:
|
|
key, ok := ecdsaPrivateKey(raw)
|
|
if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil {
|
|
return errors.New("telegram login ES256 requires a P-256 ECDSA private key")
|
|
}
|
|
case domain.TelegramLoginSigningEdDSA:
|
|
key, ok := raw.(ed25519.PrivateKey)
|
|
if !ok || len(key) != ed25519.PrivateKeySize {
|
|
return errors.New("telegram login EdDSA requires an Ed25519 private key")
|
|
}
|
|
case domain.TelegramLoginSigningES256K:
|
|
key, ok := ecdsaPrivateKey(raw)
|
|
if !ok || key.Curve == nil || key.Curve.Params() == nil ||
|
|
!strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil {
|
|
return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key")
|
|
}
|
|
default:
|
|
return domain.ErrTelegramLoginClientInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) {
|
|
switch key := raw.(type) {
|
|
case *rsa.PrivateKey:
|
|
return key, key != nil
|
|
case rsa.PrivateKey:
|
|
return &key, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) {
|
|
switch key := raw.(type) {
|
|
case *ecdsa.PrivateKey:
|
|
return key, key != nil
|
|
case ecdsa.PrivateKey:
|
|
return &key, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) {
|
|
switch algorithm {
|
|
case domain.TelegramLoginSigningRS256:
|
|
return jwa.RS256(), nil
|
|
case domain.TelegramLoginSigningES256:
|
|
return jwa.ES256(), nil
|
|
case domain.TelegramLoginSigningEdDSA:
|
|
return jwa.EdDSA(), nil
|
|
case domain.TelegramLoginSigningES256K:
|
|
if telegramLoginES256KEnabled {
|
|
return jwa.ES256K(), nil
|
|
}
|
|
return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build")
|
|
default:
|
|
return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid
|
|
}
|
|
}
|
|
|
|
func (r *SigningKeyRing) SupportedAlgorithms() []string {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
ordered := make([]string, 0, len(r.active))
|
|
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
|
domain.TelegramLoginSigningRS256,
|
|
domain.TelegramLoginSigningES256,
|
|
domain.TelegramLoginSigningEdDSA,
|
|
domain.TelegramLoginSigningES256K,
|
|
} {
|
|
if _, ok := r.active[algorithm]; ok {
|
|
ordered = append(ordered, string(algorithm))
|
|
}
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
// ActiveAlgorithms returns the algorithms that can sign new tokens on this
|
|
// instance. Callers use it to prevent durable client configuration from
|
|
// selecting an algorithm without an active private key.
|
|
func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active))
|
|
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
|
domain.TelegramLoginSigningRS256,
|
|
domain.TelegramLoginSigningES256,
|
|
domain.TelegramLoginSigningEdDSA,
|
|
domain.TelegramLoginSigningES256K,
|
|
} {
|
|
if _, ok := r.active[algorithm]; ok {
|
|
ordered = append(ordered, algorithm)
|
|
}
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
func (r *SigningKeyRing) JWKS() ([]byte, string, error) {
|
|
if r == nil {
|
|
return nil, "", errors.New("telegram login signing key ring is nil")
|
|
}
|
|
now := r.now().UTC()
|
|
set := jwk.NewSet()
|
|
for _, key := range r.keys {
|
|
if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) {
|
|
continue
|
|
}
|
|
clone, err := key.public.Clone()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err)
|
|
}
|
|
if err := set.AddKey(clone); err != nil {
|
|
return nil, "", fmt.Errorf("add telegram login public JWK: %w", err)
|
|
}
|
|
}
|
|
body, err := json.Marshal(set)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err)
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil
|
|
}
|
|
|
|
func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) {
|
|
if r == nil || token == nil {
|
|
return "", errors.New("telegram login ID token signer is unavailable")
|
|
}
|
|
key, ok := r.active[algorithm]
|
|
if !ok {
|
|
return "", fmt.Errorf("no active telegram login signing key for %s", algorithm)
|
|
}
|
|
signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private))
|
|
if err != nil {
|
|
return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err)
|
|
}
|
|
return string(signed), nil
|
|
}
|
|
|
|
type IDTokenIssuerConfig struct {
|
|
Issuer string
|
|
TTL time.Duration
|
|
Now func() time.Time
|
|
}
|
|
|
|
type IDTokenIssuer struct {
|
|
issuer string
|
|
ttl time.Duration
|
|
now func() time.Time
|
|
keys *SigningKeyRing
|
|
}
|
|
|
|
func (i *IDTokenIssuer) Issuer() string {
|
|
if i == nil {
|
|
return ""
|
|
}
|
|
return i.issuer
|
|
}
|
|
|
|
func (i *IDTokenIssuer) TTL() time.Duration {
|
|
if i == nil {
|
|
return 0
|
|
}
|
|
return i.ttl
|
|
}
|
|
|
|
func (i *IDTokenIssuer) SupportedAlgorithms() []string {
|
|
if i == nil {
|
|
return nil
|
|
}
|
|
return i.keys.SupportedAlgorithms()
|
|
}
|
|
|
|
func (i *IDTokenIssuer) JWKS() ([]byte, string, error) {
|
|
if i == nil {
|
|
return nil, "", errors.New("telegram login ID token issuer is nil")
|
|
}
|
|
return i.keys.JWKS()
|
|
}
|
|
|
|
func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) {
|
|
if keys == nil {
|
|
return nil, errors.New("telegram login signing key ring is required")
|
|
}
|
|
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
|
|
}
|
|
if cfg.TTL == 0 {
|
|
cfg.TTL = defaultIDTokenTTL
|
|
}
|
|
if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour {
|
|
return nil, errors.New("telegram login ID token TTL is outside the bounded range")
|
|
}
|
|
if cfg.Now == nil {
|
|
cfg.Now = time.Now
|
|
}
|
|
return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil
|
|
}
|
|
|
|
func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) {
|
|
if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 ||
|
|
request.ClientID == "" || request.ApprovedAt.IsZero() {
|
|
return "", domain.ErrTelegramLoginRequestInvalid
|
|
}
|
|
if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil {
|
|
return "", err
|
|
}
|
|
identity := domain.TelegramLoginIdentitySnapshot{
|
|
UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName,
|
|
FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername,
|
|
Picture: request.Picture, PhoneNumber: request.PhoneNumber,
|
|
}
|
|
identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := i.now().UTC()
|
|
builder := jwt.NewBuilder().
|
|
Issuer(i.issuer).
|
|
Audience([]string{request.ClientID}).
|
|
Subject(fmt.Sprintf("%d", identity.UserID)).
|
|
IssuedAt(now).
|
|
Expiration(now.Add(i.ttl))
|
|
if request.Nonce != "" {
|
|
builder.Claim("nonce", request.Nonce)
|
|
}
|
|
if request.Requests(domain.TelegramLoginScopeProfile) {
|
|
builder.Claim("id", identity.UserID).
|
|
Claim("name", identity.Name).
|
|
Claim("given_name", identity.GivenName)
|
|
if identity.FamilyName != "" {
|
|
builder.Claim("family_name", identity.FamilyName)
|
|
}
|
|
if identity.PreferredUsername != "" {
|
|
builder.Claim("preferred_username", identity.PreferredUsername)
|
|
}
|
|
if identity.Picture != "" {
|
|
builder.Claim("picture", identity.Picture)
|
|
}
|
|
}
|
|
if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared {
|
|
builder.Claim("phone_number", identity.PhoneNumber).
|
|
Claim("phone_number_verified", true)
|
|
}
|
|
token, err := builder.Build()
|
|
if err != nil {
|
|
return "", fmt.Errorf("build telegram login ID token: %w", err)
|
|
}
|
|
return i.keys.sign(request.SigningAlgorithm, token)
|
|
}
|