From 78e867fe9a9b9facb64d89735026c4265e1d7c95 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 18:35:28 +0000 Subject: [PATCH 01/35] feat: ApplicationInfoV2 with string version --- internal/types.go | 11 +++++++++++ internal/utils.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/types.go b/internal/types.go index 6856b92..e93127b 100644 --- a/internal/types.go +++ b/internal/types.go @@ -20,6 +20,17 @@ type ApplicationInfo struct { KeyUID utils.HexString `json:"keyUID"` } +// ApplicationInfoV2 is the same as ApplicationInfo but with a string version field. +type ApplicationInfoV2 struct { + Initialized bool `json:"initialized"` + InstanceUID utils.HexString `json:"instanceUID"` + Version string `json:"version"` + AvailableSlots int `json:"availableSlots"` + // KeyUID is the sha256 of the master public key on the card. + // It's empty if the card doesn't contain any key. + KeyUID utils.HexString `json:"keyUID"` +} + type KeyPair struct { Address string `json:"address"` PublicKey utils.HexString `json:"publicKey"` diff --git a/internal/utils.go b/internal/utils.go index d86b4b3..abc026d 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -7,6 +7,7 @@ import ( keycard "github.com/status-im/keycard-go" "github.com/status-im/keycard-go/derivationpath" ktypes "github.com/status-im/keycard-go/types" + "fmt" ) func IsSCardError(err error) bool { @@ -57,6 +58,29 @@ func ToAppInfo(r *ktypes.ApplicationInfo) ApplicationInfo { } } +func ParseVersion(input []byte) string { + if len(input) != 2 { + return "unexpected version format" + } + + major := input[0] + minor := input[1] + return fmt.Sprintf("%d.%d", major, minor) +} + +func ToAppInfoV2(r *ktypes.ApplicationInfo) ApplicationInfoV2 { + if r == nil { + return ApplicationInfoV2{} + } + return ApplicationInfoV2{ + Initialized: r.Initialized, + InstanceUID: r.InstanceUID, + Version: ParseVersion(r.Version), + AvailableSlots: BytesToInt(r.AvailableSlots), + KeyUID: r.KeyUID, + } +} + func ToSignature(r *ktypes.Signature) *Signature { return &Signature{ R: r.R(), From 39f6ec7a8b7c166c8b218286ab7720eeb81468c6 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 19:02:27 +0000 Subject: [PATCH 02/35] feat: KeycardContextV2 --- internal/keycard_context_v2.go | 411 +++++++++++++++++++++++++++ internal/keycard_context_v2_state.go | 38 +++ internal/readers_states.go | 50 ++++ 3 files changed, 499 insertions(+) create mode 100644 internal/keycard_context_v2.go create mode 100644 internal/keycard_context_v2_state.go create mode 100644 internal/readers_states.go diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go new file mode 100644 index 0000000..544a3c7 --- /dev/null +++ b/internal/keycard_context_v2.go @@ -0,0 +1,411 @@ +package internal + +import ( + "github.com/ebfe/scard" + "go.uber.org/zap" + "runtime" + "github.com/status-im/keycard-go/io" + "github.com/status-im/keycard-go" + "context" + "github.com/status-im/status-keycard-go/pkg/pairing" + "github.com/status-im/keycard-go/types" + "github.com/pkg/errors" + "github.com/status-im/status-keycard-go/signal" +) + +const infiniteTimeout = -1 +const zeroTimeout = 0 +const pnpNotificationReader = `\\?PnP?\Notification` + +var ( + errKeycardNotConnected = errors.New("keycard not connected") +) + +type KeycardContextV2 struct { + KeycardContext + + shutdown func() + forceScan bool // Needed to distinguish cardCtx.cancel() from a real shutdown + logger *zap.Logger + pairings *pairing.Store + status *Status + activeReader string +} + +func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error) { + pairingsStore, err := pairing.NewStore(pairingsStoreFilePath) + if err != nil { + return nil, errors.Wrap(err, "failed to create pairing store") + } + + kc := &KeycardContextV2{ + KeycardContext: KeycardContext{ + command: make(chan commandType), + }, + logger: zap.L().Named("keycard"), + pairings: pairingsStore, + status: NewStatus(), + } + + err = kc.establishContext() + if err != nil { + kc.logger.Error("failed to establish context", zap.Error(err)) + kc.status.State = NoPCSC + kc.publishStatus() + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + kc.shutdown = cancel + kc.forceScan = false + + go kc.cardCommunicationRoutine(ctx) + kc.monitor() + + return kc, nil +} + +func (kc *KeycardContext) establishContext() error { + cardCtx, err := scard.EstablishContext() + if err != nil { + return errors.New(ErrorPCSC) + } + + kc.cardCtx = cardCtx + return nil +} + +func (kc *KeycardContext) cardCommunicationRoutine(ctx context.Context) { + // Communication with the keycard must be done in a fixed thread + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + for { + select { + case <-ctx.Done(): + return + case cmd := <-kc.command: + switch cmd { + case Transmit: + kc.rpdu, kc.runErr = kc.card.Transmit(kc.apdu) + kc.command <- Ack + case Close: + return + default: + break + } + } + } +} + +func (kc *KeycardContextV2) monitor() { + if kc.cardCtx == nil { + panic("card context is nil") + } + + logger := kc.logger.Named("monitor") + + go func() { + defer logger.Debug("monitor stopped") + // This goroutine will be stopped by cardCtx.Cancel() + for { + finish := kc.monitorRoutine(logger) + if finish { + return + } + } + }() +} + +func (kc *KeycardContextV2) monitorRoutine(logger *zap.Logger) bool { + // Get current readers list and state + readers, err := kc.getCurrentReadersState() + if err != nil { + logger.Error("failed to get readers state", zap.Error(err)) + kc.status.Reset() + kc.status.State = InternalError + kc.publishStatus() + return false + } + + logger.Debug("readers list updated", zap.Any("available", readers)) + + if readers.Empty() { + kc.status.State = WaitingForReader + kc.publishStatus() + } + + err = kc.scanReadersForKeycard(readers) + if err != nil { + logger.Error("failed to check readers", zap.Error(err)) + } + + // Wait for readers changes, including new readers + // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ + // NOTE: The article states that MacOS is not supported, but works for me on MacOS 15.1.1 (24B91). + pnpReader := scard.ReaderState{ + Reader: pnpNotificationReader, + CurrentState: scard.StateUnaware, + } + rs := append(readers, pnpReader) + + err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) + if err == scard.ErrCancelled && !kc.forceScan { + // Shutdown requested + return true + } + if err != scard.ErrCancelled && err != nil { + kc.logger.Error("failed to get status change", zap.Error(err)) + return false + } + + return false +} + +func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { + readers, err := kc.cardCtx.ListReaders() + if err != nil { + return nil, err + } + + rs := make(ReadersStates, len(readers)) + for i, name := range readers { + rs[i].Reader = name + rs[i].CurrentState = scard.StateUnaware + } + + if rs.Empty() { + return rs, nil + } + + err = kc.cardCtx.GetStatusChange(rs, zeroTimeout) + if err != nil { + return nil, err + } + + rs.Update() + + // When removing a reader, a call to `ListReaders` too quick might still return the removed reader. + // So we need to filter out the unknown readers. + knownReaders := make(ReadersStates, 0, len(rs)) + for i := range rs { + if rs[i].EventState&scard.StateUnknown == 0 { + knownReaders.Append(rs[i]) + } + } + + return knownReaders, nil +} + +func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { + if !kc.forceScan && + kc.activeReader != "" && + readers.Contains(kc.activeReader) && + readers.ReaderHasCard(kc.activeReader) { + // active reader is not selected yet or is still present, no need to connect a card + return nil + } + + if readers.Empty() { + return nil + } + + kc.forceScan = false + kc.resetCardConnection(false) + + readerWithCardIndex, ok := readers.ReaderWithCardIndex() + if !ok { + kc.logger.Debug("no card found on any readers") + kc.status.State = WaitingForCard + kc.publishStatus() + return nil + } + + kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) + + err := kc.connectKeycard(readers[readerWithCardIndex].Reader) + if err != nil { + kc.logger.Error("failed to connect keycard", zap.Error(err)) + kc.publishStatus() + return err + } + + kc.publishStatus() + return nil +} + +func (kc *KeycardContextV2) connectKeycard(reader string) error { + card, err := kc.cardCtx.Connect(reader, scard.ShareShared, scard.ProtocolAny) + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to connect to card") + } + + _, err = card.Status() + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to get card status") + } + + kc.activeReader = reader + kc.card = card + kc.c = io.NewNormalChannel(kc) + kc.cmdSet = keycard.NewCommandSet(kc.c) + + // Card connected, now check if this is a keycard + + info, err := kc.SelectApplet() + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to select applet") + } + + kc.logger.Debug("card connected") + kc.status.State = ConnectingCard + kc.publishStatus() + + // + // NOTE: copy of openSC + // + + if !info.Installed { + kc.status.State = NotKeycard + return errors.New("card is not a keycard") + } + + appInfo := ToAppInfoV2(info) + pair := kc.pairings.Get(appInfo.InstanceUID.String()) + + if pair == nil { + kc.logger.Debug("pairing not found, pairing now") + + // + // NOTE: copy of pair + // + var pairingInfo *types.PairingInfo + pairingPassword := DefPairing + pairingInfo, err = kc.Pair(pairingPassword) + if err != nil { + kc.status.State = PairingError + return errors.Wrap(err, "failed to pair keycard") + } + + pair = pairing.ToPairInfo(pairingInfo) + err = kc.pairings.Store(appInfo.InstanceUID.String(), pair) + if err != nil { + kc.status.State = InternalError + return errors.Wrap(err, "failed to store pairing") + } + } + + err = kc.OpenSecureChannel(pair.Index, pair.Key) + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to open secure channel") + } + + appStatus, err := kc.GetStatusApplication() + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to get application status") + } + + kc.status.State = Ready + kc.status.AppInfo = &appInfo + kc.status.AppStatus = appStatus + + return nil +} + +func (kc *KeycardContextV2) resetCardConnection(forceRescan bool) { + kc.logger.Debug("reset card connection") + kc.activeReader = "" + kc.card = nil + kc.c = nil + kc.cmdSet = nil + + // If a command failed, we need to cancel the context. This will force the monitor to reconnect to the card. + if forceRescan { + kc.forceScan = true + err := kc.cardCtx.Cancel() + if err != nil { + kc.logger.Error("failed to cancel context", zap.Error(err)) + } + } +} + +func (kc *KeycardContextV2) publishStatus() { + kc.logger.Info("status changed", zap.Any("status", kc.status)) + signal.Send("status-changed", kc.status) +} + +func (kc *KeycardContextV2) Stop() { + kc.forceScan = true + if kc.cardCtx != nil { + err := kc.cardCtx.Cancel() + if err != nil { + kc.logger.Error("failed to cancel context", zap.Error(err)) + } + } + kc.KeycardContext.Stop() + if kc.shutdown != nil { + kc.shutdown() + } +} + +func (kc *KeycardContextV2) keycardConnected() bool { + return kc.cmdSet != nil +} + +func (kc *KeycardContextV2) checkSCardError(err error, context string) error { + if err == nil { + return nil + } + + if IsSCardError(err) { + kc.logger.Error("command failed, resetting connection", + zap.String("context", context), + zap.Error(err)) + kc.resetCardConnection(true) + } + + return err +} + +func (kc *KeycardContextV2) InitializeKeycard(pin, puk, pairingPassword string) error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + + secrets := keycard.NewSecrets(pin, puk, pairingPassword) + err := kc.cmdSet.Init(secrets) + return kc.checkSCardError(err, "Init") +} + +func (kc *KeycardContextV2) VerifyPIN(pin string) error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + + err := kc.cmdSet.VerifyPIN(pin) + return kc.checkSCardError(err, "VerifyPIN") +} + +func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) { + if !kc.keycardConnected() { + return nil, errKeycardNotConnected + } + + indexes, err := kc.cmdSet.GenerateMnemonic(mnemonicLength / 3) + return indexes, kc.checkSCardError(err, "GenerateMnemonic") +} + +func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]byte, error) { + if !kc.keycardConnected() { + return nil, errKeycardNotConnected + } + + seed := kc.mnemonicToBinarySeed(mnemonic, password) + keyUID, err := kc.loadSeed(seed) + return keyUID, kc.checkSCardError(err, "LoadMnemonic") +} diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go new file mode 100644 index 0000000..99b3c06 --- /dev/null +++ b/internal/keycard_context_v2_state.go @@ -0,0 +1,38 @@ +package internal + +import ( + "github.com/status-im/keycard-go/types" +) + +type State string + +const ( + UnknownReaderState State = "unknown" + NoPCSC State = "no-pcsc" + InternalError State = "internal-error" + WaitingForReader State = "waiting-for-reader" + WaitingForCard State = "waiting-for-card" + ConnectingCard State = "connecting-card" + ConnectionError State = "connection-error" // NOTE: Perhaps a good place for retry + NotKeycard State = "not-keycard" + PairingError State = "pairing-error" + Ready State = "ready" +) + +type Status struct { + State State `json:"state"` + AppInfo *ApplicationInfoV2 `json:"keycardInfo"` + AppStatus *types.ApplicationStatus `json:"keycardStatus"` +} + +func NewStatus() *Status { + status := &Status{} + status.Reset() + return status +} + +func (s *Status) Reset() { + s.State = UnknownReaderState + s.AppInfo = nil + s.AppStatus = nil +} diff --git a/internal/readers_states.go b/internal/readers_states.go new file mode 100644 index 0000000..2799fcb --- /dev/null +++ b/internal/readers_states.go @@ -0,0 +1,50 @@ +package internal + +import "github.com/ebfe/scard" + +type ReadersStates []scard.ReaderState + +func (rs ReadersStates) Contains(reader string) bool { + for _, state := range rs { + if state.Reader == reader { + return true + } + } + return false +} + +func (rs ReadersStates) Update() { + for i := range rs { + rs[i].CurrentState = rs[i].EventState + } +} + +func (rs ReadersStates) ReaderWithCardIndex() (int, bool) { + for i := range rs { + if rs[i].EventState&scard.StatePresent == 0 { + continue + } + + // NOTE: For now we only support one card at a time + return i, true + } + + return -1, false +} + +func (rs *ReadersStates) Append(reader scard.ReaderState) { + *rs = append(*rs, reader) +} + +func (rs ReadersStates) ReaderHasCard(reader string) bool { + for _, state := range rs { + if state.Reader == reader && state.EventState&scard.StatePresent != 0 { + return true + } + } + return false +} + +func (rs ReadersStates) Empty() bool { + return len(rs) == 0 +} From 61a8834f809036f3c86b30683b407e5cb8383828 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 19:04:19 +0000 Subject: [PATCH 03/35] feat: rpc service --- pkg/session/rpc.go | 17 ++++++ pkg/session/service.go | 114 +++++++++++++++++++++++++++++++++++++++++ shared/api_session.go | 68 ++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 pkg/session/rpc.go create mode 100644 pkg/session/service.go create mode 100644 shared/api_session.go diff --git a/pkg/session/rpc.go b/pkg/session/rpc.go new file mode 100644 index 0000000..dddc1ea --- /dev/null +++ b/pkg/session/rpc.go @@ -0,0 +1,17 @@ +package session + +import ( + gorillajson "github.com/gorilla/rpc/json" + "github.com/gorilla/rpc" +) + +var ( + globalKeycardService KeycardService +) + +func CreateRPCServer() (*rpc.Server, error) { + rpcServer := rpc.NewServer() + rpcServer.RegisterCodec(gorillajson.NewCodec(), "application/json") + err := rpcServer.RegisterTCPService(&globalKeycardService, "keycard") + return rpcServer, err +} diff --git a/pkg/session/service.go b/pkg/session/service.go new file mode 100644 index 0000000..fd3a633 --- /dev/null +++ b/pkg/session/service.go @@ -0,0 +1,114 @@ +package session + +import ( + "github.com/status-im/status-keycard-go/internal" + "github.com/pkg/errors" + "github.com/status-im/status-keycard-go/pkg/utils" +) + +var ( + errKeycardServiceNotStarted = errors.New("keycard service not started") +) + +type KeycardService struct { + keycardContext *internal.KeycardContextV2 +} + +type StartRequest struct { + StorageFilePath string `json:"storageFilePath"` +} + +func (s *KeycardService) Start(args *StartRequest, reply *struct{}) error { + var err error + s.keycardContext, err = internal.NewKeycardContextV2(args.StorageFilePath) + return err +} + +func (s *KeycardService) Stop(args *struct{}, reply *struct{}) error { + s.keycardContext.Stop() + s.keycardContext = nil + return nil +} + +type InitializeKeycardRequest struct { + PIN string `json:"pin"` + PUK string `json:"puk"` + PairingPassword string `json:"pairingPassword"` +} + +func (s *KeycardService) InitializeKeycard(args *InitializeKeycardRequest, reply *struct{}) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + if args.PairingPassword == "" { + args.PairingPassword = internal.DefPairing + } + + err := s.keycardContext.InitializeKeycard(args.PIN, args.PUK, args.PairingPassword) + return err +} + +type VerifyPINRequest struct { + PIN string `json:"pin"` +} + +type VerifyPINResponse struct { + PINCorrect bool `json:"pinCorrect"` +} + +func (s *KeycardService) VerifyPIN(args *VerifyPINRequest, reply *VerifyPINResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := s.keycardContext.VerifyPIN(args.PIN) + if err != nil { + return err + } + reply.PINCorrect = true + return nil +} + +type GenerateSeedPhraseRequest struct { + Length int `json:"length"` +} + +type GenerateSeedPhraseResponse struct { + Indexes []int `json:"indexes"` +} + +func (s *KeycardService) GenerateSeedPhrase(args *GenerateSeedPhraseRequest, reply *GenerateSeedPhraseResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + indexes, err := s.keycardContext.GenerateMnemonic(args.Length) + if err != nil { + return err + } + reply.Indexes = indexes + return nil +} + +type LoadMnemonicRequest struct { + Mnemonic string `json:"mnemonic"` + Passphrase string `json:"passphrase"` +} + +type LoadMnemonicResponse struct { + KeyUID string `json:"keyUID"` // WARNING: Is this what's returned? +} + +func (s *KeycardService) LoadMnemonic(args *LoadMnemonicRequest, reply *LoadMnemonicResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + keyUID, err := s.keycardContext.LoadMnemonic(args.Mnemonic, args.Passphrase) + if err != nil { + reply.KeyUID = utils.Btox(keyUID) + } + + return err +} diff --git a/shared/api_session.go b/shared/api_session.go new file mode 100644 index 0000000..9c4d681 --- /dev/null +++ b/shared/api_session.go @@ -0,0 +1,68 @@ +package main + +import "C" +import ( + "github.com/status-im/status-keycard-go/pkg/session" + "go.uber.org/zap" + "net/http/httptest" + "io" + "fmt" + "bytes" + "github.com/gorilla/rpc" +) + +var ( + globalRPCServer *rpc.Server +) + +//export KeycardInitializeRPC +func KeycardInitializeRPC() *C.char { + if err := checkAPIMutualExclusion(sessionAPI); err != nil { + return C.CString(err.Error()) + } + + // TEMP: Replace with logging to a file, take the path as an argument + logger, err := zap.NewDevelopment() + if err != nil { + fmt.Printf("failed to initialize log: %v\n", err) + } + zap.ReplaceGlobals(logger) + + rpcServer, err := session.CreateRPCServer() + if err != nil { + return C.CString(err.Error()) + } + globalRPCServer = rpcServer + logger.Info("RPC server initialized") + return C.CString("") +} + +//export KeycardCallRPC +func KeycardCallRPC(payload *C.char) *C.char { + if globalRPCServer == nil { + return C.CString("RPC server not initialized") + } + + payloadBytes := []byte(C.GoString(payload)) + + // Create a fake HTTP request + req := httptest.NewRequest("POST", "/rpc", bytes.NewBuffer(payloadBytes)) + req.Header.Set("Content-Type", "application/json") + + // Create a fake HTTP response writer + rr := httptest.NewRecorder() + + // Call the server's ServeHTTP method + globalRPCServer.ServeHTTP(rr, req) + + // Read and return the response body + resp := rr.Result() + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return C.CString(fmt.Sprintf("Error reading response: %v", err)) + } + + return C.CString(string(body)) +} From 98723f911c1e69c2fd08fdf3de1dd22d006461af Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 19:04:39 +0000 Subject: [PATCH 04/35] feat: http server for the session API --- cmd/status-keycard-server/main.go | 59 ++++++++ cmd/status-keycard-server/server/server.go | 160 +++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 cmd/status-keycard-server/main.go create mode 100644 cmd/status-keycard-server/server/server.go diff --git a/cmd/status-keycard-server/main.go b/cmd/status-keycard-server/main.go new file mode 100644 index 0000000..3548a37 --- /dev/null +++ b/cmd/status-keycard-server/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "flag" + "os" + "os/signal" + "syscall" + + "github.com/status-im/status-keycard-go/cmd/status-keycard-server/server" + "go.uber.org/zap" + "fmt" + "go.uber.org/zap/zapcore" +) + +var ( + address = flag.String("address", "127.0.0.1:0", "host:port to listen") + rootLogger = zap.NewNop() +) + +func init() { + var err error + config := zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + rootLogger, err = config.Build() + if err != nil { + fmt.Printf("failed to initialize log: %v\n", err) + } + zap.ReplaceGlobals(rootLogger) +} + +func main() { + logger := rootLogger.Named("main") + + flag.Parse() + go handleInterrupts() + + srv := server.NewServer(rootLogger) + srv.Setup() + + err := srv.Listen(*address) + if err != nil { + logger.Error("failed to start server", zap.Error(err)) + return + } + + logger.Info("keycard-server started", zap.String("address", srv.Address())) + srv.Serve() +} + +// handleInterrupts catches interrupt signal (SIGTERM/SIGINT) and +// gracefully logouts and stops the node. +func handleInterrupts() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(ch) + + _ = <-ch + os.Exit(0) +} diff --git a/cmd/status-keycard-server/server/server.go b/cmd/status-keycard-server/server/server.go new file mode 100644 index 0000000..fdbc4d7 --- /dev/null +++ b/cmd/status-keycard-server/server/server.go @@ -0,0 +1,160 @@ +package server + +import ( + "context" + "net" + "net/http" + "strconv" + "sync" + "time" + "github.com/gorilla/websocket" + + "github.com/pkg/errors" + + "github.com/status-im/status-keycard-go/pkg/session" + "go.uber.org/zap" + "github.com/status-im/status-keycard-go/signal" + "os" +) + +type Server struct { + logger *zap.Logger + server *http.Server + listener net.Listener + mux *http.ServeMux + connectionsLock sync.Mutex + connections map[*websocket.Conn]struct{} + address string +} + +func NewServer(logger *zap.Logger) *Server { + return &Server{ + logger: logger.Named("server"), + connections: make(map[*websocket.Conn]struct{}, 1), + } +} + +func (s *Server) Address() string { + return s.address +} + +func (s *Server) Port() (int, error) { + _, portString, err := net.SplitHostPort(s.address) + if err != nil { + return 0, err + } + return strconv.Atoi(portString) +} + +func (s *Server) Setup() { + signal.SetKeycardSignalHandler(s.signalHandler) +} + +func (s *Server) signalHandler(data []byte) { + s.connectionsLock.Lock() + defer s.connectionsLock.Unlock() + + deleteConnection := func(connection *websocket.Conn) { + delete(s.connections, connection) + err := connection.Close() + if err != nil { + s.logger.Error("failed to close connection", zap.Error(err)) + } + } + + for connection := range s.connections { + err := connection.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err != nil { + s.logger.Error("failed to set write deadline", zap.Error(err)) + deleteConnection(connection) + continue + } + + err = connection.WriteMessage(websocket.TextMessage, data) + if err != nil { + s.logger.Error("failed to write signal message", zap.Error(err)) + deleteConnection(connection) + } + } +} + +func (s *Server) Listen(address string) error { + if s.server != nil { + return errors.New("server already started") + } + + _, _, err := net.SplitHostPort(address) + if err != nil { + return errors.Wrap(err, "invalid address") + } + + s.server = &http.Server{ + Addr: address, + ReadHeaderTimeout: 5 * time.Second, + } + + rpcServer, err := session.CreateRPCServer() + if err != nil { + s.logger.Error("failed to create PRC server", zap.Error(err)) + os.Exit(1) + } + + s.mux = http.NewServeMux() + s.mux.HandleFunc("/signals", s.signals) + s.mux.Handle("/rpc", rpcServer) + s.server.Handler = s.mux + + s.listener, err = net.Listen("tcp", address) + if err != nil { + return err + } + + s.address = s.listener.Addr().String() + + return nil +} + +func (s *Server) Serve() { + err := s.server.Serve(s.listener) + if !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("signals server closed with error", zap.Error(err)) + } +} + +func (s *Server) Stop(ctx context.Context) { + for connection := range s.connections { + err := connection.Close() + if err != nil { + s.logger.Error("failed to close connection", zap.Error(err)) + } + delete(s.connections, connection) + } + + err := s.server.Shutdown(ctx) + if err != nil { + s.logger.Error("failed to shutdown signals server", zap.Error(err)) + } + + s.server = nil + s.address = "" +} + +func (s *Server) signals(w http.ResponseWriter, r *http.Request) { + s.connectionsLock.Lock() + defer s.connectionsLock.Unlock() + + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Accepting all requests + }, + } + + connection, err := upgrader.Upgrade(w, r, nil) + if err != nil { + s.logger.Error("failed to upgrade connection", zap.Error(err)) + return + } + s.logger.Debug("new websocket connection") + + s.connections[connection] = struct{}{} +} From 79bc43f51f5bfcd226173bdd1242dad13235c602 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 19:05:00 +0000 Subject: [PATCH 05/35] feat: ensure api mutual exclusivity --- shared/api_flow.go | 4 ++++ shared/main.go | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/shared/api_flow.go b/shared/api_flow.go index 8ce23f0..72338b3 100644 --- a/shared/api_flow.go +++ b/shared/api_flow.go @@ -40,6 +40,10 @@ func jsonToParams(jsonParams *C.char) (flow.FlowParams, error) { //export KeycardInitFlow func KeycardInitFlow(storageDir *C.char) *C.char { + if err := checkAPIMutualExclusion(flowAPI); err != nil { + return retErr(err) + } + var err error globalFlow, err = flow.NewFlow(C.GoString(storageDir)) diff --git a/shared/main.go b/shared/main.go index 38dd16d..4c1f0dc 100644 --- a/shared/main.go +++ b/shared/main.go @@ -1,3 +1,30 @@ package main +import "errors" + func main() {} + +type api int + +const ( + none api = iota + flowAPI + sessionAPI +) + +func checkAPIMutualExclusion(requestedAPI api) error { + switch requestedAPI { + case flowAPI: + if globalRPCServer != nil { + return errors.New("not allowed to start flow API when session API is being used") + } + case sessionAPI: + if globalFlow != nil { + return errors.New("not allowed to start session API when flow API is being used") + } + default: + panic("Unknown API") + } + + return nil +} From c8e2670040a2c69fe287ac4eba0c01164015195c Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 16 Jan 2025 19:46:50 +0000 Subject: [PATCH 06/35] chore: go mod tidy --- go.mod | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go.mod b/go.mod index d5324d9..ca0bdc4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.22 require ( github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 github.com/ethereum/go-ethereum v1.10.26 + github.com/gorilla/rpc v1.2.1 + github.com/gorilla/websocket v1.4.2 + github.com/pkg/errors v0.9.1 github.com/status-im/keycard-go v0.3.3 golang.org/x/crypto v0.1.0 golang.org/x/text v0.4.0 From cf33a32c5c1c45864e751d6aa0db9e70ee7fe6fd Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 17 Jan 2025 10:45:08 +0000 Subject: [PATCH 07/35] fix: marshal c api errors --- shared/api_session.go | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/shared/api_session.go b/shared/api_session.go index 9c4d681..2877eb9 100644 --- a/shared/api_session.go +++ b/shared/api_session.go @@ -2,23 +2,40 @@ package main import "C" import ( - "github.com/status-im/status-keycard-go/pkg/session" - "go.uber.org/zap" - "net/http/httptest" - "io" - "fmt" "bytes" + "encoding/json" + "fmt" + "io" + "net/http/httptest" + "github.com/gorilla/rpc" + "github.com/pkg/errors" + "go.uber.org/zap" + + "github.com/status-im/status-keycard-go/pkg/session" ) var ( globalRPCServer *rpc.Server ) +func marshalError(err error) *C.char { + response := struct { + Error string `json:"error"` + }{ + Error: "", + } + if err != nil { + response.Error = err.Error() + } + responseBytes, _ := json.Marshal(response) + return C.CString(string(responseBytes)) +} + //export KeycardInitializeRPC func KeycardInitializeRPC() *C.char { if err := checkAPIMutualExclusion(sessionAPI); err != nil { - return C.CString(err.Error()) + return marshalError(err) } // TEMP: Replace with logging to a file, take the path as an argument @@ -30,17 +47,17 @@ func KeycardInitializeRPC() *C.char { rpcServer, err := session.CreateRPCServer() if err != nil { - return C.CString(err.Error()) + return marshalError(err) } globalRPCServer = rpcServer logger.Info("RPC server initialized") - return C.CString("") + return marshalError(nil) } //export KeycardCallRPC func KeycardCallRPC(payload *C.char) *C.char { if globalRPCServer == nil { - return C.CString("RPC server not initialized") + return marshalError(errors.New("RPC server not initialized")) } payloadBytes := []byte(C.GoString(payload)) @@ -61,7 +78,7 @@ func KeycardCallRPC(payload *C.char) *C.char { body, err := io.ReadAll(resp.Body) if err != nil { - return C.CString(fmt.Sprintf("Error reading response: %v", err)) + return marshalError(errors.Wrap(err, "internal error reading response body")) } return C.CString(string(body)) From a31e16c9ba5ead2317b47ccdcd2b4b77d9ec90d3 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 17 Jan 2025 10:57:57 +0000 Subject: [PATCH 08/35] feat: added `NoAvailablePairingSlots` state --- internal/keycard_context_v2.go | 22 +++++++++++++++------- internal/keycard_context_v2_state.go | 21 +++++++++++---------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 544a3c7..f6718e0 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -1,15 +1,17 @@ package internal import ( - "github.com/ebfe/scard" - "go.uber.org/zap" + "context" "runtime" - "github.com/status-im/keycard-go/io" + + "github.com/ebfe/scard" + "github.com/pkg/errors" "github.com/status-im/keycard-go" - "context" - "github.com/status-im/status-keycard-go/pkg/pairing" + "github.com/status-im/keycard-go/io" "github.com/status-im/keycard-go/types" - "github.com/pkg/errors" + "go.uber.org/zap" + + "github.com/status-im/status-keycard-go/pkg/pairing" "github.com/status-im/status-keycard-go/signal" ) @@ -274,6 +276,8 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { } appInfo := ToAppInfoV2(info) + kc.status.AppInfo = &appInfo + pair := kc.pairings.Get(appInfo.InstanceUID.String()) if pair == nil { @@ -285,6 +289,11 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { var pairingInfo *types.PairingInfo pairingPassword := DefPairing pairingInfo, err = kc.Pair(pairingPassword) + if errors.Is(err, keycard.ErrNoAvailablePairingSlots) { + kc.status.State = NoAvailablePairingSlots + return err + } + if err != nil { kc.status.State = PairingError return errors.Wrap(err, "failed to pair keycard") @@ -311,7 +320,6 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { } kc.status.State = Ready - kc.status.AppInfo = &appInfo kc.status.AppStatus = appStatus return nil diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index 99b3c06..f2672e6 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -7,16 +7,17 @@ import ( type State string const ( - UnknownReaderState State = "unknown" - NoPCSC State = "no-pcsc" - InternalError State = "internal-error" - WaitingForReader State = "waiting-for-reader" - WaitingForCard State = "waiting-for-card" - ConnectingCard State = "connecting-card" - ConnectionError State = "connection-error" // NOTE: Perhaps a good place for retry - NotKeycard State = "not-keycard" - PairingError State = "pairing-error" - Ready State = "ready" + UnknownReaderState State = "unknown" + NoPCSC State = "no-pcsc" + InternalError State = "internal-error" + WaitingForReader State = "waiting-for-reader" + WaitingForCard State = "waiting-for-card" + ConnectingCard State = "connecting-card" + ConnectionError State = "connection-error" // NOTE: Perhaps a good place for retry + NotKeycard State = "not-keycard" + NoAvailablePairingSlots State = "no-available-pairing-slots" + PairingError State = "pairing-error" + Ready State = "ready" ) type Status struct { From f36505dc7c3ee59ad21c27eb9d2f6560c21e09fb Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 17 Jan 2025 13:07:38 +0000 Subject: [PATCH 09/35] fix: fixed Initialize requset, added validation, added FactoryReset --- go.mod | 4 +++ go.sum | 10 ++++++ internal/keycard_context_v2.go | 53 +++++++++++++++++++++++----- internal/keycard_context_v2_state.go | 2 ++ internal/utils.go | 7 ++-- pkg/session/service.go | 43 ++++++++++++++++++---- 6 files changed, 102 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index ca0bdc4..eaa1614 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22 require ( github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 github.com/ethereum/go-ethereum v1.10.26 + github.com/go-playground/validator/v10 v10.24.0 github.com/gorilla/rpc v1.2.1 github.com/gorilla/websocket v1.4.2 github.com/pkg/errors v0.9.1 @@ -16,6 +17,9 @@ require ( require ( github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-stack/stack v1.8.1 // indirect golang.org/x/sys v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index e626369..2d2311e 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,16 @@ github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY= github.com/ethereum/go-ethereum v1.10.26 h1:i/7d9RBBwiXCEuyduBQzJw/mKmnvzsN14jqBmytw72s= github.com/ethereum/go-ethereum v1.10.26/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index f6718e0..df939db 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -134,6 +134,8 @@ func (kc *KeycardContextV2) monitorRoutine(logger *zap.Logger) bool { if readers.Empty() { kc.status.State = WaitingForReader + kc.status.AppInfo = nil + kc.status.AppStatus = nil kc.publishStatus() } @@ -219,6 +221,8 @@ func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { if !ok { kc.logger.Debug("no card found on any readers") kc.status.State = WaitingForCard + kc.status.AppInfo = nil + kc.status.AppStatus = nil kc.publishStatus() return nil } @@ -262,21 +266,25 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { return errors.Wrap(err, "failed to select applet") } - kc.logger.Debug("card connected") - kc.status.State = ConnectingCard - kc.publishStatus() - // // NOTE: copy of openSC // + appInfo := ToAppInfoV2(info) + kc.status.AppInfo = &appInfo + if !info.Installed { kc.status.State = NotKeycard return errors.New("card is not a keycard") } - appInfo := ToAppInfoV2(info) - kc.status.AppInfo = &appInfo + if !info.Initialized { + kc.status.State = EmptyKeycard + return errors.New("keycard not initialized") + } + + kc.status.State = ConnectingCard + kc.publishStatus() pair := kc.pairings.Get(appInfo.InstanceUID.String()) @@ -348,7 +356,7 @@ func (kc *KeycardContextV2) publishStatus() { } func (kc *KeycardContextV2) Stop() { - kc.forceScan = true + kc.forceScan = false if kc.cardCtx != nil { err := kc.cardCtx.Cancel() if err != nil { @@ -380,14 +388,24 @@ func (kc *KeycardContextV2) checkSCardError(err error, context string) error { return err } -func (kc *KeycardContextV2) InitializeKeycard(pin, puk, pairingPassword string) error { +func (kc *KeycardContextV2) GetStatus() Status { + return *kc.status +} + +func (kc *KeycardContextV2) Initialize(pin, puk, pairingPassword string) error { if !kc.keycardConnected() { return errKeycardNotConnected } secrets := keycard.NewSecrets(pin, puk, pairingPassword) err := kc.cmdSet.Init(secrets) - return kc.checkSCardError(err, "Init") + if err != nil { + return kc.checkSCardError(err, "Init") + } + + // Reset card connection to pair the card and open secure channel + kc.resetCardConnection(true) + return nil } func (kc *KeycardContextV2) VerifyPIN(pin string) error { @@ -417,3 +435,20 @@ func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]by keyUID, err := kc.loadSeed(seed) return keyUID, kc.checkSCardError(err, "LoadMnemonic") } + +func (kc *KeycardContextV2) FactoryReset() error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + + kc.status.Reset() + kc.status.State = FactoryResetting + kc.publishStatus() + + kc.logger.Debug("factory reset") + err := kc.KeycardContext.FactoryReset(true) + + // Reset card connection to read the card data + kc.resetCardConnection(true) + return err +} diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index f2672e6..c2bfb32 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -15,9 +15,11 @@ const ( ConnectingCard State = "connecting-card" ConnectionError State = "connection-error" // NOTE: Perhaps a good place for retry NotKeycard State = "not-keycard" + EmptyKeycard State = "empty-keycard" NoAvailablePairingSlots State = "no-available-pairing-slots" PairingError State = "pairing-error" Ready State = "ready" + FactoryResetting State = "factory-resetting" ) type Status struct { diff --git a/internal/utils.go b/internal/utils.go index abc026d..28bc64b 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -2,12 +2,12 @@ package internal import ( "encoding/binary" + "fmt" "github.com/ebfe/scard" - keycard "github.com/status-im/keycard-go" + "github.com/status-im/keycard-go" "github.com/status-im/keycard-go/derivationpath" ktypes "github.com/status-im/keycard-go/types" - "fmt" ) func IsSCardError(err error) bool { @@ -59,6 +59,9 @@ func ToAppInfo(r *ktypes.ApplicationInfo) ApplicationInfo { } func ParseVersion(input []byte) string { + if len(input) == 0 { + return "" + } if len(input) != 2 { return "unexpected version format" } diff --git a/pkg/session/service.go b/pkg/session/service.go index fd3a633..8e395aa 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -1,8 +1,12 @@ package session import ( - "github.com/status-im/status-keycard-go/internal" + goerrors "errors" + + "github.com/go-playground/validator/v10" "github.com/pkg/errors" + + "github.com/status-im/status-keycard-go/internal" "github.com/status-im/status-keycard-go/pkg/utils" ) @@ -30,22 +34,40 @@ func (s *KeycardService) Stop(args *struct{}, reply *struct{}) error { return nil } -type InitializeKeycardRequest struct { - PIN string `json:"pin"` - PUK string `json:"puk"` +// GetStatus should not be really used, as Status is pushed with `status-changed` signal. +// But it's handy to have for debugging purposes. +func (s *KeycardService) GetStatus(args *struct{}, reply *internal.Status) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + *reply = s.keycardContext.GetStatus() + return nil +} + +type InitializeRequest struct { + PIN string `json:"pin" validate:"required,len=6"` + PUK string `json:"puk" validate:"required,len=12"` PairingPassword string `json:"pairingPassword"` } -func (s *KeycardService) InitializeKeycard(args *InitializeKeycardRequest, reply *struct{}) error { +func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) error { if s.keycardContext == nil { return errKeycardServiceNotStarted } + validate := validator.New() + err := validate.Struct(args) + if err != nil { + errs := err.(validator.ValidationErrors) + return goerrors.Join(errs) + } + if args.PairingPassword == "" { args.PairingPassword = internal.DefPairing } - err := s.keycardContext.InitializeKeycard(args.PIN, args.PUK, args.PairingPassword) + err = s.keycardContext.Initialize(args.PIN, args.PUK, args.PairingPassword) return err } @@ -112,3 +134,12 @@ func (s *KeycardService) LoadMnemonic(args *LoadMnemonicRequest, reply *LoadMnem return err } + +func (s *KeycardService) FactoryReset(args *struct{}, reply *struct{}) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := s.keycardContext.FactoryReset() + return err +} From 008f4f97c78ba303e346d2104040c8970b724370 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 17 Jan 2025 16:24:25 +0000 Subject: [PATCH 10/35] fix: pin and puk operations --- internal/keycard_context_v2.go | 100 ++++++++++++++++++++++++--- internal/keycard_context_v2_state.go | 2 + internal/types.go | 1 + internal/utils.go | 7 +- pkg/session/service.go | 54 +++++++++++++-- 5 files changed, 145 insertions(+), 19 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index df939db..d4f6beb 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -260,7 +260,7 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { // Card connected, now check if this is a keycard - info, err := kc.SelectApplet() + appInfo, err := kc.selectApplet() if err != nil { kc.status.State = ConnectionError return errors.Wrap(err, "failed to select applet") @@ -270,15 +270,14 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { // NOTE: copy of openSC // - appInfo := ToAppInfoV2(info) - kc.status.AppInfo = &appInfo + kc.status.AppInfo = appInfo - if !info.Installed { + if !appInfo.Installed { kc.status.State = NotKeycard return errors.New("card is not a keycard") } - if !info.Initialized { + if !appInfo.Initialized { kc.status.State = EmptyKeycard return errors.New("keycard not initialized") } @@ -313,6 +312,14 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { kc.status.State = InternalError return errors.Wrap(err, "failed to store pairing") } + + // After successful pairing, we should `SelectApplet` again to update the ApplicationInfo + appInfo, err = kc.selectApplet() + if err != nil { + kc.status.State = ConnectionError + return errors.Wrap(err, "failed to select applet") + } + kc.status.AppInfo = appInfo } err = kc.OpenSecureChannel(pair.Index, pair.Key) @@ -321,15 +328,11 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { return errors.Wrap(err, "failed to open secure channel") } - appStatus, err := kc.GetStatusApplication() + err = kc.updateApplicationStatus() if err != nil { - kc.status.State = ConnectionError return errors.Wrap(err, "failed to get application status") } - kc.status.State = Ready - kc.status.AppStatus = appStatus - return nil } @@ -388,6 +391,39 @@ func (kc *KeycardContextV2) checkSCardError(err error, context string) error { return err } +func (kc *KeycardContextV2) selectApplet() (*ApplicationInfoV2, error) { + info, err := kc.SelectApplet() + if err != nil { + kc.status.State = ConnectionError + return nil, err + } + + return ToAppInfoV2(info), err +} + +func (kc *KeycardContextV2) updateApplicationStatus() error { + appStatus, err := kc.cmdSet.GetStatusApplication() + kc.status.AppStatus = appStatus + + if err != nil { + kc.status.State = ConnectionError + return err + } + + kc.status.State = Ready + + if appStatus != nil { + if appStatus.PinRetryCount == 0 { + kc.status.State = BlockedPIN + } + if appStatus.PUKRetryCount == 0 { + kc.status.State = BlockedPUK + } + } + + return nil +} + func (kc *KeycardContextV2) GetStatus() Status { return *kc.status } @@ -413,10 +449,54 @@ func (kc *KeycardContextV2) VerifyPIN(pin string) error { return errKeycardNotConnected } + defer func() { + // Update app status to get the new pin remaining attempts + // Although we can parse the `err` as `keycard.WrongPINError`, it won't work for `err == nil`. + err := kc.updateApplicationStatus() + if err != nil { + kc.logger.Error("failed to update app status after verifying pin") + } + kc.publishStatus() + }() + err := kc.cmdSet.VerifyPIN(pin) return kc.checkSCardError(err, "VerifyPIN") } +func (kc *KeycardContextV2) ChangePIN(pin string) error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + + defer func() { + err := kc.updateApplicationStatus() + if err != nil { + kc.logger.Error("failed to update app status after changing pin") + } + kc.publishStatus() + }() + + err := kc.cmdSet.ChangePIN(pin) + return kc.checkSCardError(err, "ChangePIN") +} + +func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + + defer func() { + err := kc.updateApplicationStatus() + if err != nil { + kc.logger.Error("failed to update app status after unblocking") + } + kc.publishStatus() + }() + + err := kc.cmdSet.UnblockPIN(puk, newPIN) + return kc.checkSCardError(err, "UnblockPIN") +} + func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) { if !kc.keycardConnected() { return nil, errKeycardNotConnected diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index c2bfb32..423d6c1 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -18,6 +18,8 @@ const ( EmptyKeycard State = "empty-keycard" NoAvailablePairingSlots State = "no-available-pairing-slots" PairingError State = "pairing-error" + BlockedPIN State = "blocked-pin" // PIN remaining attempts == 0 + BlockedPUK State = "blocked-puk" // PUK remaining attempts == 0 Ready State = "ready" FactoryResetting State = "factory-resetting" ) diff --git a/internal/types.go b/internal/types.go index e93127b..f5fbc13 100644 --- a/internal/types.go +++ b/internal/types.go @@ -22,6 +22,7 @@ type ApplicationInfo struct { // ApplicationInfoV2 is the same as ApplicationInfo but with a string version field. type ApplicationInfoV2 struct { + Installed bool `json:"installed"` Initialized bool `json:"initialized"` InstanceUID utils.HexString `json:"instanceUID"` Version string `json:"version"` diff --git a/internal/utils.go b/internal/utils.go index 28bc64b..c46eefb 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -71,11 +71,12 @@ func ParseVersion(input []byte) string { return fmt.Sprintf("%d.%d", major, minor) } -func ToAppInfoV2(r *ktypes.ApplicationInfo) ApplicationInfoV2 { +func ToAppInfoV2(r *ktypes.ApplicationInfo) *ApplicationInfoV2 { if r == nil { - return ApplicationInfoV2{} + return nil } - return ApplicationInfoV2{ + return &ApplicationInfoV2{ + Installed: r.Installed, Initialized: r.Initialized, InstanceUID: r.InstanceUID, Version: ParseVersion(r.Version), diff --git a/pkg/session/service.go b/pkg/session/service.go index 8e395aa..2377591 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -12,6 +12,7 @@ import ( var ( errKeycardServiceNotStarted = errors.New("keycard service not started") + validate = validator.New() ) type KeycardService struct { @@ -56,7 +57,6 @@ func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) er return errKeycardServiceNotStarted } - validate := validator.New() err := validate.Struct(args) if err != nil { errs := err.(validator.ValidationErrors) @@ -71,8 +71,33 @@ func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) er return err } +type ChangePINRequest struct { + CurrentPIN string `json:"currentPin" validate:"required,len=6"` + NewPIN string `json:"newPin" validate:"required,len=6"` +} + +func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := validate.Struct(args) + if err != nil { + errs := err.(validator.ValidationErrors) + return goerrors.Join(errs) + } + + err = s.keycardContext.VerifyPIN(args.CurrentPIN) + if err != nil { + return err + } + + err = s.keycardContext.ChangePIN(args.NewPIN) + return err +} + type VerifyPINRequest struct { - PIN string `json:"pin"` + PIN string `json:"pin" validate:"required,len=6"` } type VerifyPINResponse struct { @@ -85,11 +110,28 @@ func (s *KeycardService) VerifyPIN(args *VerifyPINRequest, reply *VerifyPINRespo } err := s.keycardContext.VerifyPIN(args.PIN) + reply.PINCorrect = err == nil + return err +} + +type UnblockRequest struct { + PUK string `json:"puk" validate:"required,len=12"` + NewPIN string `json:"newPin" validate:"required,len=6"` +} + +func (s *KeycardService) Unblock(args *UnblockRequest, reply *struct{}) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := validate.Struct(args) if err != nil { - return err + errs := err.(validator.ValidationErrors) + return goerrors.Join(errs) } - reply.PINCorrect = true - return nil + + err = s.keycardContext.UnblockPIN(args.PUK, args.NewPIN) + return err } type GenerateSeedPhraseRequest struct { @@ -114,7 +156,7 @@ func (s *KeycardService) GenerateSeedPhrase(args *GenerateSeedPhraseRequest, rep } type LoadMnemonicRequest struct { - Mnemonic string `json:"mnemonic"` + Mnemonic string `json:"mnemonic" validate:"required"` Passphrase string `json:"passphrase"` } From dc72e1012564c07082ac57f578e0a302c3d2e221 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 17 Jan 2025 17:56:03 +0000 Subject: [PATCH 11/35] fix: LoadMnemonic, rename GenerateMnemonic --- internal/keycard_context_v2.go | 13 +++++++++---- pkg/session/service.go | 13 ++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index d4f6beb..ee9c2b8 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -21,6 +21,7 @@ const pnpNotificationReader = `\\?PnP?\Notification` var ( errKeycardNotConnected = errors.New("keycard not connected") + errKeycardNotReady = errors.New("keycard not ready") ) type KeycardContextV2 struct { @@ -376,6 +377,10 @@ func (kc *KeycardContextV2) keycardConnected() bool { return kc.cmdSet != nil } +func (kc *KeycardContextV2) keycardReady() bool { + return kc.keycardConnected() && kc.status.State == Ready +} + func (kc *KeycardContextV2) checkSCardError(err error, context string) error { if err == nil { return nil @@ -498,8 +503,8 @@ func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) error { } func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) { - if !kc.keycardConnected() { - return nil, errKeycardNotConnected + if !kc.keycardReady() { + return nil, errKeycardNotReady } indexes, err := kc.cmdSet.GenerateMnemonic(mnemonicLength / 3) @@ -507,8 +512,8 @@ func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) } func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]byte, error) { - if !kc.keycardConnected() { - return nil, errKeycardNotConnected + if !kc.keycardReady() { + return nil, errKeycardNotReady } seed := kc.mnemonicToBinarySeed(mnemonic, password) diff --git a/pkg/session/service.go b/pkg/session/service.go index 2377591..c8675c8 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -134,15 +134,15 @@ func (s *KeycardService) Unblock(args *UnblockRequest, reply *struct{}) error { return err } -type GenerateSeedPhraseRequest struct { +type GenerateMnemonicRequest struct { Length int `json:"length"` } -type GenerateSeedPhraseResponse struct { +type GenerateMnemonicResponse struct { Indexes []int `json:"indexes"` } -func (s *KeycardService) GenerateSeedPhrase(args *GenerateSeedPhraseRequest, reply *GenerateSeedPhraseResponse) error { +func (s *KeycardService) GenerateMnemonic(args *GenerateMnemonicRequest, reply *GenerateMnemonicResponse) error { if s.keycardContext == nil { return errKeycardServiceNotStarted } @@ -169,11 +169,14 @@ func (s *KeycardService) LoadMnemonic(args *LoadMnemonicRequest, reply *LoadMnem return errKeycardServiceNotStarted } - keyUID, err := s.keycardContext.LoadMnemonic(args.Mnemonic, args.Passphrase) + err := validate.Struct(args) if err != nil { - reply.KeyUID = utils.Btox(keyUID) + errs := err.(validator.ValidationErrors) + return goerrors.Join(errs) } + keyUID, err := s.keycardContext.LoadMnemonic(args.Mnemonic, args.Passphrase) + reply.KeyUID = utils.Btox(keyUID) return err } From a937ce7c03b7df742238bc07e635a13e48d650f9 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Sun, 19 Jan 2025 15:02:03 +0100 Subject: [PATCH 12/35] feat: pull states instead of pnp subscribe --- internal/keycard_context_v2.go | 84 +++++++++++++++++++++++++--------- internal/readers_states.go | 9 ++++ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index ee9c2b8..33b7caf 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -3,6 +3,8 @@ package internal import ( "context" "runtime" + "sync/atomic" + "time" "github.com/ebfe/scard" "github.com/pkg/errors" @@ -28,7 +30,7 @@ type KeycardContextV2 struct { KeycardContext shutdown func() - forceScan bool // Needed to distinguish cardCtx.cancel() from a real shutdown + forceScan atomic.Bool // Needed to distinguish cardCtx.cancel() from a real shutdown logger *zap.Logger pairings *pairing.Store status *Status @@ -60,10 +62,10 @@ func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error ctx, cancel := context.WithCancel(context.Background()) kc.shutdown = cancel - kc.forceScan = false + kc.forceScan.Store(false) go kc.cardCommunicationRoutine(ctx) - kc.monitor() + kc.monitor(ctx) return kc, nil } @@ -101,7 +103,7 @@ func (kc *KeycardContext) cardCommunicationRoutine(ctx context.Context) { } } -func (kc *KeycardContextV2) monitor() { +func (kc *KeycardContextV2) monitor(ctx context.Context) { if kc.cardCtx == nil { panic("card context is nil") } @@ -112,7 +114,7 @@ func (kc *KeycardContextV2) monitor() { defer logger.Debug("monitor stopped") // This goroutine will be stopped by cardCtx.Cancel() for { - finish := kc.monitorRoutine(logger) + finish := kc.monitorRoutine(ctx, logger) if finish { return } @@ -120,7 +122,7 @@ func (kc *KeycardContextV2) monitor() { }() } -func (kc *KeycardContextV2) monitorRoutine(logger *zap.Logger) bool { +func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logger) bool { // Get current readers list and state readers, err := kc.getCurrentReadersState() if err != nil { @@ -131,9 +133,7 @@ func (kc *KeycardContextV2) monitorRoutine(logger *zap.Logger) bool { return false } - logger.Debug("readers list updated", zap.Any("available", readers)) - - if readers.Empty() { + if readers.Empty() && kc.status.State != WaitingForReader { kc.status.State = WaitingForReader kc.status.AppInfo = nil kc.status.AppStatus = nil @@ -148,25 +148,65 @@ func (kc *KeycardContextV2) monitorRoutine(logger *zap.Logger) bool { // Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ // NOTE: The article states that MacOS is not supported, but works for me on MacOS 15.1.1 (24B91). - pnpReader := scard.ReaderState{ - Reader: pnpNotificationReader, - CurrentState: scard.StateUnaware, - } - rs := append(readers, pnpReader) - - err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) - if err == scard.ErrCancelled && !kc.forceScan { + //pnpReader := scard.ReaderState{ + // Reader: pnpNotificationReader, + // CurrentState: scard.StateUnaware, + //} + //rs := append(readers, pnpReader) + //err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) + + rs := append(readers) + err = kc.getStatusChange(ctx, rs, infiniteTimeout) + if err == scard.ErrCancelled && !kc.forceScan.Load() { // Shutdown requested return true } if err != scard.ErrCancelled && err != nil { - kc.logger.Error("failed to get status change", zap.Error(err)) + logger.Error("failed to get status change", zap.Error(err)) return false } return false } +func (kc *KeycardContextV2) getStatusChange(ctx context.Context, readersStates ReadersStates, timeout time.Duration) error { + //return kc.cardCtx.GetStatusChange(readersStates, timeout) + + timer := time.NewTimer(timeout) + if timeout < 0 { + timer.Stop() // FIXME: Will it stop, but not tick? + } + + ticker := time.NewTicker(500 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + return nil + case <-timer.C: + return scard.ErrTimeout + case <-ticker.C: + if len(readersStates) == 0 { + return nil + } + if kc.forceScan.Load() { + return scard.ErrCancelled + } + + err := kc.cardCtx.GetStatusChange(readersStates, 100*time.Millisecond) + if err == scard.ErrTimeout { + break + } + if err != nil { + return err + } + if readersStates.HasChanges() { + return nil + } + } + } +} + func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { readers, err := kc.cardCtx.ListReaders() if err != nil { @@ -203,7 +243,7 @@ func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { } func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { - if !kc.forceScan && + if !kc.forceScan.Load() && kc.activeReader != "" && readers.Contains(kc.activeReader) && readers.ReaderHasCard(kc.activeReader) { @@ -215,7 +255,7 @@ func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { return nil } - kc.forceScan = false + kc.forceScan.Store(false) kc.resetCardConnection(false) readerWithCardIndex, ok := readers.ReaderWithCardIndex() @@ -346,7 +386,7 @@ func (kc *KeycardContextV2) resetCardConnection(forceRescan bool) { // If a command failed, we need to cancel the context. This will force the monitor to reconnect to the card. if forceRescan { - kc.forceScan = true + kc.forceScan.Store(true) err := kc.cardCtx.Cancel() if err != nil { kc.logger.Error("failed to cancel context", zap.Error(err)) @@ -360,7 +400,7 @@ func (kc *KeycardContextV2) publishStatus() { } func (kc *KeycardContextV2) Stop() { - kc.forceScan = false + kc.forceScan.Store(false) if kc.cardCtx != nil { err := kc.cardCtx.Cancel() if err != nil { diff --git a/internal/readers_states.go b/internal/readers_states.go index 2799fcb..5c24f1a 100644 --- a/internal/readers_states.go +++ b/internal/readers_states.go @@ -48,3 +48,12 @@ func (rs ReadersStates) ReaderHasCard(reader string) bool { func (rs ReadersStates) Empty() bool { return len(rs) == 0 } + +func (rs ReadersStates) HasChanges() bool { + for _, state := range rs { + if state.EventState&scard.StateChanged != 0 { + return true + } + } + return false +} From 0a11cf2789119be03c59a70dbc9a401f40b7b49c Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 20 Jan 2025 11:59:08 +0100 Subject: [PATCH 13/35] chore: go mod tidy --- go.mod | 10 +++++++--- go.sum | 32 ++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index eaa1614..366b38e 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,9 @@ require ( github.com/gorilla/websocket v1.4.2 github.com/pkg/errors v0.9.1 github.com/status-im/keycard-go v0.3.3 - golang.org/x/crypto v0.1.0 - golang.org/x/text v0.4.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.32.0 + golang.org/x/text v0.21.0 ) require ( @@ -21,5 +22,8 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-stack/stack v1.8.1 // indirect - golang.org/x/sys v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 2d2311e..0c6d458 100644 --- a/go.sum +++ b/go.sum @@ -24,17 +24,33 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k= +github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/status-im/keycard-go v0.3.3 h1:qk/JHSkT9sMka+lVXrTOIVSgHIY7lDm46wrUqTsNa4s= github.com/status-im/keycard-go v0.3.3/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= -golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From ce293c7d482068a4e89b32cbd47745db4a64002c Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 20 Jan 2025 14:00:35 +0100 Subject: [PATCH 14/35] feat: metadata, change puk, refactor --- internal/keycard_context_v2.go | 110 ++++++++++++++++++++------- internal/keycard_context_v2_state.go | 8 +- pkg/session/service.go | 80 ++++++++++++++----- 3 files changed, 147 insertions(+), 51 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 33b7caf..b73b4ff 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -22,8 +22,10 @@ const zeroTimeout = 0 const pnpNotificationReader = `\\?PnP?\Notification` var ( - errKeycardNotConnected = errors.New("keycard not connected") - errKeycardNotReady = errors.New("keycard not ready") + errKeycardNotConnected = errors.New("keycard not connected") + errKeycardNotReady = errors.New("keycard not ready") + errNotKeycard = errors.New("card is not a keycard") + errKeycardNotInitialized = errors.New("keycard not initialized") ) type KeycardContextV2 struct { @@ -127,23 +129,17 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg readers, err := kc.getCurrentReadersState() if err != nil { logger.Error("failed to get readers state", zap.Error(err)) - kc.status.Reset() - kc.status.State = InternalError + kc.status.Reset(InternalError) kc.publishStatus() return false } if readers.Empty() && kc.status.State != WaitingForReader { - kc.status.State = WaitingForReader - kc.status.AppInfo = nil - kc.status.AppStatus = nil + kc.status.Reset(WaitingForReader) kc.publishStatus() } - err = kc.scanReadersForKeycard(readers) - if err != nil { - logger.Error("failed to check readers", zap.Error(err)) - } + kc.scanReadersForKeycard(readers) // Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ @@ -242,17 +238,17 @@ func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { return knownReaders, nil } -func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { +func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { if !kc.forceScan.Load() && kc.activeReader != "" && readers.Contains(kc.activeReader) && readers.ReaderHasCard(kc.activeReader) { // active reader is not selected yet or is still present, no need to connect a card - return nil + return } if readers.Empty() { - return nil + return } kc.forceScan.Store(false) @@ -261,24 +257,19 @@ func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) error { readerWithCardIndex, ok := readers.ReaderWithCardIndex() if !ok { kc.logger.Debug("no card found on any readers") - kc.status.State = WaitingForCard - kc.status.AppInfo = nil - kc.status.AppStatus = nil + kc.status.Reset(WaitingForCard) kc.publishStatus() - return nil + return } kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) err := kc.connectKeycard(readers[readerWithCardIndex].Reader) - if err != nil { + if err != nil && !errors.Is(err, errNotKeycard) && !errors.Is(err, errKeycardNotInitialized) { kc.logger.Error("failed to connect keycard", zap.Error(err)) - kc.publishStatus() - return err } kc.publishStatus() - return nil } func (kc *KeycardContextV2) connectKeycard(reader string) error { @@ -315,12 +306,12 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { if !appInfo.Installed { kc.status.State = NotKeycard - return errors.New("card is not a keycard") + return errNotKeycard } if !appInfo.Initialized { kc.status.State = EmptyKeycard - return errors.New("keycard not initialized") + return errKeycardNotInitialized } kc.status.State = ConnectingCard @@ -374,6 +365,11 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { return errors.Wrap(err, "failed to get application status") } + err = kc.updateMetadata() + if err != nil { + return errors.Wrap(err, "failed to get metadata") + } + return nil } @@ -469,6 +465,16 @@ func (kc *KeycardContextV2) updateApplicationStatus() error { return nil } +func (kc *KeycardContextV2) updateMetadata() error { + metadata, err := kc.GetMetadata() + if err != nil { + return err + } + + kc.status.Metadata = metadata + return nil +} + func (kc *KeycardContextV2) GetStatus() Status { return *kc.status } @@ -542,6 +548,23 @@ func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) error { return kc.checkSCardError(err, "UnblockPIN") } +func (kc *KeycardContextV2) ChangePUK(puk string) error { + if !kc.keycardReady() { + return errKeycardNotReady + } + + defer func() { + err := kc.updateApplicationStatus() + if err != nil { + kc.logger.Error("failed to update app status after changing pin") + } + kc.publishStatus() + }() + + err := kc.cmdSet.ChangePUK(puk) + return kc.checkSCardError(err, "ChangePUK") +} + func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) { if !kc.keycardReady() { return nil, errKeycardNotReady @@ -556,8 +579,19 @@ func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]by return nil, errKeycardNotReady } + var keyUID []byte + var err error + + defer func() { + if err != nil { + return + } + kc.status.AppInfo.KeyUID = keyUID + kc.publishStatus() + }() + seed := kc.mnemonicToBinarySeed(mnemonic, password) - keyUID, err := kc.loadSeed(seed) + keyUID, err = kc.loadSeed(seed) return keyUID, kc.checkSCardError(err, "LoadMnemonic") } @@ -566,8 +600,7 @@ func (kc *KeycardContextV2) FactoryReset() error { return errKeycardNotConnected } - kc.status.Reset() - kc.status.State = FactoryResetting + kc.status.Reset(FactoryResetting) kc.publishStatus() kc.logger.Debug("factory reset") @@ -577,3 +610,26 @@ func (kc *KeycardContextV2) FactoryReset() error { kc.resetCardConnection(true) return err } + +func (kc *KeycardContextV2) GetMetadata() (*Metadata, error) { + if !kc.keycardConnected() { + return nil, errKeycardNotConnected + } + + data, err := kc.cmdSet.GetData(keycard.P1StoreDataPublic) + if err != nil { + return nil, kc.checkSCardError(err, "GetMetadata") + } + + if len(data) == 0 { + return nil, nil + } + + metadata, err := types.ParseMetadata(data) + if err != nil { + return nil, errors.Wrap(err, "failed to parse metadata") + } + + return ToMetadata(metadata), nil +} + diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index 423d6c1..973c4c8 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -28,16 +28,18 @@ type Status struct { State State `json:"state"` AppInfo *ApplicationInfoV2 `json:"keycardInfo"` AppStatus *types.ApplicationStatus `json:"keycardStatus"` + Metadata *Metadata `json:"metadata"` } func NewStatus() *Status { status := &Status{} - status.Reset() + status.Reset(UnknownReaderState) return status } -func (s *Status) Reset() { - s.State = UnknownReaderState +func (s *Status) Reset(newState State) { + s.State = newState s.AppInfo = nil s.AppStatus = nil + s.Metadata = nil } diff --git a/pkg/session/service.go b/pkg/session/service.go index c8675c8..d3e9fee 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -15,6 +15,15 @@ var ( validate = validator.New() ) +func validateRequest(v interface{}) error { + err := validate.Struct(v) + if err != nil { + errs := err.(validator.ValidationErrors) + return goerrors.Join(errs) + } + return nil +} + type KeycardService struct { keycardContext *internal.KeycardContextV2 } @@ -57,10 +66,9 @@ func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) er return errKeycardServiceNotStarted } - err := validate.Struct(args) + err := validateRequest(args) if err != nil { - errs := err.(validator.ValidationErrors) - return goerrors.Join(errs) + return err } if args.PairingPassword == "" { @@ -71,6 +79,24 @@ func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) er return err } +type VerifyPINRequest struct { + PIN string `json:"pin" validate:"required,len=6"` +} + +type VerifyPINResponse struct { + PINCorrect bool `json:"pinCorrect"` +} + +func (s *KeycardService) VerifyPIN(args *VerifyPINRequest, reply *VerifyPINResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := s.keycardContext.VerifyPIN(args.PIN) + reply.PINCorrect = err == nil + return err +} + type ChangePINRequest struct { CurrentPIN string `json:"currentPin" validate:"required,len=6"` NewPIN string `json:"newPin" validate:"required,len=6"` @@ -81,12 +107,12 @@ func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) erro return errKeycardServiceNotStarted } - err := validate.Struct(args) + err := validateRequest(args) if err != nil { - errs := err.(validator.ValidationErrors) - return goerrors.Join(errs) + return err } + // FIXME: Should we Verify the PIN, or client has to call it before? err = s.keycardContext.VerifyPIN(args.CurrentPIN) if err != nil { return err @@ -96,21 +122,21 @@ func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) erro return err } -type VerifyPINRequest struct { - PIN string `json:"pin" validate:"required,len=6"` -} - -type VerifyPINResponse struct { - PINCorrect bool `json:"pinCorrect"` +type ChangePUKRequest struct { + NewPUK string `json:"newPuk" validate:"required,len=12"` } -func (s *KeycardService) VerifyPIN(args *VerifyPINRequest, reply *VerifyPINResponse) error { +func (s *KeycardService) ChangePUK(args *ChangePUKRequest, reply *struct{}) error { if s.keycardContext == nil { return errKeycardServiceNotStarted } - err := s.keycardContext.VerifyPIN(args.PIN) - reply.PINCorrect = err == nil + err := validateRequest(args) + if err != nil { + return err + } + + err = s.keycardContext.ChangePUK(args.NewPUK) return err } @@ -124,10 +150,9 @@ func (s *KeycardService) Unblock(args *UnblockRequest, reply *struct{}) error { return errKeycardServiceNotStarted } - err := validate.Struct(args) + err := validateRequest(args) if err != nil { - errs := err.(validator.ValidationErrors) - return goerrors.Join(errs) + return err } err = s.keycardContext.UnblockPIN(args.PUK, args.NewPIN) @@ -169,10 +194,9 @@ func (s *KeycardService) LoadMnemonic(args *LoadMnemonicRequest, reply *LoadMnem return errKeycardServiceNotStarted } - err := validate.Struct(args) + err := validateRequest(args) if err != nil { - errs := err.(validator.ValidationErrors) - return goerrors.Join(errs) + return err } keyUID, err := s.keycardContext.LoadMnemonic(args.Mnemonic, args.Passphrase) @@ -188,3 +212,17 @@ func (s *KeycardService) FactoryReset(args *struct{}, reply *struct{}) error { err := s.keycardContext.FactoryReset() return err } + +type GetMetadataResponse struct { + Metadata *internal.Metadata `json:"metadata"` +} + +func (s *KeycardService) GetMetadata(args *struct{}, reply *GetMetadataResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + metadata, err := s.keycardContext.GetMetadata() + reply.Metadata = metadata + return err +} From 65a2c97f30daeb7b6f427b47d6ba6e6c1506ec5c Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 20 Jan 2025 16:57:33 +0100 Subject: [PATCH 15/35] feat: added versionRaw for simpler comparison --- internal/keycard_context_v2_state.go | 6 +++++- internal/types.go | 1 + internal/utils.go | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index 973c4c8..de9cb71 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -13,7 +13,7 @@ const ( WaitingForReader State = "waiting-for-reader" WaitingForCard State = "waiting-for-card" ConnectingCard State = "connecting-card" - ConnectionError State = "connection-error" // NOTE: Perhaps a good place for retry + ConnectionError State = "connection-error" NotKeycard State = "not-keycard" EmptyKeycard State = "empty-keycard" NoAvailablePairingSlots State = "no-available-pairing-slots" @@ -43,3 +43,7 @@ func (s *Status) Reset(newState State) { s.AppStatus = nil s.Metadata = nil } + +func (s *Status) KeycardSupportsExtendedKeys() bool { + return s.AppInfo != nil && s.AppInfo.versionRaw >= 0x0310 +} diff --git a/internal/types.go b/internal/types.go index f5fbc13..4e87b20 100644 --- a/internal/types.go +++ b/internal/types.go @@ -25,6 +25,7 @@ type ApplicationInfoV2 struct { Installed bool `json:"installed"` Initialized bool `json:"initialized"` InstanceUID utils.HexString `json:"instanceUID"` + versionRaw int `json:"-"` Version string `json:"version"` AvailableSlots int `json:"availableSlots"` // KeyUID is the sha256 of the master public key on the card. diff --git a/internal/utils.go b/internal/utils.go index c46eefb..bcd0fba 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -79,6 +79,7 @@ func ToAppInfoV2(r *ktypes.ApplicationInfo) *ApplicationInfoV2 { Installed: r.Installed, Initialized: r.Initialized, InstanceUID: r.InstanceUID, + versionRaw: BytesToInt(r.Version), Version: ParseVersion(r.Version), AvailableSlots: BytesToInt(r.AvailableSlots), KeyUID: r.KeyUID, From 2925ae26f5dcba6cf264bedbe4080c70da6701f1 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 20 Jan 2025 16:57:50 +0100 Subject: [PATCH 16/35] feat: ExportLoginKeys, ExportRecoverKeys --- internal/keycard_context_v2.go | 100 +++++++++++++++++++++++++++++++++ internal/types.go | 13 +++++ pkg/session/service.go | 31 +++++++++- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index b73b4ff..2896bbc 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -7,6 +7,7 @@ import ( "time" "github.com/ebfe/scard" + "github.com/ethereum/go-ethereum/crypto" "github.com/pkg/errors" "github.com/status-im/keycard-go" "github.com/status-im/keycard-go/io" @@ -633,3 +634,102 @@ func (kc *KeycardContextV2) GetMetadata() (*Metadata, error) { return ToMetadata(metadata), nil } +func (kc *KeycardContextV2) exportedKeyToAddress(key *types.ExportedKey) (string, error) { + if key.PubKey() == nil { + return "", nil + } + + ecdsaPubKey, err := crypto.UnmarshalPubkey(key.PubKey()) + if err != nil { + return "", errors.Wrap(err, "failed to unmarshal public key") + } + + return crypto.PubkeyToAddress(*ecdsaPubKey).Hex(), nil +} + +func (kc *KeycardContextV2) exportKey(path string, exportOption uint8) (*KeyPair, error) { + // 1. As for today, it's pointless to use the 'current path' feature. So we always derive. + // 2. We keep this workaround for `makeCurrent` to mitigate a bug in an older version of the Keycard applet + // that doesn't correctly export the public key for the master path unless it is also the current path. + const derive = true + makeCurrent := path == MasterPath + + exportedKey, err := kc.cmdSet.ExportKeyExtended(derive, makeCurrent, exportOption, path) + if err != nil { + return nil, kc.checkSCardError(err, "ExportKeyExtended") + } + + address, err := kc.exportedKeyToAddress(exportedKey) + if err != nil { + return nil, errors.Wrap(err, "failed to convert key to address") + } + + return &KeyPair{ + Address: address, + PublicKey: exportedKey.PubKey(), + PrivateKey: exportedKey.PrivKey(), + ChainCode: exportedKey.ChainCode(), + }, nil +} + +func (kc *KeycardContextV2) ExportLoginKeys() (*LoginKeys, error) { + if !kc.keycardReady() { + return nil, errKeycardNotReady + } + + var err error + keys := &LoginKeys{} + + keys.EncryptionPrivateKey, err = kc.exportKey(EncryptionPath, keycard.P2ExportKeyPrivateAndPublic) + if err != nil { + return nil, err + } + + keys.WhisperPrivateKey, err = kc.exportKey(WhisperPath, keycard.P2ExportKeyPrivateAndPublic) + if err != nil { + return nil, err + } + + return keys, err +} + +func (kc *KeycardContextV2) ExportRecoverKeys() (*RecoverKeys, error) { + if !kc.keycardReady() { + return nil, errKeycardNotReady + } + + loginKeys, err := kc.ExportLoginKeys() + if err != nil { + return nil, err + } + + keys := &RecoverKeys{ + LoginKeys: *loginKeys, + } + + keys.EIP1581key, err = kc.exportKey(Eip1581Path, keycard.P2ExportKeyPublicOnly) + if err != nil { + return nil, err + } + + rootExportOptions := map[bool]uint8{ + true: keycard.P2ExportKeyExtendedPublic, + false: keycard.P2ExportKeyPublicOnly, + } + keys.WalletRootKey, err = kc.exportKey(WalletRoothPath, rootExportOptions[kc.status.KeycardSupportsExtendedKeys()]) + if err != nil { + return nil, err + } + + keys.WalletKey, err = kc.exportKey(WalletPath, keycard.P2ExportKeyPublicOnly) + if err != nil { + return nil, err + } + + keys.MasterKey, err = kc.exportKey(MasterPath, keycard.P2ExportKeyPublicOnly) + if err != nil { + return nil, err + } + + return keys, err +} diff --git a/internal/types.go b/internal/types.go index 4e87b20..9e8833b 100644 --- a/internal/types.go +++ b/internal/types.go @@ -50,3 +50,16 @@ type Metadata struct { Name string `json:"name"` Wallets []Wallet `json:"wallets"` } + +type LoginKeys struct { + EncryptionPrivateKey *KeyPair `json:"encryptionPrivateKey"` + WhisperPrivateKey *KeyPair `json:"whisperPrivateKey"` +} + +type RecoverKeys struct { + LoginKeys + EIP1581key *KeyPair `json:"eip1581"` + WalletRootKey *KeyPair `json:"walletRootKey"` + WalletKey *KeyPair `json:"walletKey"` + MasterKey *KeyPair `json:"masterKey"` +} diff --git a/pkg/session/service.go b/pkg/session/service.go index d3e9fee..26829ba 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -221,8 +221,35 @@ func (s *KeycardService) GetMetadata(args *struct{}, reply *GetMetadataResponse) if s.keycardContext == nil { return errKeycardServiceNotStarted } + var err error + reply.Metadata, err = s.keycardContext.GetMetadata() + return err +} + +type ExportLoginKeysResponse struct { + Keys *internal.LoginKeys `json:"keys"` +} + +func (s *KeycardService) ExportLoginKeys(args *struct{}, reply *ExportLoginKeysResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + var err error + reply.Keys, err = s.keycardContext.ExportLoginKeys() + return err +} + +type ExportRecoveredKeysResponse struct { + Keys *internal.RecoverKeys `json:"keys"` +} - metadata, err := s.keycardContext.GetMetadata() - reply.Metadata = metadata +func (s *KeycardService) ExportRecoverKeys(args *struct{}, reply *ExportRecoveredKeysResponse) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + var err error + reply.Keys, err = s.keycardContext.ExportRecoverKeys() return err } From 8615a1ddd9da7cffc82b71692bdb944069806978 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 20 Jan 2025 17:47:00 +0100 Subject: [PATCH 17/35] feat: Authorized state, fixed stopping service --- internal/keycard_context_v2.go | 73 +++++++++++++++++++--------- internal/keycard_context_v2_state.go | 1 + pkg/session/service.go | 22 ++++----- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 2896bbc..1ff34f3 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -23,9 +23,10 @@ const zeroTimeout = 0 const pnpNotificationReader = `\\?PnP?\Notification` var ( + errNotKeycard = errors.New("card is not a keycard") errKeycardNotConnected = errors.New("keycard not connected") errKeycardNotReady = errors.New("keycard not ready") - errNotKeycard = errors.New("card is not a keycard") + errKeycardNotAuthorized = errors.New("keycard not authorized") errKeycardNotInitialized = errors.New("keycard not initialized") ) @@ -152,7 +153,7 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg //rs := append(readers, pnpReader) //err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) - rs := append(readers) + rs := readers err = kc.getStatusChange(ctx, rs, infiniteTimeout) if err == scard.ErrCancelled && !kc.forceScan.Load() { // Shutdown requested @@ -166,21 +167,25 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg return false } -func (kc *KeycardContextV2) getStatusChange(ctx context.Context, readersStates ReadersStates, timeout time.Duration) error { - //return kc.cardCtx.GetStatusChange(readersStates, timeout) - - timer := time.NewTimer(timeout) +func (kc *KeycardContextV2) createTimer(timeout time.Duration) <-chan time.Time { if timeout < 0 { - timer.Stop() // FIXME: Will it stop, but not tick? + return nil } + return time.After(timeout) +} + +func (kc *KeycardContextV2) getStatusChange(ctx context.Context, readersStates ReadersStates, timeout time.Duration) error { + //return kc.cardCtx.GetStatusChange(readersStates, timeout) + + timer := kc.createTimer(timeout) ticker := time.NewTicker(500 * time.Millisecond) for { select { case <-ctx.Done(): - return nil - case <-timer.C: + return scard.ErrCancelled + case <-timer: return scard.ErrTimeout case <-ticker.C: if len(readersStates) == 0 { @@ -414,8 +419,24 @@ func (kc *KeycardContextV2) keycardConnected() bool { return kc.cmdSet != nil } -func (kc *KeycardContextV2) keycardReady() bool { - return kc.keycardConnected() && kc.status.State == Ready +func (kc *KeycardContextV2) keycardReady() error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + if kc.status.State != Ready && kc.status.State != Authorized { + return errKeycardNotReady + } + return nil +} + +func (kc *KeycardContextV2) keycardAuthorized() error { + if !kc.keycardConnected() { + return errKeycardNotConnected + } + if kc.status.State != Authorized { + return errKeycardNotAuthorized + } + return nil } func (kc *KeycardContextV2) checkSCardError(err error, context string) error { @@ -501,6 +522,8 @@ func (kc *KeycardContextV2) VerifyPIN(pin string) error { return errKeycardNotConnected } + authorized := false + defer func() { // Update app status to get the new pin remaining attempts // Although we can parse the `err` as `keycard.WrongPINError`, it won't work for `err == nil`. @@ -508,16 +531,20 @@ func (kc *KeycardContextV2) VerifyPIN(pin string) error { if err != nil { kc.logger.Error("failed to update app status after verifying pin") } + if kc.status.State == Ready && authorized { + kc.status.State = Authorized + } kc.publishStatus() }() err := kc.cmdSet.VerifyPIN(pin) + authorized = err == nil return kc.checkSCardError(err, "VerifyPIN") } func (kc *KeycardContextV2) ChangePIN(pin string) error { - if !kc.keycardConnected() { - return errKeycardNotConnected + if err := kc.keycardAuthorized(); err != nil { + return err } defer func() { @@ -550,8 +577,8 @@ func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) error { } func (kc *KeycardContextV2) ChangePUK(puk string) error { - if !kc.keycardReady() { - return errKeycardNotReady + if err := kc.keycardAuthorized(); err != nil { + return err } defer func() { @@ -567,8 +594,8 @@ func (kc *KeycardContextV2) ChangePUK(puk string) error { } func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) { - if !kc.keycardReady() { - return nil, errKeycardNotReady + if err := kc.keycardReady(); err != nil { + return nil, err } indexes, err := kc.cmdSet.GenerateMnemonic(mnemonicLength / 3) @@ -576,8 +603,8 @@ func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) } func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]byte, error) { - if !kc.keycardReady() { - return nil, errKeycardNotReady + if err := kc.keycardAuthorized(); err != nil { + return nil, err } var keyUID []byte @@ -673,8 +700,8 @@ func (kc *KeycardContextV2) exportKey(path string, exportOption uint8) (*KeyPair } func (kc *KeycardContextV2) ExportLoginKeys() (*LoginKeys, error) { - if !kc.keycardReady() { - return nil, errKeycardNotReady + if err := kc.keycardAuthorized(); err != nil { + return nil, err } var err error @@ -694,8 +721,8 @@ func (kc *KeycardContextV2) ExportLoginKeys() (*LoginKeys, error) { } func (kc *KeycardContextV2) ExportRecoverKeys() (*RecoverKeys, error) { - if !kc.keycardReady() { - return nil, errKeycardNotReady + if err := kc.keycardAuthorized(); err != nil { + return nil, err } loginKeys, err := kc.ExportLoginKeys() diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index de9cb71..c0736b0 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -21,6 +21,7 @@ const ( BlockedPIN State = "blocked-pin" // PIN remaining attempts == 0 BlockedPUK State = "blocked-puk" // PUK remaining attempts == 0 Ready State = "ready" + Authorized State = "authorized" // PIN verified FactoryResetting State = "factory-resetting" ) diff --git a/pkg/session/service.go b/pkg/session/service.go index 26829ba..8f6e3ea 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -39,6 +39,9 @@ func (s *KeycardService) Start(args *StartRequest, reply *struct{}) error { } func (s *KeycardService) Stop(args *struct{}, reply *struct{}) error { + if s.keycardContext == nil { + return nil + } s.keycardContext.Stop() s.keycardContext = nil return nil @@ -79,27 +82,26 @@ func (s *KeycardService) Initialize(args *InitializeRequest, reply *struct{}) er return err } -type VerifyPINRequest struct { +type AuthorizeRequest struct { PIN string `json:"pin" validate:"required,len=6"` } -type VerifyPINResponse struct { - PINCorrect bool `json:"pinCorrect"` +type AuthorizeResponse struct { + Authorized bool `json:"authorized"` } -func (s *KeycardService) VerifyPIN(args *VerifyPINRequest, reply *VerifyPINResponse) error { +func (s *KeycardService) Authorize(args *AuthorizeRequest, reply *AuthorizeResponse) error { if s.keycardContext == nil { return errKeycardServiceNotStarted } err := s.keycardContext.VerifyPIN(args.PIN) - reply.PINCorrect = err == nil + reply.Authorized = err == nil return err } type ChangePINRequest struct { - CurrentPIN string `json:"currentPin" validate:"required,len=6"` - NewPIN string `json:"newPin" validate:"required,len=6"` + NewPIN string `json:"newPin" validate:"required,len=6"` } func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) error { @@ -112,12 +114,6 @@ func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) erro return err } - // FIXME: Should we Verify the PIN, or client has to call it before? - err = s.keycardContext.VerifyPIN(args.CurrentPIN) - if err != nil { - return err - } - err = s.keycardContext.ChangePIN(args.NewPIN) return err } From a3804cc8848a93a277895e508dd7c423f1f8338c Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Tue, 21 Jan 2025 21:21:17 +0100 Subject: [PATCH 18/35] WIP --- internal/keycard_context_v2.go | 157 +++++++++++++++++---------- internal/keycard_context_v2_state.go | 27 +++++ internal/readers_states.go | 8 ++ pkg/session/service.go | 42 ++++++- 4 files changed, 174 insertions(+), 60 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 1ff34f3..aac5a6e 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -18,9 +18,11 @@ import ( "github.com/status-im/status-keycard-go/signal" ) -const infiniteTimeout = -1 -const zeroTimeout = 0 -const pnpNotificationReader = `\\?PnP?\Notification` +const ( + infiniteTimeout = -1 + zeroTimeout = 0 + monitoringTick = 500 * time.Millisecond +) var ( errNotKeycard = errors.New("card is not a keycard") @@ -39,6 +41,10 @@ type KeycardContextV2 struct { pairings *pairing.Store status *Status activeReader string + + // simulation options + simulatedError error + simulationInstanceUID string } func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error) { @@ -56,12 +62,17 @@ func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error status: NewStatus(), } - err = kc.establishContext() + return kc, nil +} + +func (kc *KeycardContextV2) Start() error { + err := kc.establishContext() + err = kc.simulateError(err, simulatedNoPCSC) if err != nil { kc.logger.Error("failed to establish context", zap.Error(err)) kc.status.State = NoPCSC kc.publishStatus() - return nil, err + return err } ctx, cancel := context.WithCancel(context.Background()) @@ -71,7 +82,7 @@ func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error go kc.cardCommunicationRoutine(ctx) kc.monitor(ctx) - return kc, nil + return nil } func (kc *KeycardContext) establishContext() error { @@ -118,21 +129,44 @@ func (kc *KeycardContextV2) monitor(ctx context.Context) { defer logger.Debug("monitor stopped") // This goroutine will be stopped by cardCtx.Cancel() for { - finish := kc.monitorRoutine(ctx, logger) - if finish { + ok := kc.monitorRoutine(ctx, logger) + if !ok { return } } }() } +// monitorRoutine is the main routine that monitors the card readers and card changes. +// It will be stopped by cardCtx.Cancel() or when the context is done. +// Returns false if the monitoring should be stopped by the runner. func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logger) bool { + logger.Debug("tick") + + /* + Limitations: + - Only support 1 card at a time. Even if it's not a keycard, or connection failed. + - Limited support for multiple readers. Only the first found card is considered. + NOTE: Does it make senes to support multiple readers, if we can only connect to one card? + + 1. readers := ListReaders() + 2. states := GetStatusChange(readers) + 3. if states didn't change since last read, wait 500ms and go to (1) + 4. if a card is present, connect to it + 5. if connection failed, FIXME: + 6. if not a keycard, WARNING: + 7. if not initialized, WARNING: + 8. listen only to activeReader changes + */ + // Get current readers list and state readers, err := kc.getCurrentReadersState() if err != nil { logger.Error("failed to get readers state", zap.Error(err)) kc.status.Reset(InternalError) kc.publishStatus() + + // FIXME: wait 500 milliseconds return false } @@ -143,9 +177,13 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg kc.scanReadersForKeycard(readers) - // Wait for readers changes, including new readers + // NOTE: There're 2 approaches to wait for readers changes: + // 1. Periodically call `ListReaders` and compare the list of readers. + // 2. Use `GetStatusChange` with a list of readers and a `PnP` reader. + //Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ // NOTE: The article states that MacOS is not supported, but works for me on MacOS 15.1.1 (24B91). + //pnpNotificationReader := `\\?PnP?\Notification` //pnpReader := scard.ReaderState{ // Reader: pnpNotificationReader, // CurrentState: scard.StateUnaware, @@ -153,64 +191,41 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg //rs := append(readers, pnpReader) //err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) - rs := readers - err = kc.getStatusChange(ctx, rs, infiniteTimeout) + err = kc.getActiveReaderChange(ctx, readers) + if err == scard.ErrCancelled && !kc.forceScan.Load() { // Shutdown requested - return true + return false } + if err != scard.ErrCancelled && err != nil { logger.Error("failed to get status change", zap.Error(err)) return false } - return false -} - -func (kc *KeycardContextV2) createTimer(timeout time.Duration) <-chan time.Time { - if timeout < 0 { - return nil - } - - return time.After(timeout) + return true } -func (kc *KeycardContextV2) getStatusChange(ctx context.Context, readersStates ReadersStates, timeout time.Duration) error { - //return kc.cardCtx.GetStatusChange(readersStates, timeout) - - timer := kc.createTimer(timeout) - ticker := time.NewTicker(500 * time.Millisecond) - - for { +func (kc *KeycardContextV2) getActiveReaderChange(ctx context.Context, readersStates ReadersStates) error { + if len(readersStates) == 0 { select { case <-ctx.Done(): - return scard.ErrCancelled - case <-timer: - return scard.ErrTimeout - case <-ticker.C: - if len(readersStates) == 0 { - return nil - } - if kc.forceScan.Load() { - return scard.ErrCancelled - } - - err := kc.cardCtx.GetStatusChange(readersStates, 100*time.Millisecond) - if err == scard.ErrTimeout { - break - } - if err != nil { - return err - } - if readersStates.HasChanges() { - return nil - } + case <-time.After(monitoringTick): // Pause for a while to avoid a busy loop } + return nil } + + err := kc.cardCtx.GetStatusChange(readersStates, monitoringTick) + if err != nil && err != scard.ErrTimeout { + return err + } + + return nil } func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { readers, err := kc.cardCtx.ListReaders() + err = kc.simulateError(err, simulatedListReadersError) if err != nil { return nil, err } @@ -226,6 +241,7 @@ func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { } err = kc.cardCtx.GetStatusChange(rs, zeroTimeout) + err = kc.simulateError(err, simulatedGetStatusChangeError) if err != nil { return nil, err } @@ -262,6 +278,9 @@ func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { readerWithCardIndex, ok := readers.ReaderWithCardIndex() if !ok { + if kc.status.State == WaitingForCard { + return + } kc.logger.Debug("no card found on any readers") kc.status.Reset(WaitingForCard) kc.publishStatus() @@ -280,12 +299,14 @@ func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { func (kc *KeycardContextV2) connectKeycard(reader string) error { card, err := kc.cardCtx.Connect(reader, scard.ShareShared, scard.ProtocolAny) + err = kc.simulateError(err, simulatedCardConnectError) if err != nil { kc.status.State = ConnectionError return errors.Wrap(err, "failed to connect to card") } _, err = card.Status() + err = kc.simulateError(err, simulatedGetCardStatusError) if err != nil { kc.status.State = ConnectionError return errors.Wrap(err, "failed to get card status") @@ -299,20 +320,25 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { // Card connected, now check if this is a keycard appInfo, err := kc.selectApplet() + err = kc.simulateError(err, simulatedSelectAppletError) if err != nil { kc.status.State = ConnectionError return errors.Wrap(err, "failed to select applet") } - // - // NOTE: copy of openSC - // + // Check if 'not a keycard' simulation was requested for this card + simulatedError := kc.simulateError(nil, simulatedNotAKeycard) + keycardMatch := kc.simulationInstanceUID == appInfo.InstanceUID.String() + if simulatedError != nil && keycardMatch { + appInfo.Installed = false + } + // Save AppInfo kc.status.AppInfo = appInfo if !appInfo.Installed { kc.status.State = NotKeycard - return errNotKeycard + return kc.simulateError(errNotKeycard, simulatedNotAKeycard) } if !appInfo.Initialized { @@ -328,9 +354,6 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { if pair == nil { kc.logger.Debug("pairing not found, pairing now") - // - // NOTE: copy of pair - // var pairingInfo *types.PairingInfo pairingPassword := DefPairing pairingInfo, err = kc.Pair(pairingPassword) @@ -361,6 +384,7 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { } err = kc.OpenSecureChannel(pair.Index, pair.Key) + err = kc.simulateError(err, simulatedOpenSecureChannelError) if err != nil { kc.status.State = ConnectionError return errors.Wrap(err, "failed to open secure channel") @@ -380,7 +404,6 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { } func (kc *KeycardContextV2) resetCardConnection(forceRescan bool) { - kc.logger.Debug("reset card connection") kc.activeReader = "" kc.card = nil kc.c = nil @@ -760,3 +783,23 @@ func (kc *KeycardContextV2) ExportRecoverKeys() (*RecoverKeys, error) { return keys, err } + +func (kc *KeycardContextV2) SimulateError(err error, instanceUID string) error { + // Ensure the error is one of the known errors to simulate + if err != nil { + if simulateErr := GetSimulatedError(err.Error()); simulateErr == nil { + return errors.New("unknown error to simulate") + } + } + + kc.simulatedError = err + kc.simulationInstanceUID = instanceUID + return nil +} + +func (kc *KeycardContextV2) simulateError(currentError, errorToSimulate error) error { + if errors.Is(kc.simulatedError, errorToSimulate) { + return errorToSimulate + } + return currentError +} diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index c0736b0..5693860 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -1,6 +1,8 @@ package internal import ( + "errors" + "github.com/status-im/keycard-go/types" ) @@ -48,3 +50,28 @@ func (s *Status) Reset(newState State) { func (s *Status) KeycardSupportsExtendedKeys() bool { return s.AppInfo != nil && s.AppInfo.versionRaw >= 0x0310 } + +var ( + simulatedNoPCSC = errors.New("simulated-no-pcsc") + simulatedListReadersError = errors.New("simulated-list-readers-error") + simulatedGetStatusChangeError = errors.New("simulated-get-status-change-error") + simulatedCardConnectError = errors.New("simulated-card-connect-error") + simulatedGetCardStatusError = errors.New("simulated-get-card-status-error") + simulatedSelectAppletError = errors.New("simulated-select-applet-error") + simulatedNotAKeycard = errors.New("simulated-not-a-keycard") + simulatedOpenSecureChannelError = errors.New("simulated-open-secure-channel-error") +) + +func GetSimulatedError(message string) error { + errs := map[string]error{ + simulatedNoPCSC.Error(): simulatedNoPCSC, + simulatedListReadersError.Error(): simulatedListReadersError, + simulatedGetStatusChangeError.Error(): simulatedGetStatusChangeError, + simulatedCardConnectError.Error(): simulatedCardConnectError, + simulatedGetCardStatusError.Error(): simulatedGetCardStatusError, + simulatedSelectAppletError.Error(): simulatedSelectAppletError, + simulatedNotAKeycard.Error(): simulatedNotAKeycard, + simulatedOpenSecureChannelError.Error(): simulatedOpenSecureChannelError, + } + return errs[message] +} diff --git a/internal/readers_states.go b/internal/readers_states.go index 5c24f1a..189e441 100644 --- a/internal/readers_states.go +++ b/internal/readers_states.go @@ -57,3 +57,11 @@ func (rs ReadersStates) HasChanges() bool { } return false } + +func (rs ReadersStates) Readers() []string { + readers := make([]string, 0, len(rs)) + for _, state := range rs { + readers = append(readers, state.Reader) + } + return readers +} diff --git a/pkg/session/service.go b/pkg/session/service.go index 8f6e3ea..0b320d7 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -25,17 +25,28 @@ func validateRequest(v interface{}) error { } type KeycardService struct { - keycardContext *internal.KeycardContextV2 + keycardContext *internal.KeycardContextV2 + simulateError error + simulationInstanceUID string } type StartRequest struct { - StorageFilePath string `json:"storageFilePath"` + StorageFilePath string `json:"storageFilePath" validate:"required"` } func (s *KeycardService) Start(args *StartRequest, reply *struct{}) error { var err error s.keycardContext, err = internal.NewKeycardContextV2(args.StorageFilePath) - return err + if err != nil { + return err + } + + err = s.keycardContext.SimulateError(s.simulateError, s.simulationInstanceUID) + if err != nil { + return err + } + + return s.keycardContext.Start() } func (s *KeycardService) Stop(args *struct{}, reply *struct{}) error { @@ -249,3 +260,28 @@ func (s *KeycardService) ExportRecoverKeys(args *struct{}, reply *ExportRecovere reply.Keys, err = s.keycardContext.ExportRecoverKeys() return err } + +type SimulateErrorRequest struct { + Error string `json:"error"` + InstanceUID string `json:"instanceUID"` +} + +func (s *KeycardService) SimulateError(args *SimulateErrorRequest, reply *struct{}) error { + err := validateRequest(args) + if err != nil { + return err + } + + errToSimulate := internal.GetSimulatedError(args.Error) + if args.Error != "" && errToSimulate == nil { + return errors.New("unknown error to simulate") + } + + if s.keycardContext == nil { + s.simulateError = errToSimulate + s.simulationInstanceUID = args.InstanceUID + return nil + } + + return s.keycardContext.SimulateError(errToSimulate, args.InstanceUID) +} From 854f0c46abdcd7ef9536cd96dbb826a3c666a3d6 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 00:14:45 +0100 Subject: [PATCH 19/35] Loop V3 --- internal/keycard_context_v2.go | 313 ++++++++++++++++++++------------- 1 file changed, 194 insertions(+), 119 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index aac5a6e..5eb2248 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -25,11 +25,10 @@ const ( ) var ( - errNotKeycard = errors.New("card is not a keycard") - errKeycardNotConnected = errors.New("keycard not connected") - errKeycardNotReady = errors.New("keycard not ready") - errKeycardNotAuthorized = errors.New("keycard not authorized") - errKeycardNotInitialized = errors.New("keycard not initialized") + errNotAKeycard = errors.New("not a keycard") + errKeycardNotConnected = errors.New("keycard not connected") + errKeycardNotReady = errors.New("keycard not ready") + errKeycardNotAuthorized = errors.New("keycard not authorized") ) type KeycardContextV2 struct { @@ -40,7 +39,7 @@ type KeycardContextV2 struct { logger *zap.Logger pairings *pairing.Store status *Status - activeReader string + readersState ReadersStates // simulation options simulatedError error @@ -80,7 +79,7 @@ func (kc *KeycardContextV2) Start() error { kc.forceScan.Store(false) go kc.cardCommunicationRoutine(ctx) - kc.monitor(ctx) + kc.startDetectionLoop(ctx) return nil } @@ -118,7 +117,26 @@ func (kc *KeycardContext) cardCommunicationRoutine(ctx context.Context) { } } -func (kc *KeycardContextV2) monitor(ctx context.Context) { +func (kc *KeycardContextV2) startDetectionLoop(ctx context.Context) { + if kc.cardCtx == nil { + panic("card context is nil") + } + + logger := kc.logger.Named("detect") + + go func() { + defer logger.Debug("detect stopped") + // This goroutine will be stopped by cardCtx.Cancel() + for { + ok := kc.detectionRoutine(ctx, logger) + if !ok { + return + } + } + }() +} + +func (kc *KeycardContextV2) startMonitoringLoop(ctx context.Context) { if kc.cardCtx == nil { panic("card context is nil") } @@ -129,7 +147,7 @@ func (kc *KeycardContextV2) monitor(ctx context.Context) { defer logger.Debug("monitor stopped") // This goroutine will be stopped by cardCtx.Cancel() for { - ok := kc.monitorRoutine(ctx, logger) + ok := kc.detectionRoutine(ctx, logger) if !ok { return } @@ -137,10 +155,10 @@ func (kc *KeycardContextV2) monitor(ctx context.Context) { }() } -// monitorRoutine is the main routine that monitors the card readers and card changes. +// detectionRoutine is the main routine that monitors the card readers and card changes. // It will be stopped by cardCtx.Cancel() or when the context is done. // Returns false if the monitoring should be stopped by the runner. -func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logger) bool { +func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Logger) bool { logger.Debug("tick") /* @@ -165,40 +183,40 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg logger.Error("failed to get readers state", zap.Error(err)) kc.status.Reset(InternalError) kc.publishStatus() - - // FIXME: wait 500 milliseconds return false } - if readers.Empty() && kc.status.State != WaitingForReader { - kc.status.Reset(WaitingForReader) - kc.publishStatus() + card, err := kc.connectCard(ctx, readers) + if card != nil { + err = kc.connectKeycard() + if err != nil { + logger.Error("failed to connect card", zap.Error(err)) + kc.publishStatus() + } + go kc.watchActiveReader(ctx, card.readerState) + return false + } + if err != nil { + logger.Error("failed to connect card", zap.Error(err)) } - kc.scanReadersForKeycard(readers) + kc.resetCardConnection(false) - // NOTE: There're 2 approaches to wait for readers changes: - // 1. Periodically call `ListReaders` and compare the list of readers. - // 2. Use `GetStatusChange` with a list of readers and a `PnP` reader. - //Wait for readers changes, including new readers + // Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ // NOTE: The article states that MacOS is not supported, but works for me on MacOS 15.1.1 (24B91). - //pnpNotificationReader := `\\?PnP?\Notification` - //pnpReader := scard.ReaderState{ - // Reader: pnpNotificationReader, - // CurrentState: scard.StateUnaware, - //} - //rs := append(readers, pnpReader) - //err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) - - err = kc.getActiveReaderChange(ctx, readers) - - if err == scard.ErrCancelled && !kc.forceScan.Load() { - // Shutdown requested - return false + const pnpNotificationReader = `\\?PnP?\Notification` + pnpReader := scard.ReaderState{ + Reader: pnpNotificationReader, + CurrentState: scard.StateUnaware, } - - if err != scard.ErrCancelled && err != nil { + rs := append(readers, pnpReader) + err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) + if err == scard.ErrCancelled { + // Not forceScan -> shutdown requested + return !kc.forceScan.Load() + } + if err != nil { logger.Error("failed to get status change", zap.Error(err)) return false } @@ -206,6 +224,104 @@ func (kc *KeycardContextV2) monitorRoutine(ctx context.Context, logger *zap.Logg return true } +type connectedCard struct { + readerState scard.ReaderState +} + +func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStates) (*connectedCard, error) { + defer kc.publishStatus() + + if readers.Empty() { + kc.status.Reset(WaitingForReader) + return nil, nil + } + + kc.forceScan.Store(false) + kc.resetCardConnection(false) + + readerWithCardIndex, ok := readers.ReaderWithCardIndex() + if !ok { + kc.logger.Debug("no card found on any readers") + kc.status.Reset(WaitingForCard) + return nil, nil + } + + kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) + activeReader := readers[readerWithCardIndex] + + card, err := kc.cardCtx.Connect(activeReader.Reader, scard.ShareShared, scard.ProtocolAny) + err = kc.simulateError(err, simulatedCardConnectError) + if err != nil { + kc.status.State = ConnectionError + return nil, errors.Wrap(err, "failed to connect to card") + } + + // FIXME: Do we actually need to get card status? + _, err = card.Status() + err = kc.simulateError(err, simulatedGetCardStatusError) + if err != nil { + kc.status.State = ConnectionError + return nil, errors.Wrap(err, "failed to get card status") + } + + kc.card = card + kc.c = io.NewNormalChannel(kc) + kc.cmdSet = keycard.NewCommandSet(kc.c) + + // Card connected, now check if this is a keycard + appInfo, err := kc.selectApplet() + err = kc.simulateError(err, simulatedSelectAppletError) + if err != nil { + kc.status.State = ConnectionError + return nil, errors.Wrap(err, "failed to select applet") + } + + // Check if 'not a keycard' simulation was requested for this card + simulatedError := kc.simulateError(nil, simulatedNotAKeycard) + keycardMatch := kc.simulationInstanceUID == appInfo.InstanceUID.String() + if simulatedError != nil && keycardMatch { + appInfo.Installed = false + } + + // Save AppInfo + kc.status.AppInfo = appInfo + + if !appInfo.Installed { + kc.status.State = NotKeycard + return nil, nil + } + + return &connectedCard{ + readerState: activeReader, + }, nil +} + +func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader scard.ReaderState) { + logger := kc.logger.Named("watch") + logger.Debug("watch started", zap.String("reader", activeReader.Reader)) + defer logger.Debug("watch stopped") + + readersStates := ReadersStates{ + activeReader, + } + err := kc.cardCtx.GetStatusChange(readersStates, infiniteTimeout) + + if err == scard.ErrCancelled { + if kc.forceScan.Load() { + kc.startDetectionLoop(ctx) + } + return + } + + if err != nil { + kc.logger.Error("failed to get status change", zap.Error(err)) + return + } + + kc.startDetectionLoop(ctx) + return +} + func (kc *KeycardContextV2) getActiveReaderChange(ctx context.Context, readersStates ReadersStates) error { if len(readersStates) == 0 { select { @@ -260,90 +376,50 @@ func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { return knownReaders, nil } -func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { - if !kc.forceScan.Load() && - kc.activeReader != "" && - readers.Contains(kc.activeReader) && - readers.ReaderHasCard(kc.activeReader) { - // active reader is not selected yet or is still present, no need to connect a card - return - } - - if readers.Empty() { - return - } - - kc.forceScan.Store(false) - kc.resetCardConnection(false) - - readerWithCardIndex, ok := readers.ReaderWithCardIndex() - if !ok { - if kc.status.State == WaitingForCard { - return - } - kc.logger.Debug("no card found on any readers") - kc.status.Reset(WaitingForCard) - kc.publishStatus() - return - } - - kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) - - err := kc.connectKeycard(readers[readerWithCardIndex].Reader) - if err != nil && !errors.Is(err, errNotKeycard) && !errors.Is(err, errKeycardNotInitialized) { - kc.logger.Error("failed to connect keycard", zap.Error(err)) - } - - kc.publishStatus() -} - -func (kc *KeycardContextV2) connectKeycard(reader string) error { - card, err := kc.cardCtx.Connect(reader, scard.ShareShared, scard.ProtocolAny) - err = kc.simulateError(err, simulatedCardConnectError) - if err != nil { - kc.status.State = ConnectionError - return errors.Wrap(err, "failed to connect to card") - } - - _, err = card.Status() - err = kc.simulateError(err, simulatedGetCardStatusError) - if err != nil { - kc.status.State = ConnectionError - return errors.Wrap(err, "failed to get card status") - } - - kc.activeReader = reader - kc.card = card - kc.c = io.NewNormalChannel(kc) - kc.cmdSet = keycard.NewCommandSet(kc.c) - - // Card connected, now check if this is a keycard - - appInfo, err := kc.selectApplet() - err = kc.simulateError(err, simulatedSelectAppletError) - if err != nil { - kc.status.State = ConnectionError - return errors.Wrap(err, "failed to select applet") - } - - // Check if 'not a keycard' simulation was requested for this card - simulatedError := kc.simulateError(nil, simulatedNotAKeycard) - keycardMatch := kc.simulationInstanceUID == appInfo.InstanceUID.String() - if simulatedError != nil && keycardMatch { - appInfo.Installed = false - } - - // Save AppInfo - kc.status.AppInfo = appInfo - - if !appInfo.Installed { - kc.status.State = NotKeycard - return kc.simulateError(errNotKeycard, simulatedNotAKeycard) - } +//func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { +// if !kc.forceScan.Load() && +// kc.activeReader != "" && +// readers.Contains(kc.activeReader) && +// readers.ReaderHasCard(kc.activeReader) { +// // active reader is not selected yet or is still present, no need to connect a card +// return +// } +// +// if readers.Empty() { +// return +// } +// +// kc.forceScan.Store(false) +// kc.resetCardConnection(false) +// +// readerWithCardIndex, ok := readers.ReaderWithCardIndex() +// if !ok { +// if kc.status.State == WaitingForCard { +// return +// } +// kc.logger.Debug("no card found on any readers") +// kc.status.Reset(WaitingForCard) +// kc.publishStatus() +// return +// } +// +// kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) +// +// err := kc.connectKeycard(readers[readerWithCardIndex].Reader) +// if err != nil { +// kc.logger.Error("failed to connect keycard", zap.Error(err)) +// } +// +// kc.publishStatus() +//} + +func (kc *KeycardContextV2) connectKeycard() error { + var err error + appInfo := kc.status.AppInfo if !appInfo.Initialized { kc.status.State = EmptyKeycard - return errKeycardNotInitialized + return nil } kc.status.State = ConnectingCard @@ -361,7 +437,6 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { kc.status.State = NoAvailablePairingSlots return err } - if err != nil { kc.status.State = PairingError return errors.Wrap(err, "failed to pair keycard") @@ -404,7 +479,6 @@ func (kc *KeycardContextV2) connectKeycard(reader string) error { } func (kc *KeycardContextV2) resetCardConnection(forceRescan bool) { - kc.activeReader = "" kc.card = nil kc.c = nil kc.cmdSet = nil @@ -513,6 +587,7 @@ func (kc *KeycardContextV2) updateApplicationStatus() error { func (kc *KeycardContextV2) updateMetadata() error { metadata, err := kc.GetMetadata() if err != nil { + kc.status.State = ConnectionError return err } From 48e01ec480634ffe6c601b7b96de2cc645d9b463 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 09:49:53 +0100 Subject: [PATCH 20/35] fix: cleanup states and better monitoring --- internal/keycard_context_v2.go | 44 +++++++++++++++++++++------------- internal/readers_states.go | 2 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 5eb2248..4717fed 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -56,7 +56,7 @@ func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error KeycardContext: KeycardContext{ command: make(chan commandType), }, - logger: zap.L().Named("keycard"), + logger: zap.L().Named("context"), pairings: pairingsStore, status: NewStatus(), } @@ -123,6 +123,7 @@ func (kc *KeycardContextV2) startDetectionLoop(ctx context.Context) { } logger := kc.logger.Named("detect") + logger.Debug("detect started") go func() { defer logger.Debug("detect stopped") @@ -159,7 +160,7 @@ func (kc *KeycardContextV2) startMonitoringLoop(ctx context.Context) { // It will be stopped by cardCtx.Cancel() or when the context is done. // Returns false if the monitoring should be stopped by the runner. func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Logger) bool { - logger.Debug("tick") + logger.Debug("detection tick") /* Limitations: @@ -191,7 +192,6 @@ func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Lo err = kc.connectKeycard() if err != nil { logger.Error("failed to connect card", zap.Error(err)) - kc.publishStatus() } go kc.watchActiveReader(ctx, card.readerState) return false @@ -291,6 +291,8 @@ func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStat return nil, nil } + kc.status.State = ConnectingCard + return &connectedCard{ readerState: activeReader, }, nil @@ -304,22 +306,31 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader readersStates := ReadersStates{ activeReader, } - err := kc.cardCtx.GetStatusChange(readersStates, infiniteTimeout) - if err == scard.ErrCancelled { - if kc.forceScan.Load() { - kc.startDetectionLoop(ctx) + for { + err := kc.cardCtx.GetStatusChange(readersStates, infiniteTimeout) + + if err == scard.ErrCancelled { + if kc.forceScan.Load() { + kc.startDetectionLoop(ctx) + } + return } - return - } - if err != nil { - kc.logger.Error("failed to get status change", zap.Error(err)) - return + if err != nil { + kc.logger.Error("failed to get status change", zap.Error(err)) + return + } + + state := readersStates[0].EventState + if state&scard.StateUnknown != 0 || state&scard.StateEmpty != 0 { + break + } + + readersStates.Update() } kc.startDetectionLoop(ctx) - return } func (kc *KeycardContextV2) getActiveReaderChange(ctx context.Context, readersStates ReadersStates) error { @@ -417,14 +428,13 @@ func (kc *KeycardContextV2) connectKeycard() error { var err error appInfo := kc.status.AppInfo + defer kc.publishStatus() + if !appInfo.Initialized { kc.status.State = EmptyKeycard return nil } - kc.status.State = ConnectingCard - kc.publishStatus() - pair := kc.pairings.Get(appInfo.InstanceUID.String()) if pair == nil { @@ -465,7 +475,7 @@ func (kc *KeycardContextV2) connectKeycard() error { return errors.Wrap(err, "failed to open secure channel") } - err = kc.updateApplicationStatus() + err = kc.updateApplicationStatus() // Changes status to Ready if err != nil { return errors.Wrap(err, "failed to get application status") } diff --git a/internal/readers_states.go b/internal/readers_states.go index 189e441..a707a53 100644 --- a/internal/readers_states.go +++ b/internal/readers_states.go @@ -58,7 +58,7 @@ func (rs ReadersStates) HasChanges() bool { return false } -func (rs ReadersStates) Readers() []string { +func (rs ReadersStates) Names() []string { readers := make([]string, 0, len(rs)) for _, state := range rs { readers = append(readers, state.Reader) From 2e4cf60eb1015239b150446c749d21e314c61aba Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 10:03:00 +0100 Subject: [PATCH 21/35] fix: active reader state polling --- internal/keycard_context_v2.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 4717fed..72a1e5f 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -308,7 +308,7 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader } for { - err := kc.cardCtx.GetStatusChange(readersStates, infiniteTimeout) + err := kc.cardCtx.GetStatusChange(readersStates, zeroTimeout) if err == scard.ErrCancelled { if kc.forceScan.Load() { @@ -317,7 +317,7 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader return } - if err != nil { + if err != nil && err != scard.ErrTimeout { kc.logger.Error("failed to get status change", zap.Error(err)) return } @@ -328,6 +328,13 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader } readersStates.Update() + + // NOTE: Would be better to use `GetStatusChange` with infinite timeout. + // This worked perfectly on MacOS, but not on Linux. So we poll the reader state instead. + select { + case <-ctx.Done(): + case <-time.After(monitoringTick): // Pause for a while to avoid a busy loop + } } kc.startDetectionLoop(ctx) From b414330ac53bd962f7cde95f002735674071d6aa Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 11:59:38 +0100 Subject: [PATCH 22/35] chore: simplify force scan and auth status update --- internal/keycard_context_v2.go | 199 ++++++++------------------------- 1 file changed, 47 insertions(+), 152 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 72a1e5f..91517dd 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -3,7 +3,6 @@ package internal import ( "context" "runtime" - "sync/atomic" "time" "github.com/ebfe/scard" @@ -35,7 +34,7 @@ type KeycardContextV2 struct { KeycardContext shutdown func() - forceScan atomic.Bool // Needed to distinguish cardCtx.cancel() from a real shutdown + forceScanC chan struct{} logger *zap.Logger pairings *pairing.Store status *Status @@ -76,7 +75,7 @@ func (kc *KeycardContextV2) Start() error { ctx, cancel := context.WithCancel(context.Background()) kc.shutdown = cancel - kc.forceScan.Store(false) + kc.forceScanC = nil go kc.cardCommunicationRoutine(ctx) kc.startDetectionLoop(ctx) @@ -84,7 +83,7 @@ func (kc *KeycardContextV2) Start() error { return nil } -func (kc *KeycardContext) establishContext() error { +func (kc *KeycardContextV2) establishContext() error { cardCtx, err := scard.EstablishContext() if err != nil { return errors.New(ErrorPCSC) @@ -94,7 +93,7 @@ func (kc *KeycardContext) establishContext() error { return nil } -func (kc *KeycardContext) cardCommunicationRoutine(ctx context.Context) { +func (kc *KeycardContextV2) cardCommunicationRoutine(ctx context.Context) { // Communication with the keycard must be done in a fixed thread runtime.LockOSThread() defer runtime.UnlockOSThread() @@ -137,47 +136,12 @@ func (kc *KeycardContextV2) startDetectionLoop(ctx context.Context) { }() } -func (kc *KeycardContextV2) startMonitoringLoop(ctx context.Context) { - if kc.cardCtx == nil { - panic("card context is nil") - } - - logger := kc.logger.Named("monitor") - - go func() { - defer logger.Debug("monitor stopped") - // This goroutine will be stopped by cardCtx.Cancel() - for { - ok := kc.detectionRoutine(ctx, logger) - if !ok { - return - } - } - }() -} - // detectionRoutine is the main routine that monitors the card readers and card changes. // It will be stopped by cardCtx.Cancel() or when the context is done. // Returns false if the monitoring should be stopped by the runner. func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Logger) bool { logger.Debug("detection tick") - /* - Limitations: - - Only support 1 card at a time. Even if it's not a keycard, or connection failed. - - Limited support for multiple readers. Only the first found card is considered. - NOTE: Does it make senes to support multiple readers, if we can only connect to one card? - - 1. readers := ListReaders() - 2. states := GetStatusChange(readers) - 3. if states didn't change since last read, wait 500ms and go to (1) - 4. if a card is present, connect to it - 5. if connection failed, FIXME: - 6. if not a keycard, WARNING: - 7. if not initialized, WARNING: - 8. listen only to activeReader changes - */ - // Get current readers list and state readers, err := kc.getCurrentReadersState() if err != nil { @@ -200,7 +164,7 @@ func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Lo logger.Error("failed to connect card", zap.Error(err)) } - kc.resetCardConnection(false) + kc.resetCardConnection() // Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ @@ -213,8 +177,8 @@ func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Lo rs := append(readers, pnpReader) err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout) if err == scard.ErrCancelled { - // Not forceScan -> shutdown requested - return !kc.forceScan.Load() + // Shutdown requested + return false } if err != nil { logger.Error("failed to get status change", zap.Error(err)) @@ -236,8 +200,8 @@ func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStat return nil, nil } - kc.forceScan.Store(false) - kc.resetCardConnection(false) + kc.forceScanC = make(chan struct{}) + kc.resetCardConnection() readerWithCardIndex, ok := readers.ReaderWithCardIndex() if !ok { @@ -310,13 +274,6 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader for { err := kc.cardCtx.GetStatusChange(readersStates, zeroTimeout) - if err == scard.ErrCancelled { - if kc.forceScan.Load() { - kc.startDetectionLoop(ctx) - } - return - } - if err != nil && err != scard.ErrTimeout { kc.logger.Error("failed to get status change", zap.Error(err)) return @@ -334,29 +291,17 @@ func (kc *KeycardContextV2) watchActiveReader(ctx context.Context, activeReader select { case <-ctx.Done(): case <-time.After(monitoringTick): // Pause for a while to avoid a busy loop + case _, ok := <-kc.forceScanC: + if ok { + kc.startDetectionLoop(ctx) + } + return } } kc.startDetectionLoop(ctx) } -func (kc *KeycardContextV2) getActiveReaderChange(ctx context.Context, readersStates ReadersStates) error { - if len(readersStates) == 0 { - select { - case <-ctx.Done(): - case <-time.After(monitoringTick): // Pause for a while to avoid a busy loop - } - return nil - } - - err := kc.cardCtx.GetStatusChange(readersStates, monitoringTick) - if err != nil && err != scard.ErrTimeout { - return err - } - - return nil -} - func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { readers, err := kc.cardCtx.ListReaders() err = kc.simulateError(err, simulatedListReadersError) @@ -394,43 +339,6 @@ func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) { return knownReaders, nil } -//func (kc *KeycardContextV2) scanReadersForKeycard(readers ReadersStates) { -// if !kc.forceScan.Load() && -// kc.activeReader != "" && -// readers.Contains(kc.activeReader) && -// readers.ReaderHasCard(kc.activeReader) { -// // active reader is not selected yet or is still present, no need to connect a card -// return -// } -// -// if readers.Empty() { -// return -// } -// -// kc.forceScan.Store(false) -// kc.resetCardConnection(false) -// -// readerWithCardIndex, ok := readers.ReaderWithCardIndex() -// if !ok { -// if kc.status.State == WaitingForCard { -// return -// } -// kc.logger.Debug("no card found on any readers") -// kc.status.Reset(WaitingForCard) -// kc.publishStatus() -// return -// } -// -// kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) -// -// err := kc.connectKeycard(readers[readerWithCardIndex].Reader) -// if err != nil { -// kc.logger.Error("failed to connect keycard", zap.Error(err)) -// } -// -// kc.publishStatus() -//} - func (kc *KeycardContextV2) connectKeycard() error { var err error appInfo := kc.status.AppInfo @@ -495,19 +403,14 @@ func (kc *KeycardContextV2) connectKeycard() error { return nil } -func (kc *KeycardContextV2) resetCardConnection(forceRescan bool) { +func (kc *KeycardContextV2) resetCardConnection() { kc.card = nil kc.c = nil kc.cmdSet = nil +} - // If a command failed, we need to cancel the context. This will force the monitor to reconnect to the card. - if forceRescan { - kc.forceScan.Store(true) - err := kc.cardCtx.Cancel() - if err != nil { - kc.logger.Error("failed to cancel context", zap.Error(err)) - } - } +func (kc *KeycardContextV2) forceScan() { + kc.forceScanC <- struct{}{} } func (kc *KeycardContextV2) publishStatus() { @@ -516,7 +419,7 @@ func (kc *KeycardContextV2) publishStatus() { } func (kc *KeycardContextV2) Stop() { - kc.forceScan.Store(false) + close(kc.forceScanC) if kc.cardCtx != nil { err := kc.cardCtx.Cancel() if err != nil { @@ -562,7 +465,8 @@ func (kc *KeycardContextV2) checkSCardError(err error, context string) error { kc.logger.Error("command failed, resetting connection", zap.String("context", context), zap.Error(err)) - kc.resetCardConnection(true) + kc.resetCardConnection() + kc.forceScan() } return err @@ -628,32 +532,33 @@ func (kc *KeycardContextV2) Initialize(pin, puk, pairingPassword string) error { } // Reset card connection to pair the card and open secure channel - kc.resetCardConnection(true) + kc.resetCardConnection() + kc.forceScan() return nil } -func (kc *KeycardContextV2) VerifyPIN(pin string) error { - if !kc.keycardConnected() { - return errKeycardNotConnected +func (kc *KeycardContextV2) onAuthorizeInteractions(authorized bool) { + err := kc.updateApplicationStatus() + if err != nil { + kc.logger.Error("failed to update app status", zap.Error(err)) } + if kc.status.State == Ready && authorized { + kc.status.State = Authorized + } + kc.publishStatus() +} - authorized := false +func (kc *KeycardContextV2) VerifyPIN(pin string) (err error) { + if err = kc.keycardReady(); err != nil { + return err + } defer func() { - // Update app status to get the new pin remaining attempts - // Although we can parse the `err` as `keycard.WrongPINError`, it won't work for `err == nil`. - err := kc.updateApplicationStatus() - if err != nil { - kc.logger.Error("failed to update app status after verifying pin") - } - if kc.status.State == Ready && authorized { - kc.status.State = Authorized - } - kc.publishStatus() + authorized := err == nil + kc.onAuthorizeInteractions(authorized) }() - err := kc.cmdSet.VerifyPIN(pin) - authorized = err == nil + err = kc.cmdSet.VerifyPIN(pin) return kc.checkSCardError(err, "VerifyPIN") } @@ -663,31 +568,24 @@ func (kc *KeycardContextV2) ChangePIN(pin string) error { } defer func() { - err := kc.updateApplicationStatus() - if err != nil { - kc.logger.Error("failed to update app status after changing pin") - } - kc.publishStatus() + kc.onAuthorizeInteractions(false) }() err := kc.cmdSet.ChangePIN(pin) return kc.checkSCardError(err, "ChangePIN") } -func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) error { +func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) (err error) { if !kc.keycardConnected() { return errKeycardNotConnected } defer func() { - err := kc.updateApplicationStatus() - if err != nil { - kc.logger.Error("failed to update app status after unblocking") - } - kc.publishStatus() + authorized := err == nil + kc.onAuthorizeInteractions(authorized) }() - err := kc.cmdSet.UnblockPIN(puk, newPIN) + err = kc.cmdSet.UnblockPIN(puk, newPIN) return kc.checkSCardError(err, "UnblockPIN") } @@ -697,11 +595,7 @@ func (kc *KeycardContextV2) ChangePUK(puk string) error { } defer func() { - err := kc.updateApplicationStatus() - if err != nil { - kc.logger.Error("failed to update app status after changing pin") - } - kc.publishStatus() + kc.onAuthorizeInteractions(false) }() err := kc.cmdSet.ChangePUK(puk) @@ -750,7 +644,8 @@ func (kc *KeycardContextV2) FactoryReset() error { err := kc.KeycardContext.FactoryReset(true) // Reset card connection to read the card data - kc.resetCardConnection(true) + kc.resetCardConnection() + kc.forceScan() return err } From 6b9e1bd25b180f54152c50b5c215561d47761881 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 14:49:26 +0100 Subject: [PATCH 23/35] fix: graceful stop --- cmd/status-keycard-server/server/server.go | 7 ++++--- internal/keycard_context_v2.go | 7 ++++++- pkg/session/service.go | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/status-keycard-server/server/server.go b/cmd/status-keycard-server/server/server.go index fdbc4d7..6101a69 100644 --- a/cmd/status-keycard-server/server/server.go +++ b/cmd/status-keycard-server/server/server.go @@ -4,17 +4,19 @@ import ( "context" "net" "net/http" + "os" "strconv" "sync" "time" + "github.com/gorilla/websocket" "github.com/pkg/errors" - "github.com/status-im/status-keycard-go/pkg/session" "go.uber.org/zap" + + "github.com/status-im/status-keycard-go/pkg/session" "github.com/status-im/status-keycard-go/signal" - "os" ) type Server struct { @@ -72,7 +74,6 @@ func (s *Server) signalHandler(data []byte) { err = connection.WriteMessage(websocket.TextMessage, data) if err != nil { - s.logger.Error("failed to write signal message", zap.Error(err)) deleteConnection(connection) } } diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 91517dd..9d26bf7 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -419,14 +419,19 @@ func (kc *KeycardContextV2) publishStatus() { } func (kc *KeycardContextV2) Stop() { - close(kc.forceScanC) + if kc.forceScanC != nil { + close(kc.forceScanC) + } + if kc.cardCtx != nil { err := kc.cardCtx.Cancel() if err != nil { kc.logger.Error("failed to cancel context", zap.Error(err)) } } + kc.KeycardContext.Stop() + if kc.shutdown != nil { kc.shutdown() } diff --git a/pkg/session/service.go b/pkg/session/service.go index 0b320d7..7238c61 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -35,6 +35,10 @@ type StartRequest struct { } func (s *KeycardService) Start(args *StartRequest, reply *struct{}) error { + if s.keycardContext != nil { + return errors.New("keycard service already started") + } + var err error s.keycardContext, err = internal.NewKeycardContextV2(args.StorageFilePath) if err != nil { From 60a0f69e7dde07105ce9fbda1c87d7b09083fa02 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 22 Jan 2025 14:53:45 +0100 Subject: [PATCH 24/35] feat: api --- .gitignore | 1 + api/Authorize.http | 12 ++++++++++++ api/ChangePIN.http | 12 ++++++++++++ api/ChangePUK.http | 12 ++++++++++++ api/ExportLoginKeys.http | 8 ++++++++ api/ExportRecoverKeys.http | 8 ++++++++ api/FactoryReset.http | 8 ++++++++ api/GenerateMnemonic.http | 12 ++++++++++++ api/GetMetadata.http | 8 ++++++++ api/GetStatus.http | 8 ++++++++ api/Initialize.http | 13 +++++++++++++ api/LoadMnemonic.http | 12 ++++++++++++ api/Signals.http | 3 +++ api/SimulateError.http | 13 +++++++++++++ api/Start.http | 12 ++++++++++++ api/Stop.http | 8 ++++++++ api/Unblock.http | 13 +++++++++++++ 17 files changed, 163 insertions(+) create mode 100644 api/Authorize.http create mode 100644 api/ChangePIN.http create mode 100644 api/ChangePUK.http create mode 100644 api/ExportLoginKeys.http create mode 100644 api/ExportRecoverKeys.http create mode 100644 api/FactoryReset.http create mode 100644 api/GenerateMnemonic.http create mode 100644 api/GetMetadata.http create mode 100644 api/GetStatus.http create mode 100644 api/Initialize.http create mode 100644 api/LoadMnemonic.http create mode 100644 api/Signals.http create mode 100644 api/SimulateError.http create mode 100644 api/Start.http create mode 100644 api/Stop.http create mode 100644 api/Unblock.http diff --git a/.gitignore b/.gitignore index b8db6ef..a69c36c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /keycard /build /status-keycard-go +/api/http-client.private.env.json diff --git a/api/Authorize.http b/api/Authorize.http new file mode 100644 index 0000000..6363f31 --- /dev/null +++ b/api/Authorize.http @@ -0,0 +1,12 @@ +# @name Authorize +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.Authorize", + "params": [ + { + "pin": "654321" + } + ] +} \ No newline at end of file diff --git a/api/ChangePIN.http b/api/ChangePIN.http new file mode 100644 index 0000000..b232355 --- /dev/null +++ b/api/ChangePIN.http @@ -0,0 +1,12 @@ +# @name ChangePIN +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.ChangePIN", + "params": [ + { + "newPIN": "654321" + } + ] +} diff --git a/api/ChangePUK.http b/api/ChangePUK.http new file mode 100644 index 0000000..6cce8d5 --- /dev/null +++ b/api/ChangePUK.http @@ -0,0 +1,12 @@ +# @name ChangePUK +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.ChangePUK", + "params": [ + { + "newPuk": "654321654322" + } + ] +} diff --git a/api/ExportLoginKeys.http b/api/ExportLoginKeys.http new file mode 100644 index 0000000..933b12a --- /dev/null +++ b/api/ExportLoginKeys.http @@ -0,0 +1,8 @@ +# @name ExportLoginKeys +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.ExportLoginKeys", + "params": [] +} \ No newline at end of file diff --git a/api/ExportRecoverKeys.http b/api/ExportRecoverKeys.http new file mode 100644 index 0000000..1dc40cb --- /dev/null +++ b/api/ExportRecoverKeys.http @@ -0,0 +1,8 @@ +# @name ExportRecoverKeys +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.ExportRecoverKeys", + "params": [] +} \ No newline at end of file diff --git a/api/FactoryReset.http b/api/FactoryReset.http new file mode 100644 index 0000000..fef6e14 --- /dev/null +++ b/api/FactoryReset.http @@ -0,0 +1,8 @@ +# @name FactoryReset +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.FactoryReset", + "params": [] +} \ No newline at end of file diff --git a/api/GenerateMnemonic.http b/api/GenerateMnemonic.http new file mode 100644 index 0000000..e5799bb --- /dev/null +++ b/api/GenerateMnemonic.http @@ -0,0 +1,12 @@ +# @name Initialize +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.GenerateMnemonic", + "params": [ + { + "length": 12 + } + ] +} \ No newline at end of file diff --git a/api/GetMetadata.http b/api/GetMetadata.http new file mode 100644 index 0000000..ee8f93f --- /dev/null +++ b/api/GetMetadata.http @@ -0,0 +1,8 @@ +# @name FactoryReset +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.GetMetadata", + "params": [] +} \ No newline at end of file diff --git a/api/GetStatus.http b/api/GetStatus.http new file mode 100644 index 0000000..f726dc3 --- /dev/null +++ b/api/GetStatus.http @@ -0,0 +1,8 @@ +# @name GetStatus +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.GetStatus", + "params": [] +} \ No newline at end of file diff --git a/api/Initialize.http b/api/Initialize.http new file mode 100644 index 0000000..25365e6 --- /dev/null +++ b/api/Initialize.http @@ -0,0 +1,13 @@ +# @name Initialize +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.Initialize", + "params": [ + { + "pin": "654321", + "puk": "654321654321" + } + ] +} \ No newline at end of file diff --git a/api/LoadMnemonic.http b/api/LoadMnemonic.http new file mode 100644 index 0000000..6f99374 --- /dev/null +++ b/api/LoadMnemonic.http @@ -0,0 +1,12 @@ +# @name LoadMnemonic +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.LoadMnemonic", + "params": [ + { + "mnemonic": "tiger worth relief food limb glad recycle similar wreck work region uncover" + } + ] +} \ No newline at end of file diff --git a/api/Signals.http b/api/Signals.http new file mode 100644 index 0000000..3467188 --- /dev/null +++ b/api/Signals.http @@ -0,0 +1,3 @@ +# @name Signals +WEBSOCKET ws://{{address}}/signals +Content-Type: application/json diff --git a/api/SimulateError.http b/api/SimulateError.http new file mode 100644 index 0000000..a8feeb0 --- /dev/null +++ b/api/SimulateError.http @@ -0,0 +1,13 @@ +# @name SimulateError +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.SimulateError", + "params": [ + { + "instanceUID": "", + "error": "simulated-card-connect-error" + } + ] +} \ No newline at end of file diff --git a/api/Start.http b/api/Start.http new file mode 100644 index 0000000..b4c87d9 --- /dev/null +++ b/api/Start.http @@ -0,0 +1,12 @@ +# @name Start +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.Start", + "params": [ + { + "storageFilePath": "{{storageFilePath}}" + } + ] +} diff --git a/api/Stop.http b/api/Stop.http new file mode 100644 index 0000000..1b52e2d --- /dev/null +++ b/api/Stop.http @@ -0,0 +1,8 @@ +# @name Stop +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.Stop", + "params": [] +} diff --git a/api/Unblock.http b/api/Unblock.http new file mode 100644 index 0000000..38fe354 --- /dev/null +++ b/api/Unblock.http @@ -0,0 +1,13 @@ +# @name Unblock +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.Unblock", + "params": [ + { + "puk": "654321654321", + "newPIN": "654321" + } + ] +} \ No newline at end of file From ac695482c964f2ca7b491974071b6804c0980b0d Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 23 Jan 2025 11:44:54 +0100 Subject: [PATCH 25/35] feat: ApplicationStatus type --- internal/keycard_context_v2.go | 3 +-- internal/keycard_context_v2_state.go | 10 ++++------ internal/types.go | 7 +++++++ internal/utils.go | 12 ++++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 9d26bf7..93b4431 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -480,7 +480,6 @@ func (kc *KeycardContextV2) checkSCardError(err error, context string) error { func (kc *KeycardContextV2) selectApplet() (*ApplicationInfoV2, error) { info, err := kc.SelectApplet() if err != nil { - kc.status.State = ConnectionError return nil, err } @@ -489,7 +488,7 @@ func (kc *KeycardContextV2) selectApplet() (*ApplicationInfoV2, error) { func (kc *KeycardContextV2) updateApplicationStatus() error { appStatus, err := kc.cmdSet.GetStatusApplication() - kc.status.AppStatus = appStatus + kc.status.AppStatus = ToAppStatus(appStatus) if err != nil { kc.status.State = ConnectionError diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index 5693860..616b4a2 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -2,8 +2,6 @@ package internal import ( "errors" - - "github.com/status-im/keycard-go/types" ) type State string @@ -28,10 +26,10 @@ const ( ) type Status struct { - State State `json:"state"` - AppInfo *ApplicationInfoV2 `json:"keycardInfo"` - AppStatus *types.ApplicationStatus `json:"keycardStatus"` - Metadata *Metadata `json:"metadata"` + State State `json:"state"` + AppInfo *ApplicationInfoV2 `json:"keycardInfo"` + AppStatus *ApplicationStatus `json:"keycardStatus"` + Metadata *Metadata `json:"metadata"` } func NewStatus() *Status { diff --git a/internal/types.go b/internal/types.go index 9e8833b..fca526c 100644 --- a/internal/types.go +++ b/internal/types.go @@ -33,6 +33,13 @@ type ApplicationInfoV2 struct { KeyUID utils.HexString `json:"keyUID"` } +type ApplicationStatus struct { + RemainingAttemptsPIN int `json:"remainingAttemptsPIN"` + RemainingAttemptsPUK int `json:"remainingAttemptsPUK"` + KeyInitialized bool `json:"keyInitialized"` + Path string `json:"path"` +} + type KeyPair struct { Address string `json:"address"` PublicKey utils.HexString `json:"publicKey"` diff --git a/internal/utils.go b/internal/utils.go index bcd0fba..a6b9314 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -86,6 +86,18 @@ func ToAppInfoV2(r *ktypes.ApplicationInfo) *ApplicationInfoV2 { } } +func ToAppStatus(r *ktypes.ApplicationStatus) *ApplicationStatus { + if r == nil { + return nil + } + return &ApplicationStatus{ + RemainingAttemptsPIN: r.PinRetryCount, + RemainingAttemptsPUK: r.PUKRetryCount, + KeyInitialized: r.KeyInitialized, + Path: r.Path, + } +} + func ToSignature(r *ktypes.Signature) *Signature { return &Signature{ R: r.R(), From 75b1c9eac08de708724e2ee36909764fcafa858e Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 23 Jan 2025 11:45:15 +0100 Subject: [PATCH 26/35] docs: description of the Session API --- README.md | 30 +++++ api/README.md | 190 +++++++++++++++++++++++++++ internal/keycard_context_v2_state.go | 75 ++++++++--- 3 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 README.md create mode 100644 api/README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f90e145 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# status-keycard-go + +This library provides a higher level Keycard API for Status app. It is currently only used in [status-desktop](https://github.com/status-im/status-desktop/). + +There are 2 types of API provided. + +## Flow API + +Each keycard command is executed in a single _flow_. A flow roughly looks like this: + 1. List available readers + 2. Look for a keycard + 3. Set up a connection + 4. Execute the command + 5. Close the connection + +If client interaction is required at any stage (e.g. insert a card, input a PIN), the flow is "paused" and signals to the client. The client should manually continue the flow when the required action was performed. This basically drives the UI right from `status-keycard-go` library. + +> [!NOTE] +> status-desktop doesn't use this API anymore. Consider switching to Session API. + +## Session API + +The main problem with Flow API is that it does not signal certain changes, e.g. "reader disconnected" and "card removed". Session API addresses this issue. + +The journey begins with `Start` endpoint. When the keycard service is started, it monitors all connected readers and cards. This allows to track the state of reader+card and notify the client on any change. As soon as a keycard is found, a "connection" (pair and open secure channel) is established automatically and will be reused until `Stop` is called or the keycard is removed. + +In the `Ready`/`Authorized` states client can execute wanted commands, each as a separate endpoint. + +Check out the detailed usage in ./api/README.md + diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..4297a6c --- /dev/null +++ b/api/README.md @@ -0,0 +1,190 @@ +> [!NOTE] +> This guide is not comprehensive and relies on your wisdom and intelligence. +> Only _session_ API is considered. For the _flow_ API please check out the source code. + +# Description + +This directory contains `*.http` request for each available endpoint in the Session API. + +# Usage + +Session API uses JSON-RPC protocol. All commands are available at `keycard` service. Here is an example: +```json +{ + "id": "1", + "method": "keycard.Authorize", + "params": [ + { + "pin": "654321" + } + ] +} +``` + +There are 2 ways to access the API. + +## HTTP + +This way is easier to use for testing and debugging. + +1. Run the server: + ```shell + go run ./cmd/status-keycard-server/main.go --address=localhost:12346 + ``` + +2. Connect to signals websocket at `ws://localhost:12346/signals` + +3. Send requests to `http://localhost:12346/rpc` + +## C bindings + +This is the way to integrate `status-keycard-go` library, e.g. how `status-desktop` uses it. + +To subscribe to signals, set a callback function with `setSignalEventCallback`. + +For the RPC server, there are 2 methods provided: +1. `InitializeRPC` - must be called once at the start of the application, before making any RPC calls. +2. `CallRPC` - call with a single JSON string argument according to the JSON-RPC protocol. Returns a single JSON string response. + + +# Setup + +1. Connect to signals + For the session API, the only emitted signal is `status-changed`. + It provides current status of the session and information about connected keycard. +2. Call `Start` + From this moment, until `Stop` is called, the keycard service will take care of watching readers/cards and keeping a secure "connection" with a keycard. + Provide `StorageFilePath`, e.g. `~/pairings.json`. This file will be used to store pairing keys for all paired keycards. +3. If planning to execute any authorization-required commands, call `Authorize` +4. Monitor state of the session, execute any needed commands. + NOTE: Some of the keycard commands can only be executed in `ready` or `authorized` state. +5. Call `Stop` + +# Simulation + +Because it is difficult (perhaps nearly impossible) to implement proper simulation of a keycard, +this library provides a way to simulate certain errors, which is not simple/possible to achieve with hardware. + +Check [`SimulateErrro`](#simulateerror) method for details + +# API + +## Signals + +Signals follow the structure described here: https://github.com/keycard-tech/status-keycard-go/blob/b1e1f7f0bf534269a5c18fcd31649d2056b13e5b/signal/signals.go#L27-L31 + +The only signal type used in Session API is `status-changed`. For event structure, check out [Status](#status) + +## Service endpoints + +These endpoints are related to the `status-keycard-go` library itself: + +## `Start` + +Starts the monitoring of readers and cards. + +The monitoring starts with _detect_ mode. +In this mode it checks all connected readers for a smart cards. Monitoring supports events (like reader connection and card insertion) to happen even after calling `Start`. + +As soon as a reader with a Keycard is found, the monitoring switches to _watch_ mode: +- Only the reader with the keycard is watched. If the keycard is removed, or the reader is disconnected, the monitoring goes back to _detect_ mode. +- Any new connected readers, or inserted smart cards on other readers, are ignored. + +[//]: # (TODO: Diagram) + +## `Stop` + +Stops the monitoring. + +## `SimulateError` + +Marks that certain error should be simulated. + +For the `simulated-not-a-keycard` error, `InstanceUID` argument must be provided. Only keycards with such `InstanceUID` will be treated as not keycards. +Other errors are applied no matter of the `InstanceUID` value. + +`SimulateError` can also be called before `Start`, e.g. to simulate `simulated-no-pcsc` error, as this one can only happen during `Start` operation. + +Use `SimulateError` method with one of the supported simulation errors: https://github.com/keycard-tech/status-keycard-go/blob/a3804cc8848a93a277895e508dd7c423f1f8338c/internal/keycard_context_v2_state.go#L55-L62 + +## `GetStatus` + +Returns current status of the session. + +In most scenarios `status-changed` signal should be used to get status. Yet in some debugging circumstances this method can be handy to get the latest status. + +## Status + +Here is the structure of the status: https://github.com/keycard-tech/status-keycard-go/blob/a3804cc8848a93a277895e508dd7c423f1f8338c/internal/keycard_context_v2_state.go#L30-L35 + +The main field is `state` + +### State + +Check the source code for the list of possible states and their description. +https://github.com/keycard-tech/status-keycard-go/blob/a3804cc8848a93a277895e508dd7c423f1f8338c/internal/keycard_context_v2_state.go#L11-L73 + +## Commands + +Apart from the service endpoints listed above, all other endpoints represent the actual [Keycard API](https://keycard.tech/docs/apdu). + +Most of the commands have to be executed in `ready` or `authorized` states. Service will return a readable error if the keycard is not in the proper state for the command. + +Please check out the Keycard documentation for more details. + +## Examples + +The examples are presented in a "you'll get it" form. +`<-` represents a reception of `status-changed` signal. + +### Initialize a new Keycard + +```go +Start("~/pairings.json") +<- "waiting-for-reader" +// connect a reader +<- "waiting-for-card" +// insert a keycard +<- "connecting-card" +<- "empty-keycard", AppInfo: { InstanceUID: "abcd" }, AppStatus: null +Initialize(pin: "123456", puk: "123456123456") +<- "ready", Appinfo: ..., AppStatus: { remainingAttemptsPIN: 3, remainingAttemptsPUK: 5, ... } +Authorize(pin: "123456") +<- "autorized", AppInfo: ..., AppStatus ... +ExportLoginKeys() +``` + +### Unblock a Keycard + +```go +Start("~/pairings.json") +<- "waiting-for-reader" +// connect a reader with a keycard +<- "connecting-card" +<- "blocked-pin", AppInfo: { InstanceUID: "abcd" }, AppStatus: { remainingAttemptsPIN: 0, remainingAttemptsPUK: 5, ... } +UnblockPIN(puk: "123456123456") +<- "authorized", AppInfo: ..., AppStatus: { remainingAttemptsPIN: 3, remainingAttemptsPUK: 5, ... } +``` + +### Factory reset a completely blocked Keycard + +```go +Start("~/pairings.json") +<- "waiting-for-reader" +// connect a reader with a keycard +<- "connecting-card" +<- "blocked-puk", AppInfo: { InstanceUID: "abcd" }, AppStatus: { remainingAttemptsPIN: 0, remainingAttemptsPUK: 0, ... } +FactoryReset() +<- "factory-resetting" +<- "empty-keycard" +``` + +# Implementation decisions + +1. Monitoring detect mode utilizes [`\\?PnP?\Notification`](https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/) feature to detect new connected readers without any CPU load. +2. Monitoring watch mode could use a blocking call to `GetStatusChange`, but this did not work on Linux (Ubuntu), although worked on MacOS. +So instead there is a loop that checks the state of the reader each 500ms. +3. JSON-RPC was chosen for 2 reasons: + - to expose API to HTTP for testing/debugging + - to simplify the adding new methods to the API +gRPC was also considered, but this would require more work on `status-desktop`. diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index 616b4a2..ca6c08d 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -7,22 +7,67 @@ import ( type State string const ( - UnknownReaderState State = "unknown" - NoPCSC State = "no-pcsc" - InternalError State = "internal-error" - WaitingForReader State = "waiting-for-reader" - WaitingForCard State = "waiting-for-card" - ConnectingCard State = "connecting-card" - ConnectionError State = "connection-error" - NotKeycard State = "not-keycard" - EmptyKeycard State = "empty-keycard" + // UnknownReaderState is the default state when the monitoring was not started. + UnknownReaderState State = "unknown" + + // NoPCSC - PCSC library was not found. Can only happen during Start. + NoPCSC State = "no-pcsc" + + // InternalError - an internal error occurred. + // Should never happen, check logs for more details. Depending on circumstances, can stop the monitoring when occurred. + InternalError State = "internal-error" + + // WaitingForReader - no reader was found. + WaitingForReader State = "waiting-for-reader" + + // WaitingForCard - no card was found inserted into any of connected readers. + WaitingForCard State = "waiting-for-card" + + // ConnectingCard - a card was found inserted into a reader and the connection is being established. + // This state is usually very short, as the connection is established quickly. + ConnectingCard State = "connecting-card" + + // ConnectionError - an error occurred while connecting or communicating with the card. + // In all cases, the monitoring will continue to stay in the watch mode and expect the user to reinsert the card. + ConnectionError State = "connection-error" + + // NotKeycard - the card inserted is not a keycard (does not have Keycard applet installed) + NotKeycard State = "not-keycard" + + // EmptyKeycard - the keycard is empty, i.e. has not been initialized (PIN/PUK are not set). + // Use Initialize command to initialize the keycard. + EmptyKeycard State = "empty-keycard" + + // NoAvailablePairingSlots - there are no available pairing slots on the keycard. + // Use Unpair command to unpair an existing slot (this command must be executed from the paired devices), + // or use FactoryReset command to reset the keycard to factory settings. NoAvailablePairingSlots State = "no-available-pairing-slots" - PairingError State = "pairing-error" - BlockedPIN State = "blocked-pin" // PIN remaining attempts == 0 - BlockedPUK State = "blocked-puk" // PUK remaining attempts == 0 - Ready State = "ready" - Authorized State = "authorized" // PIN verified - FactoryResetting State = "factory-resetting" + + // PairingError - an error occurred during the pairing process. + // This can be due to a wrong pairing password. + PairingError State = "pairing-error" + + // BlockedPIN - the PIN is blocked (remaining attempts == 0). + // Use UnblockPIN command to unblock the PIN. + BlockedPIN State = "blocked-pin" + + // BlockedPUK - the PUK is blocked (remaining attempts == 0). + // The keycard is completely blocked. Use FactoryReset command to reset the keycard to factory settings + // and recover the keycard with recovery phrase. + BlockedPUK State = "blocked-puk" + + // Ready - the keycard is ready for use. + // The keycard is initialized, paired and secure channel is established. + // The PIN has not been verified, so only unauthenticated commands can be executed. + Ready State = "ready" + + // Authorized - the keycard is authorized (PIN verified). + // The keycard is in Ready state and the PIN has been verified, allowing authenticated commands to be executed. + Authorized State = "authorized" + + // FactoryResetting - the keycard is undergoing a factory reset. + // The keycard is being reset to factory settings. This process can take a few seconds. + FactoryResetting State = "factory-resetting" ) type Status struct { From bcf094518e5d3a8144b831669d833d70e7aed112 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Thu, 23 Jan 2025 12:59:01 +0100 Subject: [PATCH 27/35] docs: update state struct link --- api/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 4297a6c..8f0b00e 100644 --- a/api/README.md +++ b/api/README.md @@ -122,7 +122,7 @@ The main field is `state` ### State Check the source code for the list of possible states and their description. -https://github.com/keycard-tech/status-keycard-go/blob/a3804cc8848a93a277895e508dd7c423f1f8338c/internal/keycard_context_v2_state.go#L11-L73 +https://github.com/keycard-tech/status-keycard-go/blob/75b1c9eac08de708724e2ee36909764fcafa858e/internal/keycard_context_v2_state.go#L9-L71 ## Commands From e4da931305a243b1d0859ec27b121e92a003257a Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 24 Jan 2025 09:25:43 +0100 Subject: [PATCH 28/35] chore: jetbrains idea run configuration --- .gitignore | 3 ++- .idea/runConfigurations/status_keycard_server.xml | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .idea/runConfigurations/status_keycard_server.xml diff --git a/.gitignore b/.gitignore index a69c36c..59dac1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode -.idea +.idea/* +!.idea/runConfigurations /keycard /build /status-keycard-go diff --git a/.idea/runConfigurations/status_keycard_server.xml b/.idea/runConfigurations/status_keycard_server.xml new file mode 100644 index 0000000..7bff137 --- /dev/null +++ b/.idea/runConfigurations/status_keycard_server.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file From 5302740da597bc6a47245bbb233578f97c16ed05 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 24 Jan 2025 10:04:51 +0100 Subject: [PATCH 29/35] chore: minor cleanup --- internal/keycard_context_v2.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 93b4431..96ced11 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -33,12 +33,11 @@ var ( type KeycardContextV2 struct { KeycardContext - shutdown func() - forceScanC chan struct{} - logger *zap.Logger - pairings *pairing.Store - status *Status - readersState ReadersStates + shutdown func() + forceScanC chan struct{} + logger *zap.Logger + pairings *pairing.Store + status *Status // simulation options simulatedError error @@ -122,9 +121,9 @@ func (kc *KeycardContextV2) startDetectionLoop(ctx context.Context) { } logger := kc.logger.Named("detect") - logger.Debug("detect started") go func() { + logger.Debug("detect started") defer logger.Debug("detect stopped") // This goroutine will be stopped by cardCtx.Cancel() for { @@ -762,6 +761,7 @@ func (kc *KeycardContextV2) ExportRecoverKeys() (*RecoverKeys, error) { return nil, err } + // NOTE: In theory, if P2ExportKeyExtendedPublic is used, then we don't need to export the wallet key separately. keys.WalletKey, err = kc.exportKey(WalletPath, keycard.P2ExportKeyPublicOnly) if err != nil { return nil, err From 39b3adce6c990f8d0e5b18699b7451653104fb92 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 24 Jan 2025 13:06:59 +0100 Subject: [PATCH 30/35] fix: use exclusive connection, graceful disconnect and release (#15) --- internal/keycard_context_v2.go | 23 ++++++++++++++++++----- internal/readers_states.go | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 96ced11..399f0b9 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -97,7 +97,16 @@ func (kc *KeycardContextV2) cardCommunicationRoutine(ctx context.Context) { runtime.LockOSThread() defer runtime.UnlockOSThread() + defer func() { + kc.logger.Debug("card communication routine stopped") + err := kc.cardCtx.Release() + if err != nil { + kc.logger.Error("failed to release context", zap.Error(err)) + } + }() + for { + kc.logger.Debug("card communication routine started") select { case <-ctx.Done(): return @@ -139,8 +148,6 @@ func (kc *KeycardContextV2) startDetectionLoop(ctx context.Context) { // It will be stopped by cardCtx.Cancel() or when the context is done. // Returns false if the monitoring should be stopped by the runner. func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Logger) bool { - logger.Debug("detection tick") - // Get current readers list and state readers, err := kc.getCurrentReadersState() if err != nil { @@ -163,8 +170,6 @@ func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Lo logger.Error("failed to connect card", zap.Error(err)) } - kc.resetCardConnection() - // Wait for readers changes, including new readers // https://blog.apdu.fr/posts/2024/08/improved-scardgetstatuschange-for-pnpnotification-special-reader/ // NOTE: The article states that MacOS is not supported, but works for me on MacOS 15.1.1 (24B91). @@ -212,7 +217,7 @@ func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStat kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) activeReader := readers[readerWithCardIndex] - card, err := kc.cardCtx.Connect(activeReader.Reader, scard.ShareShared, scard.ProtocolAny) + card, err := kc.cardCtx.Connect(activeReader.Reader, scard.ShareExclusive, scard.ProtocolAny) err = kc.simulateError(err, simulatedCardConnectError) if err != nil { kc.status.State = ConnectionError @@ -403,6 +408,14 @@ func (kc *KeycardContextV2) connectKeycard() error { } func (kc *KeycardContextV2) resetCardConnection() { + if kc.card != nil { + err := kc.card.Disconnect(scard.LeaveCard) + + if err != nil { + kc.logger.Error("failed to disconnect card", zap.Error(err)) + } + } + kc.card = nil kc.c = nil kc.cmdSet = nil diff --git a/internal/readers_states.go b/internal/readers_states.go index a707a53..034d87c 100644 --- a/internal/readers_states.go +++ b/internal/readers_states.go @@ -21,7 +21,7 @@ func (rs ReadersStates) Update() { func (rs ReadersStates) ReaderWithCardIndex() (int, bool) { for i := range rs { - if rs[i].EventState&scard.StatePresent == 0 { + if rs[i].EventState&scard.StatePresent == 0 || rs[i].EventState&scard.StateExclusive != 0 { continue } From d7f74731454068647d066451f5f00d4361a9dc25 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 24 Jan 2025 15:27:38 +0100 Subject: [PATCH 31/35] feat: StoreMetadata --- api/GetMetadata.http | 2 +- api/StoreMetadata.http | 15 +++++++++ internal/keycard_context_v2.go | 56 +++++++++++++++++++++++++++++++++- pkg/session/service.go | 18 +++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 api/StoreMetadata.http diff --git a/api/GetMetadata.http b/api/GetMetadata.http index ee8f93f..f679ace 100644 --- a/api/GetMetadata.http +++ b/api/GetMetadata.http @@ -1,4 +1,4 @@ -# @name FactoryReset +# @name GetMetadata POST {{address}}/rpc { diff --git a/api/StoreMetadata.http b/api/StoreMetadata.http new file mode 100644 index 0000000..d262f31 --- /dev/null +++ b/api/StoreMetadata.http @@ -0,0 +1,15 @@ +# @name StoreMetadata +POST {{address}}/rpc + +{ + "id": "{{$random.uuid}}", + "method": "keycard.StoreMetadata", + "params": [ + { + "name": "{{$timestamp}}", + "paths": [ + "m/44'/60'/0'/0/1" + ] + } + ] +} \ No newline at end of file diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 399f0b9..cba3191 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -2,13 +2,16 @@ package internal import ( "context" + "fmt" "runtime" + "strings" "time" "github.com/ebfe/scard" "github.com/ethereum/go-ethereum/crypto" "github.com/pkg/errors" "github.com/status-im/keycard-go" + "github.com/status-im/keycard-go/derivationpath" "github.com/status-im/keycard-go/io" "github.com/status-im/keycard-go/types" "go.uber.org/zap" @@ -97,6 +100,8 @@ func (kc *KeycardContextV2) cardCommunicationRoutine(ctx context.Context) { runtime.LockOSThread() defer runtime.UnlockOSThread() + kc.logger.Debug("card communication routine started") + defer func() { kc.logger.Debug("card communication routine stopped") err := kc.cardCtx.Release() @@ -106,7 +111,6 @@ func (kc *KeycardContextV2) cardCommunicationRoutine(ctx context.Context) { }() for { - kc.logger.Debug("card communication routine started") select { case <-ctx.Done(): return @@ -687,6 +691,56 @@ func (kc *KeycardContextV2) GetMetadata() (*Metadata, error) { return ToMetadata(metadata), nil } +func (kc KeycardContextV2) parsePaths(paths []string) ([]uint32, error) { + parsedPaths := make([]uint32, len(paths)) + for i, path := range paths { + if !strings.HasPrefix(path, WalletRoothPath) { + return nil, fmt.Errorf("path '%s' does not start with wallet path '%s'", path, WalletRoothPath) + } + + _, components, err := derivationpath.Decode(path) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse path '%s'", path) + } + + // Store only the last part of the component, ignoring the common prefix + parsedPaths[i] = components[len(components)-1] + } + return parsedPaths, nil +} + +func (kc *KeycardContextV2) StoreMetadata(name string, paths []string) (err error) { + if err = kc.keycardAuthorized(); err != nil { + return err + } + + pathComponents, err := kc.parsePaths(paths) + if err != nil { + return err + } + + metadata, err := types.NewMetadata(name, pathComponents) + if err != nil { + return errors.Wrap(err, "failed to create metadata") + } + + defer func() { + if err != nil { + return + } + + err = kc.updateMetadata() + if err != nil { + return + } + + kc.publishStatus() + }() + + err = kc.cmdSet.StoreData(keycard.P1StoreDataPublic, metadata.Serialize()) + return kc.checkSCardError(err, "StoreMetadata") +} + func (kc *KeycardContextV2) exportedKeyToAddress(key *types.ExportedKey) (string, error) { if key.PubKey() == nil { return "", nil diff --git a/pkg/session/service.go b/pkg/session/service.go index 7238c61..35d5e9c 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -237,6 +237,24 @@ func (s *KeycardService) GetMetadata(args *struct{}, reply *GetMetadataResponse) return err } +type StoreMetadataRequest struct { + Name string `json:"name" validate:"required"` + Paths []string `json:"paths" validate:""` +} + +func (s *KeycardService) StoreMetadata(args *StoreMetadataRequest, reply *struct{}) error { + if s.keycardContext == nil { + return errKeycardServiceNotStarted + } + + err := validateRequest(args) + if err != nil { + return err + } + + return s.keycardContext.StoreMetadata(args.Name, args.Paths) +} + type ExportLoginKeysResponse struct { Keys *internal.LoginKeys `json:"keys"` } From 0a354c92f28918d166663163f879dea4823e2e9f Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Fri, 24 Jan 2025 15:45:35 +0100 Subject: [PATCH 32/35] fix: linter --- cmd/status-keycard-server/main.go | 7 ++++--- internal/keycard_context_v2.go | 1 - pkg/session/rpc.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/status-keycard-server/main.go b/cmd/status-keycard-server/main.go index 3548a37..dc87e42 100644 --- a/cmd/status-keycard-server/main.go +++ b/cmd/status-keycard-server/main.go @@ -2,14 +2,15 @@ package main import ( "flag" + "fmt" "os" "os/signal" "syscall" - "github.com/status-im/status-keycard-go/cmd/status-keycard-server/server" "go.uber.org/zap" - "fmt" "go.uber.org/zap/zapcore" + + "github.com/status-im/status-keycard-go/cmd/status-keycard-server/server" ) var ( @@ -54,6 +55,6 @@ func handleInterrupts() { signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(ch) - _ = <-ch + <-ch os.Exit(0) } diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index cba3191..fbe75b2 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -27,7 +27,6 @@ const ( ) var ( - errNotAKeycard = errors.New("not a keycard") errKeycardNotConnected = errors.New("keycard not connected") errKeycardNotReady = errors.New("keycard not ready") errKeycardNotAuthorized = errors.New("keycard not authorized") diff --git a/pkg/session/rpc.go b/pkg/session/rpc.go index dddc1ea..e33e8bc 100644 --- a/pkg/session/rpc.go +++ b/pkg/session/rpc.go @@ -1,8 +1,8 @@ package session import ( - gorillajson "github.com/gorilla/rpc/json" "github.com/gorilla/rpc" + "github.com/gorilla/rpc/json" ) var ( @@ -11,7 +11,7 @@ var ( func CreateRPCServer() (*rpc.Server, error) { rpcServer := rpc.NewServer() - rpcServer.RegisterCodec(gorillajson.NewCodec(), "application/json") + rpcServer.RegisterCodec(json.NewCodec(), "application/json") err := rpcServer.RegisterTCPService(&globalKeycardService, "keycard") return rpcServer, err } From 540829711ae126f013fa674e1df1b425888a60f1 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 29 Jan 2025 17:58:37 +0000 Subject: [PATCH 33/35] fix: SimulateError issues --- api/SimulateError.http | 3 +-- internal/keycard_context_v2.go | 37 ++++++++++------------------ internal/keycard_context_v2_state.go | 34 +++++++++++++++++-------- pkg/session/service.go | 16 ++++++------ 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/api/SimulateError.http b/api/SimulateError.http index a8feeb0..e556e17 100644 --- a/api/SimulateError.http +++ b/api/SimulateError.http @@ -6,8 +6,7 @@ POST {{address}}/rpc "method": "keycard.SimulateError", "params": [ { - "instanceUID": "", - "error": "simulated-card-connect-error" + "error": "simulated-no-pcsc" } ] } \ No newline at end of file diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index fbe75b2..0144590 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -42,8 +42,7 @@ type KeycardContextV2 struct { status *Status // simulation options - simulatedError error - simulationInstanceUID string + simulatedError error } func NewKeycardContextV2(pairingsStoreFilePath string) (*KeycardContextV2, error) { @@ -220,22 +219,14 @@ func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStat kc.logger.Debug("card found", zap.Int("index", readerWithCardIndex)) activeReader := readers[readerWithCardIndex] - card, err := kc.cardCtx.Connect(activeReader.Reader, scard.ShareExclusive, scard.ProtocolAny) + var err error + kc.card, err = kc.cardCtx.Connect(activeReader.Reader, scard.ShareExclusive, scard.ProtocolAny) err = kc.simulateError(err, simulatedCardConnectError) if err != nil { kc.status.State = ConnectionError return nil, errors.Wrap(err, "failed to connect to card") } - // FIXME: Do we actually need to get card status? - _, err = card.Status() - err = kc.simulateError(err, simulatedGetCardStatusError) - if err != nil { - kc.status.State = ConnectionError - return nil, errors.Wrap(err, "failed to get card status") - } - - kc.card = card kc.c = io.NewNormalChannel(kc) kc.cmdSet = keycard.NewCommandSet(kc.c) @@ -247,13 +238,6 @@ func (kc *KeycardContextV2) connectCard(ctx context.Context, readers ReadersStat return nil, errors.Wrap(err, "failed to select applet") } - // Check if 'not a keycard' simulation was requested for this card - simulatedError := kc.simulateError(nil, simulatedNotAKeycard) - keycardMatch := kc.simulationInstanceUID == appInfo.InstanceUID.String() - if simulatedError != nil && keycardMatch { - appInfo.Installed = false - } - // Save AppInfo kc.status.AppInfo = appInfo @@ -841,7 +825,7 @@ func (kc *KeycardContextV2) ExportRecoverKeys() (*RecoverKeys, error) { return keys, err } -func (kc *KeycardContextV2) SimulateError(err error, instanceUID string) error { +func (kc *KeycardContextV2) SimulateError(err error) error { // Ensure the error is one of the known errors to simulate if err != nil { if simulateErr := GetSimulatedError(err.Error()); simulateErr == nil { @@ -850,13 +834,18 @@ func (kc *KeycardContextV2) SimulateError(err error, instanceUID string) error { } kc.simulatedError = err - kc.simulationInstanceUID = instanceUID return nil } func (kc *KeycardContextV2) simulateError(currentError, errorToSimulate error) error { - if errors.Is(kc.simulatedError, errorToSimulate) { - return errorToSimulate + if !errors.Is(kc.simulatedError, errorToSimulate) { + return currentError + } + switch errorToSimulate { + case simulatedCardConnectError: + fallthrough + case simulatedSelectAppletError: + kc.resetCardConnection() // Make it look like we never connected } - return currentError + return errorToSimulate } diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index ca6c08d..cf1e641 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -95,13 +95,30 @@ func (s *Status) KeycardSupportsExtendedKeys() bool { } var ( - simulatedNoPCSC = errors.New("simulated-no-pcsc") - simulatedListReadersError = errors.New("simulated-list-readers-error") - simulatedGetStatusChangeError = errors.New("simulated-get-status-change-error") - simulatedCardConnectError = errors.New("simulated-card-connect-error") - simulatedGetCardStatusError = errors.New("simulated-get-card-status-error") - simulatedSelectAppletError = errors.New("simulated-select-applet-error") - simulatedNotAKeycard = errors.New("simulated-not-a-keycard") + // simulatedNoPCSC simulates failure in PCSC check. + // Call Start to trigger. + simulatedNoPCSC = errors.New("simulated-no-pcsc") + + // simulatedListReadersError simulates a failure when trying to list readers. + // Connect a reader to trigger. Results in `InternalError` state. + simulatedListReadersError = errors.New("simulated-list-readers-error") + + // simulatedGetStatusChangeError simulates a failure when trying to get status change. + // Connect a reader to trigger. Results in `InternalError` state. + simulatedGetStatusChangeError = errors.New("simulated-get-status-change-error") + + // simulatedNotAKeycard simulates a card connection issue. + // Insert a card to trigger. Results in `ConnectionError` state. + simulatedCardConnectError = errors.New("simulated-card-connect-error") + + // simulatedSelectAppletError simulates a failure when trying to select the applet. + // Insert the keycard to trigger. Results in `ConnectionError` state. + simulatedSelectAppletError = errors.New("simulated-select-applet-error") + + // simulatedOpenSecureChannelError happens when trying to open a secure channel with the keycard. + // The keycard must be initialized and paired to open a secure channel, so if not initialized, + // or in case of a pairing issue, this error will not occur. + // Insert an initialized card to trigger. Results in `ConnectionError` state. simulatedOpenSecureChannelError = errors.New("simulated-open-secure-channel-error") ) @@ -111,9 +128,6 @@ func GetSimulatedError(message string) error { simulatedListReadersError.Error(): simulatedListReadersError, simulatedGetStatusChangeError.Error(): simulatedGetStatusChangeError, simulatedCardConnectError.Error(): simulatedCardConnectError, - simulatedGetCardStatusError.Error(): simulatedGetCardStatusError, - simulatedSelectAppletError.Error(): simulatedSelectAppletError, - simulatedNotAKeycard.Error(): simulatedNotAKeycard, simulatedOpenSecureChannelError.Error(): simulatedOpenSecureChannelError, } return errs[message] diff --git a/pkg/session/service.go b/pkg/session/service.go index 35d5e9c..3179a35 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -25,9 +25,8 @@ func validateRequest(v interface{}) error { } type KeycardService struct { - keycardContext *internal.KeycardContextV2 - simulateError error - simulationInstanceUID string + keycardContext *internal.KeycardContextV2 + simulateError error } type StartRequest struct { @@ -45,7 +44,7 @@ func (s *KeycardService) Start(args *StartRequest, reply *struct{}) error { return err } - err = s.keycardContext.SimulateError(s.simulateError, s.simulationInstanceUID) + err = s.keycardContext.SimulateError(s.simulateError) if err != nil { return err } @@ -284,8 +283,7 @@ func (s *KeycardService) ExportRecoverKeys(args *struct{}, reply *ExportRecovere } type SimulateErrorRequest struct { - Error string `json:"error"` - InstanceUID string `json:"instanceUID"` + Error string `json:"error"` } func (s *KeycardService) SimulateError(args *SimulateErrorRequest, reply *struct{}) error { @@ -299,11 +297,11 @@ func (s *KeycardService) SimulateError(args *SimulateErrorRequest, reply *struct return errors.New("unknown error to simulate") } + s.simulateError = errToSimulate + if s.keycardContext == nil { - s.simulateError = errToSimulate - s.simulationInstanceUID = args.InstanceUID return nil } - return s.keycardContext.SimulateError(errToSimulate, args.InstanceUID) + return s.keycardContext.SimulateError(errToSimulate) } From 5fa2c5f236990fdbeb2647b7482f8a5f6545b8ae Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 29 Jan 2025 18:11:55 +0000 Subject: [PATCH 34/35] feat: improved pre-command status checks --- internal/keycard_context_v2.go | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/internal/keycard_context_v2.go b/internal/keycard_context_v2.go index 0144590..1462229 100644 --- a/internal/keycard_context_v2.go +++ b/internal/keycard_context_v2.go @@ -27,9 +27,11 @@ const ( ) var ( - errKeycardNotConnected = errors.New("keycard not connected") - errKeycardNotReady = errors.New("keycard not ready") - errKeycardNotAuthorized = errors.New("keycard not authorized") + errKeycardNotConnected = errors.New("keycard not connected") + errKeycardNotInitialized = errors.New("keycard not initialized") + errKeycardNotReady = errors.New("keycard not ready") + errKeycardNotAuthorized = errors.New("keycard not authorized") + errKeycardNotBlocked = errors.New("keycard not blocked") ) type KeycardContextV2 struct { @@ -163,7 +165,7 @@ func (kc *KeycardContextV2) detectionRoutine(ctx context.Context, logger *zap.Lo if card != nil { err = kc.connectKeycard() if err != nil { - logger.Error("failed to connect card", zap.Error(err)) + logger.Error("failed to connect keycard", zap.Error(err)) } go kc.watchActiveReader(ctx, card.readerState) return false @@ -440,10 +442,20 @@ func (kc *KeycardContextV2) keycardConnected() bool { return kc.cmdSet != nil } -func (kc *KeycardContextV2) keycardReady() error { +func (kc *KeycardContextV2) keycardInitialized() error { if !kc.keycardConnected() { return errKeycardNotConnected } + if kc.status.State == EmptyKeycard { + return errKeycardNotInitialized + } + return nil +} + +func (kc *KeycardContextV2) keycardReady() error { + if err := kc.keycardInitialized(); err != nil { + return err + } if kc.status.State != Ready && kc.status.State != Authorized { return errKeycardNotReady } @@ -451,8 +463,8 @@ func (kc *KeycardContextV2) keycardReady() error { } func (kc *KeycardContextV2) keycardAuthorized() error { - if !kc.keycardConnected() { - return errKeycardNotConnected + if err := kc.keycardInitialized(); err != nil { + return err } if kc.status.State != Authorized { return errKeycardNotAuthorized @@ -579,8 +591,12 @@ func (kc *KeycardContextV2) ChangePIN(pin string) error { } func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) (err error) { - if !kc.keycardConnected() { - return errKeycardNotConnected + if err = kc.keycardInitialized(); err != nil { + return err + } + + if kc.status.State != BlockedPIN { + return errKeycardNotBlocked } defer func() { @@ -653,8 +669,8 @@ func (kc *KeycardContextV2) FactoryReset() error { } func (kc *KeycardContextV2) GetMetadata() (*Metadata, error) { - if !kc.keycardConnected() { - return nil, errKeycardNotConnected + if err := kc.keycardReady(); err != nil { + return nil, err } data, err := kc.cmdSet.GetData(keycard.P1StoreDataPublic) From c1285673bd1b5ec6f7dd03faa82e00bcdaa0ef75 Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Wed, 29 Jan 2025 20:36:33 +0000 Subject: [PATCH 35/35] fix: add missing simulatedSelectAppletError --- internal/keycard_context_v2_state.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/keycard_context_v2_state.go b/internal/keycard_context_v2_state.go index cf1e641..bb0a580 100644 --- a/internal/keycard_context_v2_state.go +++ b/internal/keycard_context_v2_state.go @@ -128,6 +128,7 @@ func GetSimulatedError(message string) error { simulatedListReadersError.Error(): simulatedListReadersError, simulatedGetStatusChangeError.Error(): simulatedGetStatusChangeError, simulatedCardConnectError.Error(): simulatedCardConnectError, + simulatedSelectAppletError.Error(): simulatedSelectAppletError, simulatedOpenSecureChannelError.Error(): simulatedOpenSecureChannelError, } return errs[message]