Files
glchat/internal/server/passkey_emulator_test.go
T
grendervill cd1d662572 feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.

Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).

Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.

Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
2026-09-26 15:12:43 +03:00

216 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
)
// fakeAuthenticator — эмулятор аппаратного ключа для тестов: генерирует
// P-256 ключ, собирает attestationObject формата none и подписывает assertion.
// Так проверяется настоящая криптографическая проверка библиотеки, а не мок.
type fakeAuthenticator struct {
key *ecdsa.PrivateKey
credentialID []byte
handle []byte
signCount uint32
rpID string
origin string
// cloneWarning выставляет флаг BackupState: проверка клонов не должна
// ломать вход, но счётчик подписей обязан сохраняться.
backupEligible bool
}
func newFakeAuthenticator(t *testing.T, rpID, origin string) *fakeAuthenticator {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate authenticator key: %v", err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatalf("generate credential id: %v", err)
}
return &fakeAuthenticator{key: key, credentialID: credentialID, rpID: rpID, origin: origin}
}
// registrationOptions — то, что сервер вернул в /register/begin.
type registrationOptions struct {
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
RP struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"rp"`
User struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
} `json:"user"`
} `json:"publicKey"`
} `json:"options"`
Ceremony string `json:"ceremony"`
ExpiresInSec int `json:"expires_in_sec"`
}
// assertionOptions — то, что сервер вернул в /login/begin.
type assertionOptions struct {
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
} `json:"publicKey"`
} `json:"options"`
Ceremony string `json:"ceremony"`
}
func decodeBase64URL(t *testing.T, value string) []byte {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
t.Fatalf("decode base64url %q: %v", value, err)
}
return raw
}
func encodeBase64URL(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
// createResponse собирает ответ на регистрацию: JSON, который браузер отдаёт
// в navigator.credentials.create → сервер.
func (a *fakeAuthenticator) createResponse(t *testing.T, challenge string, userHandle []byte) map[string]any {
t.Helper()
a.handle = userHandle
clientData := map[string]any{
"type": "webauthn.create",
"challenge": challenge,
"origin": a.origin,
"crossOrigin": false,
}
clientDataJSON, err := json.Marshal(clientData)
if err != nil {
t.Fatalf("marshal client data: %v", err)
}
authData := a.authData(t, true)
attestation, err := cbor.Marshal(map[string]any{
"fmt": "none",
"attStmt": map[string]any{},
"authData": authData,
})
if err != nil {
t.Fatalf("marshal attestation object: %v", err)
}
return map[string]any{
"id": encodeBase64URL(a.credentialID),
"rawId": encodeBase64URL(a.credentialID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": encodeBase64URL(clientDataJSON),
"attestationObject": encodeBase64URL(attestation),
"transports": []string{"internal", "hybrid"},
},
"clientExtensionResults": map[string]any{},
}
}
// assertResponse собирает ответ на вход: подпись над authData||SHA256(clientDataJSON).
func (a *fakeAuthenticator) assertResponse(t *testing.T, challenge string) map[string]any {
t.Helper()
clientData := map[string]any{
"type": "webauthn.get",
"challenge": challenge,
"origin": a.origin,
"crossOrigin": false,
}
clientDataJSON, err := json.Marshal(clientData)
if err != nil {
t.Fatalf("marshal client data: %v", err)
}
a.signCount++
authData := a.authData(t, false)
clientHash := sha256.Sum256(clientDataJSON)
signed := append(append([]byte{}, authData...), clientHash[:]...)
digest := sha256.Sum256(signed)
signature, err := ecdsa.SignASN1(rand.Reader, a.key, digest[:])
if err != nil {
t.Fatalf("sign assertion: %v", err)
}
return map[string]any{
"id": encodeBase64URL(a.credentialID),
"rawId": encodeBase64URL(a.credentialID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": encodeBase64URL(clientDataJSON),
"authenticatorData": encodeBase64URL(authData),
"signature": encodeBase64URL(signature),
"userHandle": encodeBase64URL(a.handle),
},
"clientExtensionResults": map[string]any{},
}
}
// authData собирает authenticatorData: rpIdHash, флаги, счётчик и (при
// регистрации) attestedCredentialData с CBOR-ключом COSE EC2.
func (a *fakeAuthenticator) authData(t *testing.T, attestation bool) []byte {
t.Helper()
rpHash := sha256.Sum256([]byte(a.rpID))
flags := byte(0x01) // UP: пользователь присутствовал
flags |= 0x04 // UV: пользователь проверен
if attestation {
flags |= 0x40 // AT: есть attestedCredentialData
}
if a.backupEligible {
flags |= 0x08 // BE
flags |= 0x10 // BS
}
buffer := &bytes.Buffer{}
buffer.Write(rpHash[:])
buffer.WriteByte(flags)
_ = binary.Write(buffer, binary.BigEndian, a.signCount)
if !attestation {
return buffer.Bytes()
}
buffer.Write(make([]byte, 16))
credentialIDLength := make([]byte, 2)
binary.BigEndian.PutUint16(credentialIDLength, uint16(len(a.credentialID)))
buffer.Write(credentialIDLength)
buffer.Write(a.credentialID)
// PublicKey.Bytes() — несжатая точка: 0x04 || X || Y (Go 1.25+).
point, err := a.key.PublicKey.Bytes()
if err != nil {
t.Fatalf("encode public key: %v", err)
}
if len(point) != 65 {
t.Fatalf("unexpected public key encoding: %d bytes", len(point))
}
coseKey, err := cbor.Marshal(map[int]any{
1: 2, // kty: EC2
3: -7, // alg: ES256
-1: 1, // crv: P-256
-2: point[1:33],
-3: point[33:65],
})
if err != nil {
t.Fatalf("marshal cose key: %v", err)
}
buffer.Write(coseKey)
return buffer.Bytes()
}
// mustJSON кодирует значение в JSON-строку для запроса.
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
return string(encoded)
}