Merge the auth and wire sweep (#247)
Two bugs a stranger can reach, both on the seam V-515 is about to put on the network. The netaddr token handshake ran inline in Listener.Accept, so a peer that connected and never spoke was owed the full 5s handshake timeout, and no other connection could be accepted during it. One unauthenticated stranger holding a socket froze the seam. Accept now reads authorized conns off a channel fed by a loop that greets each one in its own goroutine, and Close releases what is still queued. A unix seam delegates straight through and grows nothing. webauthn kept regs and asserts as bare maps, driven from four HTTP handlers. A concurrent map write is a fatal runtime error rather than a recovered panic, so two browsers beginning a challenge at once take the web daemon down, from an endpoint that answers before any credential is proven. A mutex covers every access, and lookup and delete fold into takeReg and takeAssert. That fold is a security fix in its own right. Two replays of one response both found the challenge before either deleted it, so a challenge was not single-use. The clientDataJSON comparison is constant time now. Checked and already right: every gating value comes from crypto/rand, expiry is checked on use rather than on issue, readFrame caps at 4 MiB before allocating, and internal/auth fails closed on every arm including AuthStepUp with a nil session. stepUpOK's fail-open and fail-closed story rests on package behaviour, since a nil PasskeySession returns false from IsStepUp. (V-581)
This commit is contained in:
@@ -131,9 +131,15 @@ const (
|
|||||||
codeInternal = "internal"
|
codeInternal = "internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// codeOf maps a server-side sentinel to its wire code. Anything not matched
|
// codeOf maps a server-side sentinel to its wire code. Anything not matched is
|
||||||
// is codeInternal — we never leak internal Go error text to a module; it
|
// codeInternal.
|
||||||
// gets a generic "internal" and the daemon logs the real error server-side.
|
//
|
||||||
|
// This used to claim the text of an unmatched error stays server-side. It does
|
||||||
|
// not: rpcErr below ships err.Error() for codeInternal and codeBadParams,
|
||||||
|
// deliberately, because on those two codes the text is the whole diagnostic and
|
||||||
|
// a module has no other way to see it. Worth knowing before putting a secret in
|
||||||
|
// an error string, and worth knowing twice on a tcp seam, where that string
|
||||||
|
// leaves the box.
|
||||||
func codeOf(err error) string {
|
func codeOf(err error) string {
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
|
|||||||
+69
-11
@@ -31,6 +31,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -154,31 +155,78 @@ func clientHandshake(c net.Conn, token string) error {
|
|||||||
// Listener wraps a net.Listener so Accept performs the token check for a tcp
|
// Listener wraps a net.Listener so Accept performs the token check for a tcp
|
||||||
// seam. A connection that fails the check is closed and never surfaces, so
|
// seam. A connection that fails the check is closed and never surfaces, so
|
||||||
// the protocol above this layer only ever sees authorized peers.
|
// the protocol above this layer only ever sees authorized peers.
|
||||||
|
//
|
||||||
|
// A unix seam takes none of that machinery: Accept delegates straight to the
|
||||||
|
// wrapped listener, which is what it did before the token existed.
|
||||||
type Listener struct {
|
type Listener struct {
|
||||||
net.Listener
|
net.Listener
|
||||||
addr Addr
|
addr Addr
|
||||||
|
|
||||||
|
start sync.Once
|
||||||
|
closeOnce sync.Once
|
||||||
|
conns chan net.Conn
|
||||||
|
errc chan error // buffered 1, re-armed so every Accept sees the error
|
||||||
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept returns the next authorized connection. Unauthorized peers are
|
// Accept returns the next authorized connection. Unauthorized peers are
|
||||||
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
|
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
|
||||||
// reason to stop serving.
|
// reason to stop serving.
|
||||||
|
//
|
||||||
|
// Each tcp handshake runs in its own goroutine rather than inline here. A peer
|
||||||
|
// that connects and then says nothing holds its greeting open for
|
||||||
|
// handshakeTimeout, and inline that peer stalls every other connection for
|
||||||
|
// five seconds — one silent stranger was enough to freeze the seam.
|
||||||
func (l *Listener) Accept() (net.Conn, error) {
|
func (l *Listener) Accept() (net.Conn, error) {
|
||||||
|
if l.addr.IsUnix() {
|
||||||
|
return l.Listener.Accept()
|
||||||
|
}
|
||||||
|
l.start.Do(func() { go l.acceptLoop() })
|
||||||
|
select {
|
||||||
|
case c := <-l.conns:
|
||||||
|
return c, nil
|
||||||
|
case err := <-l.errc:
|
||||||
|
l.errc <- err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptLoop takes connections off the wrapped listener and greets each one
|
||||||
|
// concurrently. It ends on the first listener error, which every later Accept
|
||||||
|
// then reports.
|
||||||
|
func (l *Listener) acceptLoop() {
|
||||||
for {
|
for {
|
||||||
c, err := l.Listener.Accept()
|
c, err := l.Listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
select {
|
||||||
|
case l.errc <- err:
|
||||||
|
case <-l.done:
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if l.addr.IsUnix() {
|
go l.greet(c)
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
if err := serverHandshake(c, l.addr.Token); err != nil {
|
|
||||||
_ = c.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Listener) greet(c net.Conn) {
|
||||||
|
if err := serverHandshake(c, l.addr.Token); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.conns <- c:
|
||||||
|
case <-l.done:
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the listener and releases any connection still waiting to be
|
||||||
|
// handed to Accept.
|
||||||
|
func (l *Listener) Close() error {
|
||||||
|
l.closeOnce.Do(func() { close(l.done) })
|
||||||
|
return l.Listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// Addr reports the parsed seam address this listener was built from.
|
// Addr reports the parsed seam address this listener was built from.
|
||||||
func (l *Listener) SeamAddr() Addr { return l.addr }
|
func (l *Listener) SeamAddr() Addr { return l.addr }
|
||||||
|
|
||||||
@@ -236,7 +284,7 @@ func Listen(a Addr) (*Listener, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Listener{Listener: ln, addr: a}, nil
|
return wrap(ln, a), nil
|
||||||
}
|
}
|
||||||
if a.Token == "" {
|
if a.Token == "" {
|
||||||
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
|
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
|
||||||
@@ -245,7 +293,17 @@ func Listen(a Addr) (*Listener, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
|
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
|
||||||
}
|
}
|
||||||
return &Listener{Listener: ln, addr: a}, nil
|
return wrap(ln, a), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrap(ln net.Listener, a Addr) *Listener {
|
||||||
|
return &Listener{
|
||||||
|
Listener: ln,
|
||||||
|
addr: a,
|
||||||
|
conns: make(chan net.Conn),
|
||||||
|
errc: make(chan error, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listenUnix(path string) (net.Listener, error) {
|
func listenUnix(path string) (net.Listener, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
|
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
|
||||||
@@ -140,6 +141,41 @@ func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A peer that connects and never speaks must not hold the seam. The greeting
|
||||||
|
// it owes is bounded by handshakeTimeout, so serving it on the accept path
|
||||||
|
// costs every later connection those five seconds.
|
||||||
|
func TestTCPSilentPeerDoesNotStallTheSeam(t *testing.T) {
|
||||||
|
ln, addr := listenLoopback(t, "s3cret")
|
||||||
|
defer ln.Close()
|
||||||
|
go echoOnce(ln)
|
||||||
|
|
||||||
|
mute, err := net.Dial("tcp", addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mute dial: %v", err)
|
||||||
|
}
|
||||||
|
defer mute.Close()
|
||||||
|
|
||||||
|
done := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
c, err := Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
done <- "dial: " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
done <- roundTrip(t, c, "still here")
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got != "still here" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
case <-time.After(handshakeTimeout / 2):
|
||||||
|
t.Fatal("a silent peer stalled the listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A tcp seam with no token is a misconfiguration, and it must fail at bind
|
// A tcp seam with no token is a misconfiguration, and it must fail at bind
|
||||||
// rather than serve the owner's turns to anyone who connects.
|
// rather than serve the owner's turns to anyone who connects.
|
||||||
func TestTCPListenRequiresToken(t *testing.T) {
|
func TestTCPListenRequiresToken(t *testing.T) {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,8 +58,16 @@ type credentialAssertion struct {
|
|||||||
|
|
||||||
// RP — the relying party instance. Holds config and transient challenge state.
|
// RP — the relying party instance. Holds config and transient challenge state.
|
||||||
// A single-user daemon has one RP.
|
// A single-user daemon has one RP.
|
||||||
|
//
|
||||||
|
// mavweb serves the four passkey endpoints from its HTTP handlers, so the two
|
||||||
|
// challenge maps are reached concurrently even on a single-user box: a browser
|
||||||
|
// retrying an assertion while another tab begins one is enough. A concurrent
|
||||||
|
// map write is a fatal runtime error, not a recovered panic, so it would take
|
||||||
|
// the whole daemon down from an endpoint that answers before any credential is
|
||||||
|
// proven. Every read and write of regs and asserts is under mu.
|
||||||
type RP struct {
|
type RP struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
|
mu sync.Mutex
|
||||||
regs map[string]*credentialRegistration
|
regs map[string]*credentialRegistration
|
||||||
asserts map[string]*credentialAssertion
|
asserts map[string]*credentialAssertion
|
||||||
challengeTTL time.Duration
|
challengeTTL time.Duration
|
||||||
@@ -75,6 +85,13 @@ func NewRP(cfg Config) *RP {
|
|||||||
|
|
||||||
// CleanExpired removes challenges older than the TTL.
|
// CleanExpired removes challenges older than the TTL.
|
||||||
func (rp *RP) CleanExpired() {
|
func (rp *RP) CleanExpired() {
|
||||||
|
rp.mu.Lock()
|
||||||
|
defer rp.mu.Unlock()
|
||||||
|
rp.cleanExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanExpired is CleanExpired for a caller that already holds mu.
|
||||||
|
func (rp *RP) cleanExpired() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for k, r := range rp.regs {
|
for k, r := range rp.regs {
|
||||||
if now.Sub(r.CreatedAt) > rp.challengeTTL {
|
if now.Sub(r.CreatedAt) > rp.challengeTTL {
|
||||||
@@ -97,12 +114,14 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s
|
|||||||
}
|
}
|
||||||
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
||||||
|
|
||||||
rp.CleanExpired()
|
rp.mu.Lock()
|
||||||
|
rp.cleanExpired()
|
||||||
rp.regs[challengeB64] = &credentialRegistration{
|
rp.regs[challengeB64] = &credentialRegistration{
|
||||||
Challenge: challengeB64,
|
Challenge: challengeB64,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
rp.mu.Unlock()
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"rp": map[string]string{
|
"rp": map[string]string{
|
||||||
@@ -136,12 +155,10 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s
|
|||||||
|
|
||||||
// FinishRegistration parses the browser's response and stores the credential.
|
// FinishRegistration parses the browser's response and stores the credential.
|
||||||
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
|
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
|
||||||
rp.CleanExpired()
|
reg, ok := rp.takeReg(challengeB64)
|
||||||
reg, ok := rp.regs[challengeB64]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
||||||
}
|
}
|
||||||
delete(rp.regs, challengeB64)
|
|
||||||
|
|
||||||
credID := rawString(resp, "id")
|
credID := rawString(resp, "id")
|
||||||
if credID == "" {
|
if credID == "" {
|
||||||
@@ -188,11 +205,13 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
|||||||
}
|
}
|
||||||
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
|
||||||
|
|
||||||
rp.CleanExpired()
|
rp.mu.Lock()
|
||||||
|
rp.cleanExpired()
|
||||||
rp.asserts[challengeB64] = &credentialAssertion{
|
rp.asserts[challengeB64] = &credentialAssertion{
|
||||||
Challenge: challengeB64,
|
Challenge: challengeB64,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
rp.mu.Unlock()
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"challenge": challengeB64,
|
"challenge": challengeB64,
|
||||||
@@ -215,11 +234,9 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
|||||||
// FinishAssertion verifies the browser's assertion response and returns the
|
// FinishAssertion verifies the browser's assertion response and returns the
|
||||||
// verified credential ID.
|
// verified credential ID.
|
||||||
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
|
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
|
||||||
rp.CleanExpired()
|
if !rp.takeAssert(challengeB64) {
|
||||||
if _, ok := rp.asserts[challengeB64]; !ok {
|
|
||||||
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
return "", fmt.Errorf("webauthn: unknown or expired challenge")
|
||||||
}
|
}
|
||||||
delete(rp.asserts, challengeB64)
|
|
||||||
|
|
||||||
credID := rawString(resp, "id")
|
credID := rawString(resp, "id")
|
||||||
if credID == "" {
|
if credID == "" {
|
||||||
@@ -298,6 +315,34 @@ func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCount
|
|||||||
return credID, nil
|
return credID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// takeReg removes and returns the in-flight registration for challengeB64.
|
||||||
|
// Taking under one lock is what makes a challenge single-use: looking it up
|
||||||
|
// and deleting it separately lets two replays of the same response both find
|
||||||
|
// it before either deletes.
|
||||||
|
func (rp *RP) takeReg(challengeB64 string) (*credentialRegistration, bool) {
|
||||||
|
rp.mu.Lock()
|
||||||
|
defer rp.mu.Unlock()
|
||||||
|
rp.cleanExpired()
|
||||||
|
reg, ok := rp.regs[challengeB64]
|
||||||
|
if ok {
|
||||||
|
delete(rp.regs, challengeB64)
|
||||||
|
}
|
||||||
|
return reg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeAssert removes the in-flight assertion for challengeB64 and reports
|
||||||
|
// whether it was there. Single-use for the same reason takeReg is.
|
||||||
|
func (rp *RP) takeAssert(challengeB64 string) bool {
|
||||||
|
rp.mu.Lock()
|
||||||
|
defer rp.mu.Unlock()
|
||||||
|
rp.cleanExpired()
|
||||||
|
if _, ok := rp.asserts[challengeB64]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(rp.asserts, challengeB64)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
|
func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
|
||||||
var cdj struct {
|
var cdj struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -310,7 +355,10 @@ func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChalleng
|
|||||||
if cdj.Type != expectedType {
|
if cdj.Type != expectedType {
|
||||||
return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
|
return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
|
||||||
}
|
}
|
||||||
if cdj.Challenge != expectedChallenge {
|
// Constant time, because the challenge is the one secret in clientDataJSON:
|
||||||
|
// it is 32 bytes of crypto/rand the browser has to echo back, and a
|
||||||
|
// byte-at-a-time compare is the shape that leaks a guessed prefix.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(cdj.Challenge), []byte(expectedChallenge)) != 1 {
|
||||||
return fmt.Errorf("webauthn: challenge mismatch")
|
return fmt.Errorf("webauthn: challenge mismatch")
|
||||||
}
|
}
|
||||||
if cdj.Origin != expectedOrigin {
|
if cdj.Origin != expectedOrigin {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,6 +180,53 @@ func TestRegisterAssertRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The passkey endpoints are HTTP handlers, so two browsers beginning a
|
||||||
|
// challenge at once reach the same RP. Under -race this fails on the bare maps
|
||||||
|
// it used to keep, and in production a concurrent map write is fatal.
|
||||||
|
func TestChallengeMapsAreConcurrencySafe(t *testing.T) {
|
||||||
|
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
||||||
|
lookup := func(string) ([]byte, int64, error) { return nil, 0, nil }
|
||||||
|
upd := func(string, int64) error { return nil }
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 32; j++ {
|
||||||
|
_, chal, err := rp.AssertionOptions()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = rp.FinishAssertion(lookup, upd, chal, map[string]any{})
|
||||||
|
if _, _, err := rp.CreationOptions([]byte("u"), "user"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rp.CleanExpired()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A challenge is single-use: the second presentation of one already spent is
|
||||||
|
// unknown, whichever goroutine gets there first.
|
||||||
|
func TestAssertionChallengeIsSingleUse(t *testing.T) {
|
||||||
|
rp := NewRP(Config{Origin: testOrigin, RPID: testRPID, RPName: "maven"})
|
||||||
|
_, chal, err := rp.AssertionOptions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !rp.takeAssert(chal) {
|
||||||
|
t.Fatal("first take of a fresh challenge failed")
|
||||||
|
}
|
||||||
|
if rp.takeAssert(chal) {
|
||||||
|
t.Fatal("a spent challenge was accepted twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAssertRejectsWrongOrigin — a phished assertion from another origin fails.
|
// TestAssertRejectsWrongOrigin — a phished assertion from another origin fails.
|
||||||
func TestAssertRejectsWrongOrigin(t *testing.T) {
|
func TestAssertRejectsWrongOrigin(t *testing.T) {
|
||||||
err := verifyClientDataBytes(clientData("webauthn.get", "abc", "https://evil.test"), "webauthn.get", "abc", testOrigin)
|
err := verifyClientDataBytes(clientData("webauthn.get", "abc", "https://evil.test"), "webauthn.get", "abc", testOrigin)
|
||||||
|
|||||||
Reference in New Issue
Block a user