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
|
||||
}
|
||||
175
internal/webauthn/webauthn_test.go
Normal file
175
internal/webauthn/webauthn_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package webauthn_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/webauthn"
|
||||
"telesrv/internal/webauthn/webauthntest"
|
||||
)
|
||||
|
||||
const (
|
||||
testRPID = "telesrv.test"
|
||||
testOrigin = "android:apk-key-hash:Zm9vYmFy"
|
||||
)
|
||||
|
||||
func randChallenge(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func registerAuth(t *testing.T) (*webauthntest.Authenticator, *webauthn.Credential) {
|
||||
t.Helper()
|
||||
auth, err := webauthntest.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
}
|
||||
ch := randChallenge(t)
|
||||
cd, att, err := auth.Register(testRPID, testOrigin, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
cred, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{
|
||||
ClientDataJSON: cd, AttestationObject: att, RPID: testRPID, Challenge: ch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRegistration: %v", err)
|
||||
}
|
||||
if !bytes.Equal(cred.ID, auth.CredentialID()) {
|
||||
t.Fatalf("credential id mismatch: got %x want %x", cred.ID, auth.CredentialID())
|
||||
}
|
||||
if len(cred.COSEPublicKey) == 0 {
|
||||
t.Fatal("empty COSE public key")
|
||||
}
|
||||
return auth, cred
|
||||
}
|
||||
|
||||
func TestRegisterThenAssert(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, err := auth.Assert(testRPID, testOrigin, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("assert: %v", err)
|
||||
}
|
||||
count, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAssertion: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("sign count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertCounterRegressionRejected(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch) // signCount -> 1
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, StoredSignCount: 5, // 已存计数器更大 => 克隆嫌疑
|
||||
}); !errors.Is(err, webauthn.ErrCounterRegressed) {
|
||||
t.Fatalf("err = %v, want ErrCounterRegressed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertChallengeMismatch(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch)
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: randChallenge(t), StoredSignCount: cred.SignCount,
|
||||
}); !errors.Is(err, webauthn.ErrChallengeMismatch) {
|
||||
t.Fatalf("err = %v, want ErrChallengeMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSignatureTamperRejected(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch)
|
||||
sig[len(sig)-1] ^= 0xFF
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount,
|
||||
}); !errors.Is(err, webauthn.ErrSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertWrongRPIDRejected(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, _ := auth.Assert("other.rp", testOrigin, ch)
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount,
|
||||
}); !errors.Is(err, webauthn.ErrRPIDMismatch) {
|
||||
t.Fatalf("err = %v, want ErrRPIDMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationWrongRPIDRejected(t *testing.T) {
|
||||
auth, _ := func() (*webauthntest.Authenticator, *webauthn.Credential) {
|
||||
a, err := webauthntest.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
return a, nil
|
||||
}()
|
||||
ch := randChallenge(t)
|
||||
cd, att, _ := auth.Register("attacker.rp", testOrigin, ch)
|
||||
if _, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{
|
||||
ClientDataJSON: cd, AttestationObject: att, RPID: testRPID, Challenge: ch,
|
||||
}); !errors.Is(err, webauthn.ErrRPIDMismatch) {
|
||||
t.Fatalf("err = %v, want ErrRPIDMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginEnforcement(t *testing.T) {
|
||||
auth, cred := registerAuth(t)
|
||||
ch := randChallenge(t)
|
||||
cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch)
|
||||
// 允许列表匹配 => 通过。
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, AllowedOrigins: []string{testOrigin}, StoredSignCount: cred.SignCount,
|
||||
}); err != nil {
|
||||
t.Fatalf("matching origin should pass: %v", err)
|
||||
}
|
||||
// 允许列表不含该 origin => 拒绝。
|
||||
if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{
|
||||
COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig,
|
||||
RPID: testRPID, Challenge: ch, AllowedOrigins: []string{"android:apk-key-hash:other"}, StoredSignCount: cred.SignCount,
|
||||
}); !errors.Is(err, webauthn.ErrOriginNotAllowed) {
|
||||
t.Fatalf("err = %v, want ErrOriginNotAllowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOptionsContainPublicKey(t *testing.T) {
|
||||
reg, err := webauthn.BuildRegistrationOptions(webauthn.RegistrationParams{
|
||||
RPID: testRPID, UserID: []byte("2:123"), UserName: "user", Challenge: randChallenge(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRegistrationOptions: %v", err)
|
||||
}
|
||||
if !bytes.Contains(reg, []byte(`"publicKey"`)) || !bytes.Contains(reg, []byte(`"challenge"`)) {
|
||||
t.Fatalf("registration options missing publicKey/challenge: %s", reg)
|
||||
}
|
||||
login, err := webauthn.BuildLoginOptions(webauthn.LoginParams{RPID: testRPID, Challenge: randChallenge(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLoginOptions: %v", err)
|
||||
}
|
||||
if !bytes.Contains(login, []byte(`"publicKey"`)) || !bytes.Contains(login, []byte(`"rpId"`)) {
|
||||
t.Fatalf("login options missing publicKey/rpId: %s", login)
|
||||
}
|
||||
}
|
||||
135
internal/webauthn/webauthntest/authenticator.go
Normal file
135
internal/webauthn/webauthntest/authenticator.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Package webauthntest 提供一个纯 Go 软件 authenticator,模拟平台 passkey 设备
|
||||
// 生成符合 WebAuthn 规范的 attestation(注册)与 assertion(登录)。
|
||||
//
|
||||
// 它与被测的 internal/webauthn 是「同一规范的两份独立实现」:authenticator 按规范
|
||||
// 构造字节并用真私钥签名,服务端按规范验证。e2e/单测通过即说明双方对规范的理解一致。
|
||||
// 仅供测试使用(被 _test.go 导入)。
|
||||
package webauthntest
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagUserPresent = 0x01
|
||||
flagUserVerified = 0x04
|
||||
flagAttestedCredData = 0x40
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// Authenticator 是一个 ES256(ECDSA P-256)软件 authenticator,持有一对密钥与一个
|
||||
// 凭据 id,可重复用于注册与多次登录(签名计数器递增)。
|
||||
type Authenticator struct {
|
||||
priv *ecdsa.PrivateKey
|
||||
credID []byte
|
||||
aaguid []byte
|
||||
signCount uint32
|
||||
}
|
||||
|
||||
// New 生成一个新的软件 authenticator(随机 ES256 密钥 + 随机 credential id)。
|
||||
func New() (*Authenticator, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credID := make([]byte, 32)
|
||||
if _, err := rand.Read(credID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aaguid := make([]byte, 16) // 全 0 aaguid(与多数 platform passkey 一致)
|
||||
return &Authenticator{priv: priv, credID: credID, aaguid: aaguid}, nil
|
||||
}
|
||||
|
||||
// CredentialID 返回 credential id 原始字节。
|
||||
func (a *Authenticator) CredentialID() []byte { return append([]byte(nil), a.credID...) }
|
||||
|
||||
// CredentialIDB64 返回 base64url(无填充) 的 credential id(对应 TL 的 ID/RawID string)。
|
||||
func (a *Authenticator) CredentialIDB64() string { return b64.EncodeToString(a.credID) }
|
||||
|
||||
type clientData struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
CrossOrigin bool `json:"crossOrigin"`
|
||||
}
|
||||
|
||||
func (a *Authenticator) clientDataJSON(typ, origin string, challenge []byte) []byte {
|
||||
out, _ := json.Marshal(clientData{Type: typ, Challenge: b64.EncodeToString(challenge), Origin: origin})
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Authenticator) cosePublicKey() []byte {
|
||||
x := make([]byte, 32)
|
||||
y := make([]byte, 32)
|
||||
a.priv.PublicKey.X.FillBytes(x)
|
||||
a.priv.PublicKey.Y.FillBytes(y)
|
||||
key := 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"`
|
||||
}{Kty: 2, Alg: -7, Crv: 1, X: x, Y: y}
|
||||
out, _ := cbor.Marshal(key)
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Authenticator) authData(rpID string, flags byte, includeCred bool) []byte {
|
||||
h := sha256.Sum256([]byte(rpID))
|
||||
buf := make([]byte, 0, 128)
|
||||
buf = append(buf, h[:]...)
|
||||
buf = append(buf, flags)
|
||||
var counter [4]byte
|
||||
binary.BigEndian.PutUint32(counter[:], a.signCount)
|
||||
buf = append(buf, counter[:]...)
|
||||
if includeCred {
|
||||
buf = append(buf, a.aaguid...)
|
||||
var l [2]byte
|
||||
binary.BigEndian.PutUint16(l[:], uint16(len(a.credID)))
|
||||
buf = append(buf, l[:]...)
|
||||
buf = append(buf, a.credID...)
|
||||
buf = append(buf, a.cosePublicKey()...)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Register 模拟 createCredential,返回 clientDataJSON 与 attestationObject(fmt=none)。
|
||||
func (a *Authenticator) Register(rpID, origin string, challenge []byte) (clientDataJSON, attestationObject []byte, err error) {
|
||||
cd := a.clientDataJSON("webauthn.create", origin, challenge)
|
||||
ad := a.authData(rpID, flagUserPresent|flagUserVerified|flagAttestedCredData, true)
|
||||
att := struct {
|
||||
Fmt string `cbor:"fmt"`
|
||||
AttStmt map[string]any `cbor:"attStmt"`
|
||||
AuthData []byte `cbor:"authData"`
|
||||
}{Fmt: "none", AttStmt: map[string]any{}, AuthData: ad}
|
||||
obj, err := cbor.Marshal(att)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cd, obj, nil
|
||||
}
|
||||
|
||||
// Assert 模拟 getCredential,返回 clientDataJSON、authenticatorData 与 signature。
|
||||
// 每次调用签名计数器 +1。
|
||||
func (a *Authenticator) Assert(rpID, origin string, challenge []byte) (clientDataJSON, authenticatorData, signature []byte, err error) {
|
||||
a.signCount++
|
||||
cd := a.clientDataJSON("webauthn.get", origin, challenge)
|
||||
ad := a.authData(rpID, flagUserPresent|flagUserVerified, false)
|
||||
cdHash := sha256.Sum256(cd)
|
||||
signed := append(append([]byte(nil), ad...), cdHash[:]...)
|
||||
digest := sha256.Sum256(signed)
|
||||
sig, err := ecdsa.SignASN1(rand.Reader, a.priv, digest[:])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return cd, ad, sig, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue