diff --git a/client/internal/certproof/pinlatch.go b/client/internal/certproof/pinlatch.go new file mode 100644 index 000000000..1dd7d03d1 --- /dev/null +++ b/client/internal/certproof/pinlatch.go @@ -0,0 +1,47 @@ +package certproof + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "sync" +) + +// errPINRejectedBefore is returned instead of logging in with a PIN the token already +// refused: every failed login counts towards the token's lockout, which for a TPM is +// shared with everything else on the machine, and proofs are collected on every sync. +var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again") + +// rejectedPINs outlives a single store, since a store is built for each collection. +var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}} + +// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not +// kept in memory any longer than the store that read it. +type pinLatch struct { + mu sync.Mutex + keys map[[sha256.Size]byte]struct{} +} + +func (l *pinLatch) has(key [sha256.Size]byte) bool { + l.mu.Lock() + defer l.mu.Unlock() + _, ok := l.keys[key] + return ok +} + +func (l *pinLatch) add(key [sha256.Size]byte) { + l.mu.Lock() + defer l.mu.Unlock() + l.keys[key] = struct{}{} +} + +// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token +// rejected is still tried on the token it belongs to. +func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte { + var buf []byte + for _, part := range [][]byte{[]byte(module), []byte(token), pin} { + buf = binary.BigEndian.AppendUint64(buf, uint64(len(part))) + buf = append(buf, part...) + } + return sha256.Sum256(buf) +} diff --git a/client/internal/certproof/pinlatch_test.go b/client/internal/certproof/pinlatch_test.go new file mode 100644 index 000000000..bf3fa2b31 --- /dev/null +++ b/client/internal/certproof/pinlatch_test.go @@ -0,0 +1,29 @@ +package certproof + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) { + base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234")) + + assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key") + assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried") + assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another") + assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another") + assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")), + "field boundaries are part of the key, so shifted fields do not collide") +} + +func TestPINLatch_RemembersRejectedKeys(t *testing.T) { + latch := &pinLatch{keys: map[[32]byte]struct{}{}} + rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000")) + other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234")) + + assert.False(t, latch.has(rejected), "nothing is rejected before a login fails") + latch.add(rejected) + assert.True(t, latch.has(rejected), "a rejected PIN is not tried again") + assert.False(t, latch.has(other), "other PINs are unaffected") +} diff --git a/client/internal/certproof/pkcs11store.go b/client/internal/certproof/pkcs11store.go index fc5234171..c774fcb2e 100644 --- a/client/internal/certproof/pkcs11store.go +++ b/client/internal/certproof/pkcs11store.go @@ -193,7 +193,20 @@ func (s *PKCS11Store) open() (*pkcs11.Session, error) { if err != nil { return nil, err } - return module.OpenSession(s.uri.Token, pin) + if pin == nil { + return module.OpenSession(s.uri.Token, nil) + } + + key := rejectedPINKey(s.uri.Module(), s.uri.Token, pin) + if rejectedPINs.has(key) { + return nil, errPINRejectedBefore + } + session, err := module.OpenSession(s.uri.Token, pin) + if pkcs11.PINRejected(err) { + rejectedPINs.add(key) + return nil, fmt.Errorf("%s rejected the PIN, not retrying it until the daemon restarts: %w", s, err) + } + return session, err } func (s *PKCS11Store) userPIN() ([]byte, error) { diff --git a/client/internal/pkcs11/pinrejected_test.go b/client/internal/pkcs11/pinrejected_test.go new file mode 100644 index 000000000..6bbfbcae5 --- /dev/null +++ b/client/internal/pkcs11/pinrejected_test.go @@ -0,0 +1,19 @@ +package pkcs11 + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPINRejected(t *testing.T) { + for _, code := range []uint{rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked} { + err := fmt.Errorf("open session: %w", Error{Op: "C_Login", Code: code}) + assert.True(t, PINRejected(err), "CKR 0x%x refuses the PIN, also when wrapped", code) + } + assert.False(t, PINRejected(Error{Op: "C_Login", Code: 0x30}), "a device error says nothing about the PIN") + assert.False(t, PINRejected(errors.New("CKR_PIN_INCORRECT")), "only a PKCS#11 return value counts") + assert.False(t, PINRejected(nil)) +} diff --git a/client/internal/pkcs11/pkcs11.go b/client/internal/pkcs11/pkcs11.go index 8f93da2a3..74903954e 100644 --- a/client/internal/pkcs11/pkcs11.go +++ b/client/internal/pkcs11/pkcs11.go @@ -44,6 +44,11 @@ const ( MGF1SHA384 = 0x3 rvOK = 0x0 + rvPINIncorrect = 0xa0 + rvPINInvalid = 0xa1 + rvPINLenRange = 0xa2 + rvPINExpired = 0xa3 + rvPINLocked = 0xa4 rvUserAlreadyLoggedIn = 0x100 rvAlreadyInitialized = 0x191 ) @@ -63,6 +68,20 @@ func (e Error) Error() string { return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code) } +// PINRejected reports whether err is the token refusing the user PIN. Retrying the same +// PIN cannot succeed, and each attempt counts towards the token's lockout. +func PINRejected(err error) bool { + var e Error + if !errors.As(err, &e) { + return false + } + switch e.Code { + case rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked: + return true + } + return false +} + var returnValueNames = map[uint]string{ 0x2: "CKR_HOST_MEMORY", 0x3: "CKR_SLOT_ID_INVALID", @@ -77,6 +96,9 @@ var returnValueNames = map[uint]string{ 0x71: "CKR_MECHANISM_PARAM_INVALID", 0x82: "CKR_OBJECT_HANDLE_INVALID", 0xa0: "CKR_PIN_INCORRECT", + 0xa1: "CKR_PIN_INVALID", + 0xa2: "CKR_PIN_LEN_RANGE", + 0xa3: "CKR_PIN_EXPIRED", 0xa4: "CKR_PIN_LOCKED", 0xb3: "CKR_SESSION_HANDLE_INVALID", 0xd0: "CKR_TEMPLATE_INCOMPLETE",