cd1d662572
Фаза 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.
216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
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)
|
||
}
|