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