feat: Session API (#13)

* feat: KeycardContextV2

* feat: ApplicationInfoV2 with string version

* feat: rpc service

* feat: http server for the session API

* feat: ensure api mutual exclusivity

* chore: go mod tidy
This commit is contained in:
Igor Sirotin 2025-01-29 21:01:47 +00:00 committed by GitHub
parent 6e63788d31
commit efec05e104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2264 additions and 13 deletions

4
.gitignore vendored
View File

@ -1,5 +1,7 @@
.vscode
.idea
.idea/*
!.idea/runConfigurations
/keycard
/build
/status-keycard-go
/api/http-client.private.env.json

View File

@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="status-keycard-server" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="status-keycard-go" />
<working_directory value="$PROJECT_DIR$" />
<parameters value="--address=localhost:12346" />
<kind value="PACKAGE" />
<package value="github.com/status-im/status-keycard-go/cmd/status-keycard-server" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$/cmd/status-keycard-server/main.go" />
<method v="2" />
</configuration>
</component>

30
README.md Normal file
View File

@ -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

12
api/Authorize.http Normal file
View File

@ -0,0 +1,12 @@
# @name Authorize
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.Authorize",
"params": [
{
"pin": "654321"
}
]
}

12
api/ChangePIN.http Normal file
View File

@ -0,0 +1,12 @@
# @name ChangePIN
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.ChangePIN",
"params": [
{
"newPIN": "654321"
}
]
}

12
api/ChangePUK.http Normal file
View File

@ -0,0 +1,12 @@
# @name ChangePUK
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.ChangePUK",
"params": [
{
"newPuk": "654321654322"
}
]
}

8
api/ExportLoginKeys.http Normal file
View File

@ -0,0 +1,8 @@
# @name ExportLoginKeys
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.ExportLoginKeys",
"params": []
}

View File

@ -0,0 +1,8 @@
# @name ExportRecoverKeys
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.ExportRecoverKeys",
"params": []
}

8
api/FactoryReset.http Normal file
View File

@ -0,0 +1,8 @@
# @name FactoryReset
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.FactoryReset",
"params": []
}

12
api/GenerateMnemonic.http Normal file
View File

@ -0,0 +1,12 @@
# @name Initialize
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.GenerateMnemonic",
"params": [
{
"length": 12
}
]
}

8
api/GetMetadata.http Normal file
View File

@ -0,0 +1,8 @@
# @name GetMetadata
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.GetMetadata",
"params": []
}

8
api/GetStatus.http Normal file
View File

@ -0,0 +1,8 @@
# @name GetStatus
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.GetStatus",
"params": []
}

13
api/Initialize.http Normal file
View File

@ -0,0 +1,13 @@
# @name Initialize
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.Initialize",
"params": [
{
"pin": "654321",
"puk": "654321654321"
}
]
}

12
api/LoadMnemonic.http Normal file
View File

@ -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"
}
]
}

190
api/README.md Normal file
View File

@ -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/75b1c9eac08de708724e2ee36909764fcafa858e/internal/keycard_context_v2_state.go#L9-L71
## 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`.

3
api/Signals.http Normal file
View File

@ -0,0 +1,3 @@
# @name Signals
WEBSOCKET ws://{{address}}/signals
Content-Type: application/json

12
api/SimulateError.http Normal file
View File

@ -0,0 +1,12 @@
# @name SimulateError
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.SimulateError",
"params": [
{
"error": "simulated-no-pcsc"
}
]
}

12
api/Start.http Normal file
View File

@ -0,0 +1,12 @@
# @name Start
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.Start",
"params": [
{
"storageFilePath": "{{storageFilePath}}"
}
]
}

8
api/Stop.http Normal file
View File

@ -0,0 +1,8 @@
# @name Stop
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.Stop",
"params": []
}

15
api/StoreMetadata.http Normal file
View File

@ -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"
]
}
]
}

13
api/Unblock.http Normal file
View File

@ -0,0 +1,13 @@
# @name Unblock
POST {{address}}/rpc
{
"id": "{{$random.uuid}}",
"method": "keycard.Unblock",
"params": [
{
"puk": "654321654321",
"newPIN": "654321"
}
]
}

View File

@ -0,0 +1,60 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/status-im/status-keycard-go/cmd/status-keycard-server/server"
)
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)
}

View File

@ -0,0 +1,161 @@
package server
import (
"context"
"net"
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"go.uber.org/zap"
"github.com/status-im/status-keycard-go/pkg/session"
"github.com/status-im/status-keycard-go/signal"
)
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 {
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{}{}
}

17
go.mod
View File

@ -5,14 +5,25 @@ 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
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 (
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
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
)

42
go.sum
View File

@ -12,19 +12,45 @@ 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/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=

View File

@ -0,0 +1,867 @@
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"
"github.com/status-im/status-keycard-go/pkg/pairing"
"github.com/status-im/status-keycard-go/signal"
)
const (
infiniteTimeout = -1
zeroTimeout = 0
monitoringTick = 500 * time.Millisecond
)
var (
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 {
KeycardContext
shutdown func()
forceScanC chan struct{}
logger *zap.Logger
pairings *pairing.Store
status *Status
// simulation options
simulatedError error
}
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("context"),
pairings: pairingsStore,
status: NewStatus(),
}
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 err
}
ctx, cancel := context.WithCancel(context.Background())
kc.shutdown = cancel
kc.forceScanC = nil
go kc.cardCommunicationRoutine(ctx)
kc.startDetectionLoop(ctx)
return nil
}
func (kc *KeycardContextV2) establishContext() error {
cardCtx, err := scard.EstablishContext()
if err != nil {
return errors.New(ErrorPCSC)
}
kc.cardCtx = cardCtx
return nil
}
func (kc *KeycardContextV2) cardCommunicationRoutine(ctx context.Context) {
// Communication with the keycard must be done in a fixed thread
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()
if err != nil {
kc.logger.Error("failed to release context", zap.Error(err))
}
}()
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) startDetectionLoop(ctx context.Context) {
if kc.cardCtx == nil {
panic("card context is nil")
}
logger := kc.logger.Named("detect")
go func() {
logger.Debug("detect started")
defer logger.Debug("detect 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 {
// 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()
return false
}
card, err := kc.connectCard(ctx, readers)
if card != nil {
err = kc.connectKeycard()
if err != nil {
logger.Error("failed to connect keycard", zap.Error(err))
}
go kc.watchActiveReader(ctx, card.readerState)
return false
}
if err != nil {
logger.Error("failed to connect card", 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).
const pnpNotificationReader = `\\?PnP?\Notification`
pnpReader := scard.ReaderState{
Reader: pnpNotificationReader,
CurrentState: scard.StateUnaware,
}
rs := append(readers, pnpReader)
err = kc.cardCtx.GetStatusChange(rs, infiniteTimeout)
if err == scard.ErrCancelled {
// Shutdown requested
return false
}
if err != nil {
logger.Error("failed to get status change", zap.Error(err))
return false
}
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.forceScanC = make(chan struct{})
kc.resetCardConnection()
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]
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")
}
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")
}
// Save AppInfo
kc.status.AppInfo = appInfo
if !appInfo.Installed {
kc.status.State = NotKeycard
return nil, nil
}
kc.status.State = ConnectingCard
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,
}
for {
err := kc.cardCtx.GetStatusChange(readersStates, zeroTimeout)
if err != nil && err != scard.ErrTimeout {
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()
// 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
case _, ok := <-kc.forceScanC:
if ok {
kc.startDetectionLoop(ctx)
}
return
}
}
kc.startDetectionLoop(ctx)
}
func (kc *KeycardContextV2) getCurrentReadersState() (ReadersStates, error) {
readers, err := kc.cardCtx.ListReaders()
err = kc.simulateError(err, simulatedListReadersError)
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)
err = kc.simulateError(err, simulatedGetStatusChangeError)
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) connectKeycard() error {
var err error
appInfo := kc.status.AppInfo
defer kc.publishStatus()
if !appInfo.Initialized {
kc.status.State = EmptyKeycard
return nil
}
pair := kc.pairings.Get(appInfo.InstanceUID.String())
if pair == nil {
kc.logger.Debug("pairing not found, pairing now")
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")
}
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")
}
// 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)
err = kc.simulateError(err, simulatedOpenSecureChannelError)
if err != nil {
kc.status.State = ConnectionError
return errors.Wrap(err, "failed to open secure channel")
}
err = kc.updateApplicationStatus() // Changes status to Ready
if err != nil {
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
}
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
}
func (kc *KeycardContextV2) forceScan() {
kc.forceScanC <- struct{}{}
}
func (kc *KeycardContextV2) publishStatus() {
kc.logger.Info("status changed", zap.Any("status", kc.status))
signal.Send("status-changed", kc.status)
}
func (kc *KeycardContextV2) Stop() {
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()
}
}
func (kc *KeycardContextV2) keycardConnected() bool {
return kc.cmdSet != nil
}
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
}
return nil
}
func (kc *KeycardContextV2) keycardAuthorized() error {
if err := kc.keycardInitialized(); err != nil {
return err
}
if kc.status.State != Authorized {
return errKeycardNotAuthorized
}
return 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()
kc.forceScan()
}
return err
}
func (kc *KeycardContextV2) selectApplet() (*ApplicationInfoV2, error) {
info, err := kc.SelectApplet()
if err != nil {
return nil, err
}
return ToAppInfoV2(info), err
}
func (kc *KeycardContextV2) updateApplicationStatus() error {
appStatus, err := kc.cmdSet.GetStatusApplication()
kc.status.AppStatus = ToAppStatus(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) updateMetadata() error {
metadata, err := kc.GetMetadata()
if err != nil {
kc.status.State = ConnectionError
return err
}
kc.status.Metadata = metadata
return nil
}
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)
if err != nil {
return kc.checkSCardError(err, "Init")
}
// Reset card connection to pair the card and open secure channel
kc.resetCardConnection()
kc.forceScan()
return nil
}
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()
}
func (kc *KeycardContextV2) VerifyPIN(pin string) (err error) {
if err = kc.keycardReady(); err != nil {
return err
}
defer func() {
authorized := err == nil
kc.onAuthorizeInteractions(authorized)
}()
err = kc.cmdSet.VerifyPIN(pin)
return kc.checkSCardError(err, "VerifyPIN")
}
func (kc *KeycardContextV2) ChangePIN(pin string) error {
if err := kc.keycardAuthorized(); err != nil {
return err
}
defer func() {
kc.onAuthorizeInteractions(false)
}()
err := kc.cmdSet.ChangePIN(pin)
return kc.checkSCardError(err, "ChangePIN")
}
func (kc *KeycardContextV2) UnblockPIN(puk string, newPIN string) (err error) {
if err = kc.keycardInitialized(); err != nil {
return err
}
if kc.status.State != BlockedPIN {
return errKeycardNotBlocked
}
defer func() {
authorized := err == nil
kc.onAuthorizeInteractions(authorized)
}()
err = kc.cmdSet.UnblockPIN(puk, newPIN)
return kc.checkSCardError(err, "UnblockPIN")
}
func (kc *KeycardContextV2) ChangePUK(puk string) error {
if err := kc.keycardAuthorized(); err != nil {
return err
}
defer func() {
kc.onAuthorizeInteractions(false)
}()
err := kc.cmdSet.ChangePUK(puk)
return kc.checkSCardError(err, "ChangePUK")
}
func (kc *KeycardContextV2) GenerateMnemonic(mnemonicLength int) ([]int, error) {
if err := kc.keycardReady(); err != nil {
return nil, err
}
indexes, err := kc.cmdSet.GenerateMnemonic(mnemonicLength / 3)
return indexes, kc.checkSCardError(err, "GenerateMnemonic")
}
func (kc *KeycardContextV2) LoadMnemonic(mnemonic string, password string) ([]byte, error) {
if err := kc.keycardAuthorized(); err != nil {
return nil, err
}
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)
return keyUID, kc.checkSCardError(err, "LoadMnemonic")
}
func (kc *KeycardContextV2) FactoryReset() error {
if !kc.keycardConnected() {
return errKeycardNotConnected
}
kc.status.Reset(FactoryResetting)
kc.publishStatus()
kc.logger.Debug("factory reset")
err := kc.KeycardContext.FactoryReset(true)
// Reset card connection to read the card data
kc.resetCardConnection()
kc.forceScan()
return err
}
func (kc *KeycardContextV2) GetMetadata() (*Metadata, error) {
if err := kc.keycardReady(); err != nil {
return nil, err
}
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
}
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
}
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 err := kc.keycardAuthorized(); err != nil {
return nil, err
}
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 err := kc.keycardAuthorized(); err != nil {
return nil, err
}
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
}
// 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
}
keys.MasterKey, err = kc.exportKey(MasterPath, keycard.P2ExportKeyPublicOnly)
if err != nil {
return nil, err
}
return keys, err
}
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 {
return errors.New("unknown error to simulate")
}
}
kc.simulatedError = err
return nil
}
func (kc *KeycardContextV2) simulateError(currentError, errorToSimulate error) error {
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 errorToSimulate
}

View File

@ -0,0 +1,135 @@
package internal
import (
"errors"
)
type State string
const (
// 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 - 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 {
State State `json:"state"`
AppInfo *ApplicationInfoV2 `json:"keycardInfo"`
AppStatus *ApplicationStatus `json:"keycardStatus"`
Metadata *Metadata `json:"metadata"`
}
func NewStatus() *Status {
status := &Status{}
status.Reset(UnknownReaderState)
return status
}
func (s *Status) Reset(newState State) {
s.State = newState
s.AppInfo = nil
s.AppStatus = nil
s.Metadata = nil
}
func (s *Status) KeycardSupportsExtendedKeys() bool {
return s.AppInfo != nil && s.AppInfo.versionRaw >= 0x0310
}
var (
// 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")
)
func GetSimulatedError(message string) error {
errs := map[string]error{
simulatedNoPCSC.Error(): simulatedNoPCSC,
simulatedListReadersError.Error(): simulatedListReadersError,
simulatedGetStatusChangeError.Error(): simulatedGetStatusChangeError,
simulatedCardConnectError.Error(): simulatedCardConnectError,
simulatedSelectAppletError.Error(): simulatedSelectAppletError,
simulatedOpenSecureChannelError.Error(): simulatedOpenSecureChannelError,
}
return errs[message]
}

View File

@ -0,0 +1,67 @@
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 || rs[i].EventState&scard.StateExclusive != 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
}
func (rs ReadersStates) HasChanges() bool {
for _, state := range rs {
if state.EventState&scard.StateChanged != 0 {
return true
}
}
return false
}
func (rs ReadersStates) Names() []string {
readers := make([]string, 0, len(rs))
for _, state := range rs {
readers = append(readers, state.Reader)
}
return readers
}

View File

@ -20,6 +20,26 @@ type ApplicationInfo struct {
KeyUID utils.HexString `json:"keyUID"`
}
// 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"`
versionRaw int `json:"-"`
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 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"`
@ -37,3 +57,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"`
}

View File

@ -2,9 +2,10 @@ 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"
)
@ -57,6 +58,46 @@ func ToAppInfo(r *ktypes.ApplicationInfo) ApplicationInfo {
}
}
func ParseVersion(input []byte) string {
if len(input) == 0 {
return ""
}
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 nil
}
return &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,
}
}
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(),

17
pkg/session/rpc.go Normal file
View File

@ -0,0 +1,17 @@
package session
import (
"github.com/gorilla/rpc"
"github.com/gorilla/rpc/json"
)
var (
globalKeycardService KeycardService
)
func CreateRPCServer() (*rpc.Server, error) {
rpcServer := rpc.NewServer()
rpcServer.RegisterCodec(json.NewCodec(), "application/json")
err := rpcServer.RegisterTCPService(&globalKeycardService, "keycard")
return rpcServer, err
}

307
pkg/session/service.go Normal file
View File

@ -0,0 +1,307 @@
package session
import (
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"
)
var (
errKeycardServiceNotStarted = errors.New("keycard service not started")
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
simulateError error
}
type StartRequest struct {
StorageFilePath string `json:"storageFilePath" validate:"required"`
}
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 {
return err
}
err = s.keycardContext.SimulateError(s.simulateError)
if err != nil {
return err
}
return s.keycardContext.Start()
}
func (s *KeycardService) Stop(args *struct{}, reply *struct{}) error {
if s.keycardContext == nil {
return nil
}
s.keycardContext.Stop()
s.keycardContext = nil
return nil
}
// 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) Initialize(args *InitializeRequest, reply *struct{}) error {
if s.keycardContext == nil {
return errKeycardServiceNotStarted
}
err := validateRequest(args)
if err != nil {
return err
}
if args.PairingPassword == "" {
args.PairingPassword = internal.DefPairing
}
err = s.keycardContext.Initialize(args.PIN, args.PUK, args.PairingPassword)
return err
}
type AuthorizeRequest struct {
PIN string `json:"pin" validate:"required,len=6"`
}
type AuthorizeResponse struct {
Authorized bool `json:"authorized"`
}
func (s *KeycardService) Authorize(args *AuthorizeRequest, reply *AuthorizeResponse) error {
if s.keycardContext == nil {
return errKeycardServiceNotStarted
}
err := s.keycardContext.VerifyPIN(args.PIN)
reply.Authorized = err == nil
return err
}
type ChangePINRequest struct {
NewPIN string `json:"newPin" validate:"required,len=6"`
}
func (s *KeycardService) ChangePIN(args *ChangePINRequest, reply *struct{}) error {
if s.keycardContext == nil {
return errKeycardServiceNotStarted
}
err := validateRequest(args)
if err != nil {
return err
}
err = s.keycardContext.ChangePIN(args.NewPIN)
return err
}
type ChangePUKRequest struct {
NewPUK string `json:"newPuk" validate:"required,len=12"`
}
func (s *KeycardService) ChangePUK(args *ChangePUKRequest, reply *struct{}) error {
if s.keycardContext == nil {
return errKeycardServiceNotStarted
}
err := validateRequest(args)
if err != nil {
return err
}
err = s.keycardContext.ChangePUK(args.NewPUK)
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 := validateRequest(args)
if err != nil {
return err
}
err = s.keycardContext.UnblockPIN(args.PUK, args.NewPIN)
return err
}
type GenerateMnemonicRequest struct {
Length int `json:"length"`
}
type GenerateMnemonicResponse struct {
Indexes []int `json:"indexes"`
}
func (s *KeycardService) GenerateMnemonic(args *GenerateMnemonicRequest, reply *GenerateMnemonicResponse) 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" validate:"required"`
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
}
err := validateRequest(args)
if err != nil {
return err
}
keyUID, err := s.keycardContext.LoadMnemonic(args.Mnemonic, args.Passphrase)
reply.KeyUID = utils.Btox(keyUID)
return err
}
func (s *KeycardService) FactoryReset(args *struct{}, reply *struct{}) error {
if s.keycardContext == nil {
return errKeycardServiceNotStarted
}
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
}
var err error
reply.Metadata, err = s.keycardContext.GetMetadata()
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"`
}
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"`
}
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
}
type SimulateErrorRequest struct {
Error string `json:"error"`
}
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")
}
s.simulateError = errToSimulate
if s.keycardContext == nil {
return nil
}
return s.keycardContext.SimulateError(errToSimulate)
}

View File

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

85
shared/api_session.go Normal file
View File

@ -0,0 +1,85 @@
package main
import "C"
import (
"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 marshalError(err)
}
// 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 marshalError(err)
}
globalRPCServer = rpcServer
logger.Info("RPC server initialized")
return marshalError(nil)
}
//export KeycardCallRPC
func KeycardCallRPC(payload *C.char) *C.char {
if globalRPCServer == nil {
return marshalError(errors.New("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 marshalError(errors.Wrap(err, "internal error reading response body"))
}
return C.CString(string(body))
}

View File

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