chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
418
internal/webauthn/webauthn.go
Normal file
418
internal/webauthn/webauthn.go
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
// Package webauthn 实现 telesrv passkey 登录所需的 WebAuthn/FIDO2 服务端原语:
|
||||
// 生成注册/断言挑战选项、验证注册 attestation、验证登录 assertion。
|
||||
//
|
||||
// 它只处理字节与 JSON,不依赖 tg/domain/store——便于独立单测(配套
|
||||
// internal/webauthn/webauthntest 的软件 authenticator)。MTProto 场景下,客户端把
|
||||
// WebAuthn 各字段 base64url 解码后塞进 TL,故这里直接收原始字节(clientDataJSON、
|
||||
// authenticatorData、signature、attestationObject、credentialID)。
|
||||
//
|
||||
// 支持算法:ES256(ECDSA P-256,平台 passkey 主流)与 EdDSA(Ed25519)。
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
)
|
||||
|
||||
// 验证错误。调用方据此映射到 TL 错误码/拒绝登录。
|
||||
var (
|
||||
ErrClientDataInvalid = errors.New("webauthn: client data invalid")
|
||||
ErrChallengeMismatch = errors.New("webauthn: challenge mismatch")
|
||||
ErrOriginNotAllowed = errors.New("webauthn: origin not allowed")
|
||||
ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch")
|
||||
ErrUserNotPresent = errors.New("webauthn: user-present flag not set")
|
||||
ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid")
|
||||
ErrAttestationInvalid = errors.New("webauthn: attestation object invalid")
|
||||
ErrPublicKeyUnsupported = errors.New("webauthn: unsupported public key algorithm")
|
||||
ErrSignatureInvalid = errors.New("webauthn: signature invalid")
|
||||
ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)")
|
||||
)
|
||||
|
||||
// authenticatorData 标志位(WebAuthn §6.1)。
|
||||
const (
|
||||
flagUserPresent = 0x01
|
||||
flagUserVerified = 0x04
|
||||
flagAttestedCredData = 0x40
|
||||
flagExtensionData = 0x80
|
||||
)
|
||||
|
||||
// COSE algorithm identifiers。
|
||||
const (
|
||||
coseAlgES256 = -7
|
||||
coseAlgEdDSA = -8
|
||||
)
|
||||
|
||||
// COSE key type / curve。
|
||||
const (
|
||||
coseKtyOKP = 1
|
||||
coseKtyEC2 = 2
|
||||
coseCrvP256 = 1
|
||||
coseCrvEd25519 = 6
|
||||
)
|
||||
|
||||
// b64 是 WebAuthn 用的 base64url 无填充编码。
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// decodeB64URL 容忍有/无填充的 base64url。
|
||||
func decodeB64URL(s string) ([]byte, error) {
|
||||
if out, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// RegistrationParams 是构造 publicKey creation options 所需的输入。
|
||||
type RegistrationParams struct {
|
||||
RPID string
|
||||
RPName string
|
||||
UserID []byte // 通常是 "dcId:userId" 的字节
|
||||
UserName string
|
||||
UserDisplay string
|
||||
Challenge []byte
|
||||
TimeoutMillis int
|
||||
ExcludeIDs [][]byte // 已有凭据 id,避免同一 authenticator 重复注册
|
||||
}
|
||||
|
||||
// BuildRegistrationOptions 返回 account.passkeyRegistrationOptions 的 DataJSON 内容
|
||||
// (顶层含 publicKey 字段,与 DrKLO PasskeysController 读取约定一致)。
|
||||
func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) {
|
||||
if p.RPID == "" || len(p.Challenge) == 0 || len(p.UserID) == 0 {
|
||||
return nil, errors.New("webauthn: incomplete registration params")
|
||||
}
|
||||
timeout := p.TimeoutMillis
|
||||
if timeout == 0 {
|
||||
timeout = 120000
|
||||
}
|
||||
exclude := make([]map[string]any, 0, len(p.ExcludeIDs))
|
||||
for _, id := range p.ExcludeIDs {
|
||||
exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)})
|
||||
}
|
||||
pub := map[string]any{
|
||||
"rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, "Telegram")},
|
||||
"user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)},
|
||||
"challenge": b64.EncodeToString(p.Challenge),
|
||||
"pubKeyCredParams": []map[string]any{
|
||||
{"type": "public-key", "alg": coseAlgES256},
|
||||
{"type": "public-key", "alg": coseAlgEdDSA},
|
||||
},
|
||||
"timeout": timeout,
|
||||
"attestation": "none",
|
||||
"authenticatorSelection": map[string]any{
|
||||
"residentKey": "required",
|
||||
"userVerification": "preferred",
|
||||
},
|
||||
"excludeCredentials": exclude,
|
||||
}
|
||||
return json.Marshal(map[string]any{"publicKey": pub})
|
||||
}
|
||||
|
||||
// LoginParams 是构造 publicKey request options(断言挑战)的输入。
|
||||
type LoginParams struct {
|
||||
RPID string
|
||||
Challenge []byte
|
||||
TimeoutMillis int
|
||||
}
|
||||
|
||||
// BuildLoginOptions 返回 auth.passkeyLoginOptions 的 DataJSON 内容。无 allowCredentials
|
||||
// (discoverable/usernameless 登录,用户由 user_handle 反查)。
|
||||
func BuildLoginOptions(p LoginParams) ([]byte, error) {
|
||||
if p.RPID == "" || len(p.Challenge) == 0 {
|
||||
return nil, errors.New("webauthn: incomplete login params")
|
||||
}
|
||||
timeout := p.TimeoutMillis
|
||||
if timeout == 0 {
|
||||
timeout = 120000
|
||||
}
|
||||
pub := map[string]any{
|
||||
"challenge": b64.EncodeToString(p.Challenge),
|
||||
"timeout": timeout,
|
||||
"rpId": p.RPID,
|
||||
"userVerification": "preferred",
|
||||
"allowCredentials": []any{},
|
||||
}
|
||||
return json.Marshal(map[string]any{"publicKey": pub})
|
||||
}
|
||||
|
||||
// clientData 是 clientDataJSON 的解析结构。
|
||||
type clientData struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
// verifyClientData 校验 type/challenge/origin。allowedOrigins 为空表示不强校验 origin
|
||||
// (dev:服务端通常不预知 Android apk-key-hash origin)。
|
||||
func verifyClientData(clientDataJSON []byte, wantType string, challenge []byte, allowedOrigins []string) error {
|
||||
var cd clientData
|
||||
if err := json.Unmarshal(clientDataJSON, &cd); err != nil {
|
||||
return ErrClientDataInvalid
|
||||
}
|
||||
if cd.Type != wantType {
|
||||
return fmt.Errorf("%w: type=%q want %q", ErrClientDataInvalid, cd.Type, wantType)
|
||||
}
|
||||
got, err := decodeB64URL(cd.Challenge)
|
||||
if err != nil {
|
||||
return ErrClientDataInvalid
|
||||
}
|
||||
if subtle.ConstantTimeCompare(got, challenge) != 1 {
|
||||
return ErrChallengeMismatch
|
||||
}
|
||||
if len(allowedOrigins) > 0 {
|
||||
ok := false
|
||||
for _, o := range allowedOrigins {
|
||||
if o == cd.Origin {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %q", ErrOriginNotAllowed, cd.Origin)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChallengeFromClientData 提取 clientDataJSON 里的 challenge 原始字节,供调用方按挑战
|
||||
// 反查/消费一次性挑战 store(此时尚未验证,仅做查表键)。
|
||||
func ChallengeFromClientData(clientDataJSON []byte) ([]byte, error) {
|
||||
var cd clientData
|
||||
if err := json.Unmarshal(clientDataJSON, &cd); err != nil {
|
||||
return nil, ErrClientDataInvalid
|
||||
}
|
||||
b, err := decodeB64URL(cd.Challenge)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, ErrClientDataInvalid
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// parsedAuthData 是 authenticatorData 的解析结果。
|
||||
type parsedAuthData struct {
|
||||
rpIDHash []byte
|
||||
flags byte
|
||||
signCount uint32
|
||||
aaguid []byte
|
||||
credID []byte
|
||||
credPubKey []byte // COSE 公钥原始字节(仅注册时存在)
|
||||
}
|
||||
|
||||
func parseAuthData(authData []byte) (parsedAuthData, error) {
|
||||
if len(authData) < 37 {
|
||||
return parsedAuthData{}, ErrAuthDataInvalid
|
||||
}
|
||||
out := parsedAuthData{
|
||||
rpIDHash: authData[0:32],
|
||||
flags: authData[32],
|
||||
signCount: binary.BigEndian.Uint32(authData[33:37]),
|
||||
}
|
||||
rest := authData[37:]
|
||||
if out.flags&flagAttestedCredData != 0 {
|
||||
if len(rest) < 18 {
|
||||
return parsedAuthData{}, ErrAuthDataInvalid
|
||||
}
|
||||
out.aaguid = rest[0:16]
|
||||
credIDLen := int(binary.BigEndian.Uint16(rest[16:18]))
|
||||
rest = rest[18:]
|
||||
if len(rest) < credIDLen {
|
||||
return parsedAuthData{}, ErrAuthDataInvalid
|
||||
}
|
||||
out.credID = rest[:credIDLen]
|
||||
rest = rest[credIDLen:]
|
||||
// 之后是 COSE 公钥(变长 CBOR);用 UnmarshalFirst 只吃一个 CBOR item。
|
||||
var raw cbor.RawMessage
|
||||
remaining, err := cbor.UnmarshalFirst(rest, &raw)
|
||||
if err != nil {
|
||||
return parsedAuthData{}, ErrAuthDataInvalid
|
||||
}
|
||||
out.credPubKey = rest[:len(rest)-len(remaining)]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rpIDHashFor(rpID string) []byte {
|
||||
h := sha256.Sum256([]byte(rpID))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// Credential 是注册验证产出的凭据材料,调用方据此持久化。
|
||||
type Credential struct {
|
||||
ID []byte // credential id 原始字节
|
||||
COSEPublicKey []byte // COSE 公钥原始字节
|
||||
SignCount uint32
|
||||
AAGUID []byte
|
||||
}
|
||||
|
||||
// VerifyRegistrationInput 是注册验证输入。
|
||||
type VerifyRegistrationInput struct {
|
||||
ClientDataJSON []byte
|
||||
AttestationObject []byte
|
||||
RPID string
|
||||
Challenge []byte
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
type attestationObject struct {
|
||||
Fmt string `cbor:"fmt"`
|
||||
AuthData []byte `cbor:"authData"`
|
||||
AttStmt cbor.RawMessage `cbor:"attStmt"`
|
||||
}
|
||||
|
||||
// VerifyRegistration 校验 attestation 并提取凭据公钥。attestation 格式仅要求能取出
|
||||
// 公钥(消费级 passkey 一般是 "none",不校验 attestation statement 签名)。
|
||||
func VerifyRegistration(in VerifyRegistrationInput) (*Credential, error) {
|
||||
if err := verifyClientData(in.ClientDataJSON, "webauthn.create", in.Challenge, in.AllowedOrigins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var att attestationObject
|
||||
if err := cbor.Unmarshal(in.AttestationObject, &att); err != nil {
|
||||
return nil, ErrAttestationInvalid
|
||||
}
|
||||
ad, err := parseAuthData(att.AuthData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(ad.rpIDHash, rpIDHashFor(in.RPID)) != 1 {
|
||||
return nil, ErrRPIDMismatch
|
||||
}
|
||||
if ad.flags&flagUserPresent == 0 {
|
||||
return nil, ErrUserNotPresent
|
||||
}
|
||||
if ad.flags&flagAttestedCredData == 0 || len(ad.credID) == 0 || len(ad.credPubKey) == 0 {
|
||||
return nil, ErrAttestationInvalid
|
||||
}
|
||||
// 公钥能解析即认为有效(none attestation)。
|
||||
if _, err := parseCOSEKey(ad.credPubKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Credential{
|
||||
ID: append([]byte(nil), ad.credID...),
|
||||
COSEPublicKey: append([]byte(nil), ad.credPubKey...),
|
||||
SignCount: ad.signCount,
|
||||
AAGUID: append([]byte(nil), ad.aaguid...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyAssertionInput 是登录断言验证输入。
|
||||
type VerifyAssertionInput struct {
|
||||
COSEPublicKey []byte
|
||||
ClientDataJSON []byte
|
||||
AuthenticatorData []byte
|
||||
Signature []byte
|
||||
RPID string
|
||||
Challenge []byte
|
||||
AllowedOrigins []string
|
||||
StoredSignCount uint32
|
||||
}
|
||||
|
||||
// VerifyAssertion 校验登录断言,成功返回新的签名计数器(供持久化)。
|
||||
func VerifyAssertion(in VerifyAssertionInput) (uint32, error) {
|
||||
if err := verifyClientData(in.ClientDataJSON, "webauthn.get", in.Challenge, in.AllowedOrigins); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ad, err := parseAuthData(in.AuthenticatorData)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(ad.rpIDHash, rpIDHashFor(in.RPID)) != 1 {
|
||||
return 0, ErrRPIDMismatch
|
||||
}
|
||||
if ad.flags&flagUserPresent == 0 {
|
||||
return 0, ErrUserNotPresent
|
||||
}
|
||||
pub, err := parseCOSEKey(in.COSEPublicKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 签名覆盖 authenticatorData || sha256(clientDataJSON)。
|
||||
cdHash := sha256.Sum256(in.ClientDataJSON)
|
||||
signed := make([]byte, 0, len(in.AuthenticatorData)+len(cdHash))
|
||||
signed = append(signed, in.AuthenticatorData...)
|
||||
signed = append(signed, cdHash[:]...)
|
||||
if err := pub.verify(signed, in.Signature); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 计数器回退检测:仅当双方都非 0 时严格(passkey 常报 0)。
|
||||
if ad.signCount != 0 && in.StoredSignCount != 0 && ad.signCount <= in.StoredSignCount {
|
||||
return 0, ErrCounterRegressed
|
||||
}
|
||||
return ad.signCount, nil
|
||||
}
|
||||
|
||||
// publicKey 抽象 ES256/EdDSA 验签。
|
||||
type publicKey struct {
|
||||
ec *ecdsa.PublicKey
|
||||
ed ed25519.PublicKey
|
||||
}
|
||||
|
||||
func (p publicKey) verify(signed, sig []byte) error {
|
||||
switch {
|
||||
case p.ec != nil:
|
||||
digest := sha256.Sum256(signed)
|
||||
if !ecdsa.VerifyASN1(p.ec, digest[:], sig) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
case p.ed != nil:
|
||||
if !ed25519.Verify(p.ed, signed, sig) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrPublicKeyUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
type coseKey struct {
|
||||
Kty int `cbor:"1,keyasint"`
|
||||
Alg int `cbor:"3,keyasint"`
|
||||
Crv int `cbor:"-1,keyasint"`
|
||||
X []byte `cbor:"-2,keyasint"`
|
||||
Y []byte `cbor:"-3,keyasint"`
|
||||
}
|
||||
|
||||
func parseCOSEKey(raw []byte) (publicKey, error) {
|
||||
var k coseKey
|
||||
if err := cbor.Unmarshal(raw, &k); err != nil {
|
||||
return publicKey{}, ErrPublicKeyUnsupported
|
||||
}
|
||||
switch k.Kty {
|
||||
case coseKtyEC2:
|
||||
if k.Alg != coseAlgES256 || k.Crv != coseCrvP256 || len(k.X) != 32 || len(k.Y) != 32 {
|
||||
return publicKey{}, ErrPublicKeyUnsupported
|
||||
}
|
||||
pub := &ecdsa.PublicKey{
|
||||
Curve: elliptic.P256(),
|
||||
X: new(big.Int).SetBytes(k.X),
|
||||
Y: new(big.Int).SetBytes(k.Y),
|
||||
}
|
||||
if !pub.Curve.IsOnCurve(pub.X, pub.Y) {
|
||||
return publicKey{}, ErrPublicKeyUnsupported
|
||||
}
|
||||
return publicKey{ec: pub}, nil
|
||||
case coseKtyOKP:
|
||||
if k.Alg != coseAlgEdDSA || k.Crv != coseCrvEd25519 || len(k.X) != ed25519.PublicKeySize {
|
||||
return publicKey{}, ErrPublicKeyUnsupported
|
||||
}
|
||||
return publicKey{ed: ed25519.PublicKey(append([]byte(nil), k.X...))}, nil
|
||||
default:
|
||||
return publicKey{}, ErrPublicKeyUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue