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.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user