package mtprotoedge import ( "context" "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/json" "fmt" "net" "testing" "time" "go.uber.org/zap/zaptest" "github.com/gotd/log/logzap" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/exchange" "github.com/iamxvbaba/td/session" "github.com/iamxvbaba/td/telegram" "github.com/iamxvbaba/td/telegram/dcs" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/transport" "telesrv/internal/app/account" "telesrv/internal/app/auth" "telesrv/internal/app/contacts" "telesrv/internal/app/dialogs" "telesrv/internal/app/help" passkeyapp "telesrv/internal/app/passkey" "telesrv/internal/app/updates" "telesrv/internal/app/users" "telesrv/internal/rpc" "telesrv/internal/store/memory" "telesrv/internal/webauthn/webauthntest" ) const passkeyTestOrigin = "android:apk-key-hash:e2e-test" // publicKeyOptions 提取 DataJSON(顶层 publicKey)里的 challenge / rpId / user.id。 type publicKeyOptions struct { PublicKey struct { Challenge string `json:"challenge"` RPID string `json:"rpId"` RP struct { ID string `json:"id"` } `json:"rp"` User struct { ID string `json:"id"` } `json:"user"` } `json:"publicKey"` } func parsePasskeyOptions(t *testing.T, data string) publicKeyOptions { t.Helper() var opts publicKeyOptions if err := json.Unmarshal([]byte(data), &opts); err != nil { t.Fatalf("parse passkey options %q: %v", data, err) } return opts } func decodeB64URL(t *testing.T, s string) []byte { t.Helper() b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { if b, err = base64.URLEncoding.DecodeString(s); err != nil { t.Fatalf("base64url decode %q: %v", s, err) } } return b } // TestPasskeyEndToEnd 端到端验证 passkey:设备 A(已登录)注册 passkey,设备 B(全新 // auth key)经 initPasskeyLogin/finishPasskeyLogin 用软件 authenticator 的断言登录;再 // getPasskeys/deletePasskey 往返。整条链路验签真实(软件 authenticator 用真私钥签名)。 func TestPasskeyEndToEnd(t *testing.T) { const ( dc = 2 phone = "+8613800139000" wantPhone = "8613800139000" code = "12345" rpID = "telesrv.test" ) rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("gen rsa: %v", err) } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } tcpAddr := ln.Addr().(*net.TCPAddr) userStore := memory.NewUserStore() authKeyStore := memory.NewAuthKeyStore() helpStore := memory.NewHelpStore() dialogStore := memory.NewDialogStore() messageStore := memory.NewMessageStore(dialogStore) updateEventStore := memory.NewUpdateEventStore() passkeyService := passkeyapp.NewService(memory.NewPasskeyStore(), memory.NewPasskeyChallengeStore(), rpID, dc) deps := rpc.Deps{ Auth: auth.NewService(userStore, memory.NewAuthorizationStore(), memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code, auth.WithLoginMessages(messageStore, dialogStore), auth.WithLoginCodeDelivery(memory.NewLoginCodeDeliveryStore(messageStore, updateEventStore))), Account: account.NewService(memory.NewPasswordStore(), account.WithUsers(userStore)), Help: help.NewService(helpStore, helpStore), Users: users.NewService(userStore), Updates: updates.NewService(memory.NewUpdateStateStore(), updateEventStore), Contacts: contacts.NewService(memory.NewContactStore()), Dialogs: dialogs.NewService(dialogStore), Passkey: passkeyService, } router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router}) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) serveErr := make(chan error, 1) go func() { serveErr <- srv.Serve(ctx, ln) }() defer func() { cancel() <-serveErr }() newClient := func() *telegram.Client { return telegram.NewClient(1, "hash", telegram.Options{ PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, Logger: logzap.New(zaptest.NewLogger(t).Named("client")), SessionStorage: &session.StorageMemory{}, UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), }) } authn, err := webauthntest.New() if err != nil { t.Fatalf("new authenticator: %v", err) } var userHandle string // 设备 A:注册 + 注册 passkey + getPasskeys。 deviceA := newClient() if err := deviceA.Run(ctx, func(ctx context.Context) error { raw := tg.NewClient(deviceA) sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) if err != nil { return err } hash := sent.(*tg.AuthSentCode).PhoneCodeHash if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: hash, PhoneCode: code}); err != nil { return err } if _, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: hash, FirstName: "Pass"}); err != nil { return err } // 1) 注册选项 → 软件 authenticator 造 attestation。 regOpts, err := raw.AccountInitPasskeyRegistration(ctx) if err != nil { return err } opts := parsePasskeyOptions(t, regOpts.Options.Data) if opts.PublicKey.RP.ID != rpID { return fmt.Errorf("registration rp.id = %q, want %q", opts.PublicKey.RP.ID, rpID) } userHandle = string(decodeB64URL(t, opts.PublicKey.User.ID)) // "2:" challenge := decodeB64URL(t, opts.PublicKey.Challenge) clientData, attObj, err := authn.Register(rpID, passkeyTestOrigin, challenge) if err != nil { return err } credB64 := authn.CredentialIDB64() cred := &tg.InputPasskeyCredentialPublicKey{ ID: credB64, RawID: credB64, Response: &tg.InputPasskeyResponseRegister{ ClientData: tg.DataJSON{Data: string(clientData)}, AttestationData: attObj, }, } pk, err := raw.AccountRegisterPasskey(ctx, cred) if err != nil { return err } if pk.ID != credB64 { return fmt.Errorf("registered passkey id = %q, want %q", pk.ID, credB64) } // 2) getPasskeys 应含该 passkey。 list, err := raw.AccountGetPasskeys(ctx) if err != nil { return err } if len(list.Passkeys) != 1 || list.Passkeys[0].ID != credB64 { return fmt.Errorf("getPasskeys = %+v, want 1 passkey id=%q", list.Passkeys, credB64) } return nil }); err != nil { t.Fatalf("device A: %v", err) } // 设备 B:全新 auth key,用 passkey 登录。 deviceB := newClient() if err := deviceB.Run(ctx, func(ctx context.Context) error { raw := tg.NewClient(deviceB) loginOpts, err := raw.AuthInitPasskeyLogin(ctx, &tg.AuthInitPasskeyLoginRequest{APIID: 1, APIHash: "hash"}) if err != nil { return err } opts := parsePasskeyOptions(t, loginOpts.Options.Data) if opts.PublicKey.RPID != rpID { return fmt.Errorf("login rpId = %q, want %q", opts.PublicKey.RPID, rpID) } challenge := decodeB64URL(t, opts.PublicKey.Challenge) clientData, authData, sig, err := authn.Assert(rpID, passkeyTestOrigin, challenge) if err != nil { return err } credB64 := authn.CredentialIDB64() res, err := raw.AuthFinishPasskeyLogin(ctx, &tg.AuthFinishPasskeyLoginRequest{ Credential: &tg.InputPasskeyCredentialPublicKey{ ID: credB64, RawID: credB64, Response: &tg.InputPasskeyResponseLogin{ ClientData: tg.DataJSON{Data: string(clientData)}, AuthenticatorData: authData, Signature: sig, UserHandle: userHandle, }, }, }) if err != nil { return err } authz, ok := res.(*tg.AuthAuthorization) if !ok { return fmt.Errorf("finishPasskeyLogin result = %T, want *tg.AuthAuthorization", res) } self, ok := authz.User.(*tg.User) if !ok || !self.Self || self.Phone != wantPhone { return fmt.Errorf("passkey login user = %+v, want self phone=%s", authz.User, wantPhone) } // 第二次断言(计数器递增)仍应通过。 loginOpts2, err := raw.AuthInitPasskeyLogin(ctx, &tg.AuthInitPasskeyLoginRequest{APIID: 1, APIHash: "hash"}) if err != nil { return err } ch2 := decodeB64URL(t, parsePasskeyOptions(t, loginOpts2.Options.Data).PublicKey.Challenge) cd2, ad2, sig2, err := authn.Assert(rpID, passkeyTestOrigin, ch2) if err != nil { return err } if _, err := raw.AuthFinishPasskeyLogin(ctx, &tg.AuthFinishPasskeyLoginRequest{ Credential: &tg.InputPasskeyCredentialPublicKey{ ID: credB64, RawID: credB64, Response: &tg.InputPasskeyResponseLogin{ ClientData: tg.DataJSON{Data: string(cd2)}, AuthenticatorData: ad2, Signature: sig2, UserHandle: userHandle, }, }, }); err != nil { return fmt.Errorf("second passkey login: %w", err) } return nil }); err != nil { t.Fatalf("device B: %v", err) } // 设备 A 再次连接:删除 passkey → getPasskeys 空。 deviceC := newClient() if err := deviceC.Run(ctx, func(ctx context.Context) error { raw := tg.NewClient(deviceC) sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) if err != nil { return err } if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: sent.(*tg.AuthSentCode).PhoneCodeHash, PhoneCode: code}); err != nil { return err } deleted, err := raw.AccountDeletePasskey(ctx, authn.CredentialIDB64()) if err != nil { return err } if !deleted { return fmt.Errorf("deletePasskey returned false") } list, err := raw.AccountGetPasskeys(ctx) if err != nil { return err } if len(list.Passkeys) != 0 { return fmt.Errorf("getPasskeys after delete = %+v, want empty", list.Passkeys) } return nil }); err != nil { t.Fatalf("device C: %v", err) } }