Files
glchat/internal/server/passkey_emulator_test.go
T

216 lines
6.7 KiB
Go
Raw Normal View History

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