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) }