135 lines
4.9 KiB
Go
135 lines
4.9 KiB
Go
|
|
package webauthn
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestVerify_HappyPath(t *testing.T) {
|
||
|
|
pub, priv, cred, store := setupCredential(t, "alice")
|
||
|
|
_ = pub
|
||
|
|
challenge := []byte("challenge-bytes-just-for-the-test")
|
||
|
|
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||
|
|
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
got, err := Verify(cfg, store, a, challenge)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Verify: %v", err)
|
||
|
|
}
|
||
|
|
if got.ID != cred.ID {
|
||
|
|
t.Errorf("credential id mismatch")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerify_WrongChallenge(t *testing.T) {
|
||
|
|
_, priv, cred, store := setupCredential(t, "alice")
|
||
|
|
challenge := []byte("original-challenge-bytes-xxxxxxxx")
|
||
|
|
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||
|
|
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
_, err := Verify(cfg, store, a, []byte("different-challenge-bytes-yyyyyy"))
|
||
|
|
if !errors.Is(err, ErrChallengeMismatch) {
|
||
|
|
t.Fatalf("want ErrChallengeMismatch, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerify_UnknownCredential(t *testing.T) {
|
||
|
|
store := NewStore()
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
_, err := Verify(cfg, store, Assertion{CredentialID: "nope"}, []byte("c"))
|
||
|
|
if !errors.Is(err, ErrCredentialUnknown) {
|
||
|
|
t.Fatalf("want ErrCredentialUnknown, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerify_WrongOrigin(t *testing.T) {
|
||
|
|
_, priv, cred, store := setupCredential(t, "alice")
|
||
|
|
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||
|
|
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://evil.example", challenge)
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
_, err := Verify(cfg, store, a, challenge)
|
||
|
|
if err == nil || !errors.Is(err, ErrAssertionInvalid) {
|
||
|
|
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerify_WrongType(t *testing.T) {
|
||
|
|
_, priv, cred, store := setupCredential(t, "alice")
|
||
|
|
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||
|
|
a := makeAssertionWithType(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge, "webauthn.create")
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
_, err := Verify(cfg, store, a, challenge)
|
||
|
|
if !errors.Is(err, ErrAssertionInvalid) {
|
||
|
|
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerify_BadSignature(t *testing.T) {
|
||
|
|
_, priv, cred, store := setupCredential(t, "alice")
|
||
|
|
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||
|
|
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||
|
|
// Tamper with the signature.
|
||
|
|
sig, _ := base64.RawURLEncoding.DecodeString(a.Signature)
|
||
|
|
sig[0] ^= 0xff
|
||
|
|
a.Signature = base64.RawURLEncoding.EncodeToString(sig)
|
||
|
|
|
||
|
|
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||
|
|
_, err := Verify(cfg, store, a, challenge)
|
||
|
|
if !errors.Is(err, ErrAssertionInvalid) {
|
||
|
|
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- helpers --------------------------------------------------------------
|
||
|
|
|
||
|
|
func setupCredential(t *testing.T, userSub string) (ed25519.PublicKey, ed25519.PrivateKey, Credential, *Store) {
|
||
|
|
t.Helper()
|
||
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("gen key: %v", err)
|
||
|
|
}
|
||
|
|
idBytes := make([]byte, 16)
|
||
|
|
_, _ = rand.Read(idBytes)
|
||
|
|
cred := Credential{
|
||
|
|
ID: base64.RawURLEncoding.EncodeToString(idBytes),
|
||
|
|
UserSub: userSub,
|
||
|
|
PublicKey: pub,
|
||
|
|
}
|
||
|
|
store := NewStore()
|
||
|
|
if err := store.AddCredential(cred); err != nil {
|
||
|
|
t.Fatalf("AddCredential: %v", err)
|
||
|
|
}
|
||
|
|
return pub, priv, cred, store
|
||
|
|
}
|
||
|
|
|
||
|
|
// makeAssertion signs a mock WebAuthn assertion with the given private
|
||
|
|
// key over the standard authenticator_data || sha256(clientDataJSON).
|
||
|
|
func makeAssertion(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte) Assertion {
|
||
|
|
return makeAssertionWithType(t, priv, credID, rpID, origin, challenge, "webauthn.get")
|
||
|
|
}
|
||
|
|
|
||
|
|
func makeAssertionWithType(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte, typ string) Assertion {
|
||
|
|
t.Helper()
|
||
|
|
cd := map[string]string{
|
||
|
|
"type": typ,
|
||
|
|
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
|
||
|
|
"origin": origin,
|
||
|
|
}
|
||
|
|
cdBytes, _ := json.Marshal(cd)
|
||
|
|
authData := MakeAuthenticatorData(rpID)
|
||
|
|
cdHash := sha256.Sum256(cdBytes)
|
||
|
|
signed := append(append([]byte(nil), authData...), cdHash[:]...)
|
||
|
|
sig := ed25519.Sign(priv, signed)
|
||
|
|
return Assertion{
|
||
|
|
CredentialID: credID,
|
||
|
|
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
|
||
|
|
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
|
||
|
|
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
||
|
|
}
|
||
|
|
}
|