├── .env.example ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── main.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── README.md ├── admin ├── README.md ├── admin.go ├── admin_token.go ├── admin_token_test.go ├── api_response.go ├── docker-compose-mongo.yml ├── election_controller.go └── userelection_controller.go ├── certvalid ├── crlvalidator.go └── x509type.go ├── csp ├── csp.go ├── csp_test.go ├── handler.go └── helpers.go ├── dockerfiles ├── .env.example ├── .gitignore ├── README.md ├── docker-compose-apismsadmin.yml ├── docker-compose-oauth.yml ├── docker-compose.yml ├── handlerFiles │ └── .gitignore └── letsencrypt │ └── .gitignore ├── go.mod ├── go.sum ├── handlers ├── dummy.go ├── handlerlist │ └── handlerlist.go ├── handlers.go ├── idcathandler │ └── idcat.go ├── ipaddr.go ├── oauthhandler │ ├── config.yml │ ├── oauthhandler.go │ └── providers.go ├── rsahandler │ ├── rsa.go │ └── rsa_test.go ├── simpleMath.go └── smshandler │ ├── adminapi │ ├── README.md │ ├── adminapi │ ├── adminapi.go │ └── docker-compose.yml │ ├── challenge.go │ ├── challenge_test.go │ ├── jsonstorage.go │ ├── mongodbstorage.go │ ├── queue.go │ ├── smshandler.go │ ├── smshandler_test.go │ ├── storage.go │ └── storage_test.go ├── main.go ├── misc ├── blind_csp_flow.svg └── idCat │ ├── ec_ciutadania.crt │ ├── ec_ciutadania.pem │ └── toPem.sh ├── model ├── election.go ├── election_test.go ├── model_test.go ├── mongodbstorage.go ├── user.go ├── user_test.go ├── userelection.go └── userelection_test.go ├── saltedkey ├── saltedkey.go └── saltedkey_test.go ├── test.sh ├── test └── mongodb.go └── types ├── message.go └── signaturetype.go /.env.example: -------------------------------------------------------------------------------- 1 | FACEBOOK_CLIENT_ID=123456789 2 | FACEBOOK_CLIENT_SECRET=yourSecretFBClientSecret 3 | CSP_ADMIN_TOKEN_SECRET=adminTokenGenerationSecret -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us on reinventing digital voting 4 | title: 'bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A short summary of what the bug is. Please be clear and concise. 12 | 13 | **To Reproduce (please complete the following information)** 14 | - Config and flags: [e.g. , certificates="x, y, z"] 15 | - Steps to reproduce the behavior: 16 | 1. exec '...' 17 | 2. make request with '....' 18 | 3. '...' 19 | 4. See error 20 | 21 | **Current behavior** 22 | In depth explanation, if required, or a clear and concise description of what actually happens. 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **System (please complete the following information):** 28 | - OS: [e.g. Manjaro 20.1] 29 | - Software version [e.g. Docker 8, Golang 1.15.1] 30 | - Commit hash [e.g. e84617d] 31 | - Info related the CA (if required) 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | - stage 8 | - main 9 | - master 10 | - release** 11 | pull_request: 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | - name: Install Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.20.x 23 | cache: true 24 | - name: Run golangci-lint 25 | # run: | 26 | # curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.30.0 27 | # $(go env GOPATH)/bin/golangci-lint run --timeout=5m -c .golangci.yml 28 | uses: golangci/golangci-lint-action@v3 29 | ### golangci-lint will take much time if loading multiple linters in .golangci.yml 30 | with: 31 | version: v1.52.2 32 | args: --timeout 3m --verbose 33 | skip-cache: false 34 | skip-pkg-cache: false 35 | skip-build-cache: false 36 | only-new-issues: true 37 | 38 | test: 39 | # matrix strategy from: https://github.com/mvdan/github-actions-golang/blob/master/.github/workflows/test.yml 40 | strategy: 41 | matrix: 42 | go-version: [1.20.5] # avoid v1.20.6 due to https://github.com/testcontainers/testcontainers-go/issues/1359 43 | platform: [ubuntu-latest] 44 | runs-on: ${{ matrix.platform }} 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v3 48 | - name: Install Go 49 | uses: actions/setup-go@v3 50 | with: 51 | go-version: ${{ matrix.go-version }} 52 | cache: true 53 | - name: Run go test 54 | run: go test -timeout=10m -race ./... 55 | 56 | docker-release: 57 | runs-on: ubuntu-latest 58 | needs: [test, lint] 59 | if: github.event_name == 'push' # this is limited to selected branches at the beginning of this file 60 | steps: 61 | - name: Check out the repo 62 | uses: actions/checkout@v3 63 | - uses: docker/setup-buildx-action@v2 64 | - name: Login to DockerHub 65 | uses: docker/login-action@v2 66 | with: 67 | username: ${{ secrets.DOCKER_USERNAME }} 68 | password: ${{ secrets.DOCKER_PASSWORD }} 69 | - name: Login to GitHub Container Registry 70 | uses: docker/login-action@v2 71 | with: 72 | registry: ghcr.io 73 | username: ${{ github.repository_owner }} 74 | password: ${{ secrets.CR_PAT }} 75 | - name: Get short commit sha and branch name 76 | id: vars 77 | run: | 78 | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 79 | echo "branch_name=$(echo ${GITHUB_REF#refs/heads/} | tr '/' '-' )" >> $GITHUB_OUTPUT 80 | - name: Push to Docker Hub and ghcr.io 81 | uses: docker/build-push-action@v3 82 | with: 83 | context: . 84 | file: ./Dockerfile 85 | platforms: linux/amd64 86 | push: true 87 | tags: | 88 | vocdoni/${{ github.event.repository.name }}:latest, 89 | vocdoni/${{ github.event.repository.name }}:${{ steps.vars.outputs.branch_name }}, 90 | ghcr.io/vocdoni/${{ github.event.repository.name }}:latest, 91 | ghcr.io/vocdoni/${{ github.event.repository.name }}:${{ steps.vars.outputs.branch_name }} 92 | cache-from: type=gha 93 | cache-to: type=gha,mode=max 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | blind-csp 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | issues: 2 | max-same-issues: 0 3 | exclude-use-default: false 4 | linters: 5 | enable: 6 | - misspell 7 | - gofumpt 8 | - lll 9 | - staticcheck 10 | - unused 11 | - typecheck 12 | - ineffassign 13 | - errcheck 14 | - gosimple 15 | - goconst 16 | 17 | linters-settings: 18 | lll: 19 | line-length: 130 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20 AS builder 2 | 3 | WORKDIR /src 4 | COPY . . 5 | RUN go build -o=. -ldflags="-s -w" 6 | 7 | FROM debian:bookworm-slim as base 8 | 9 | WORKDIR /app 10 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 11 | 12 | WORKDIR /app 13 | COPY --from=builder /src/blind-csp ./ 14 | COPY --from=builder /src/handlers/oauthhandler/config.yml ./handlers/oauthhandler/config.yml 15 | 16 | ENTRYPOINT ["/app/blind-csp"] 17 | -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | ### Endpoints 2 | 3 | All the operations and results must be filtered by the Authentified user access. 4 | 5 | - [GET] `/admin/elections` : Returns all elections 6 | 7 | - [GET] `/admin/elections/:electionId` : Returns the provided electionId information 8 | 9 | - [POST] `/admin/elections` : Creates a new Census and attaches it to a new Election from the defined data. Returns the new Election ID. 10 | Request JSON body example 11 | ```json 12 | { "handlers": 13 | [ 14 | { 15 | "handler": "oauth", 16 | "service": "facebook", 17 | "mode": "usernames", 18 | "data": ["12345","nigeon@gmail.com"] 19 | }, 20 | { 21 | "handler": "oauth", 22 | "service": "github", 23 | "mode": "usernames", 24 | "data": ["nigeon"] 25 | }, 26 | { 27 | "handler": "sms", 28 | "data": ["`666666666`", "637840295"] 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | - [DELETE] `/admin/elections/:electionId` : Deletes election ID 35 | 36 | - [GET] `/admin/elections/:electionId/users` : List users in election 37 | 38 | - [POST] `/admin/elections/:electionId/users` : Add user in election 39 | Request JSON body example: 40 | ```json 41 | { 42 | "handler": "oauth", 43 | "service": "facebook", 44 | "mode": "usernames", 45 | "data": "nigeon@gmail.com", 46 | "consumed": false 47 | } 48 | ``` 49 | - [GET] `/admin/elections/[electionId]/users/[user]` : Get user 50 | 51 | - [PUT] `/admin/elections/[electionId]users/[user]` : Edits user 52 | Request JSON body example: 53 | ```json 54 | { 55 | "consumed": true 56 | } 57 | ``` 58 | - [DELETE] `/admin/elections/:electionId/users/[user]` : Deletes user 59 | 60 | - [GET] `/admin/elections/:electionId/users` : List of users in the elections 61 | -------------------------------------------------------------------------------- /admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | "github.com/vocdoni/blind-csp/model" 10 | "go.vocdoni.io/dvote/httprouter" 11 | "go.vocdoni.io/dvote/httprouter/apirest" 12 | "go.vocdoni.io/dvote/log" 13 | ) 14 | 15 | const bearerTokenReqAmount = 100000000 16 | 17 | // Admin is the main struct for the admin API, containing all the controllers, router and storage 18 | type Admin struct { 19 | router *httprouter.HTTProuter 20 | api *apirest.API 21 | storage *model.MongoStorage 22 | electionController *ElectionController 23 | userElectionController *UserelectionController 24 | } 25 | 26 | // NewAdmin creates a new Admin instance by initializing the storage and controllers 27 | func NewAdmin() (*Admin, error) { 28 | mongoStorage := model.MongoStorage{} 29 | 30 | return &Admin{ 31 | storage: &mongoStorage, 32 | electionController: NewElectionController(model.NewElectionStore(&mongoStorage)), 33 | userElectionController: NewUserelectionController(model.NewUserelectionStore(&mongoStorage)), 34 | }, mongoStorage.Init() 35 | } 36 | 37 | // ServeAPI registers the admin API handlers to the router 38 | func (admin *Admin) ServeAPI(r *httprouter.HTTProuter, baseRoute string) error { 39 | if !strings.HasPrefix(baseRoute, "/") { 40 | return fmt.Errorf("invalid base route %q, it must start with /", baseRoute) 41 | } 42 | 43 | // Remove trailing slash 44 | if len(baseRoute) > 1 { 45 | baseRoute = strings.TrimSuffix(baseRoute, "/") 46 | } 47 | if r == nil { 48 | return fmt.Errorf("router is nil") 49 | } 50 | 51 | var authToken []string 52 | if len(authToken) == 0 { 53 | authToken = strings.Split(os.Getenv("ADMINAPI_AUTHTOKEN"), ",") 54 | } 55 | 56 | // Initialize API 57 | admin.router = r 58 | var err error 59 | admin.api, err = apirest.NewAPI(admin.router, baseRoute) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | // Set bearer authentication 65 | if len(authToken) == 0 || authToken[0] == "" { 66 | authToken = []string{uuid.New().String()} 67 | } 68 | 69 | for _, at := range authToken { 70 | admin.api.AddAuthToken(at, bearerTokenReqAmount) 71 | } 72 | log.Infow("using bearer authentication token", "token", authToken) 73 | 74 | return admin.registerHandlers() 75 | } 76 | 77 | // registerHandlers registers all the admin API handlers 78 | func (admin *Admin) registerHandlers() error { 79 | if err := admin.api.RegisterMethod( 80 | "/elections", 81 | "GET", 82 | apirest.MethodAccessTypePublic, 83 | admin.electionController.List, 84 | ); err != nil { 85 | return err 86 | } 87 | 88 | // Create census and link it to a new election 89 | if err := admin.api.RegisterMethod( 90 | "/elections", 91 | "POST", 92 | apirest.MethodAccessTypePublic, 93 | admin.electionController.Create, 94 | ); err != nil { 95 | return err 96 | } 97 | 98 | // Obtain the authentication token for a given election 99 | if err := admin.api.RegisterMethod( 100 | "/elections/{electionId}/auth", 101 | "POST", 102 | apirest.MethodAccessTypePublic, 103 | admin.electionController.AdminToken, 104 | ); err != nil { 105 | log.Fatal(err) 106 | } 107 | 108 | if err := admin.api.RegisterMethod( 109 | "/elections/{electionId}", 110 | "GET", 111 | apirest.MethodAccessTypePublic, 112 | admin.electionController.Election, 113 | ); err != nil { 114 | log.Fatal(err) 115 | } 116 | 117 | if err := admin.api.RegisterMethod( 118 | "/elections/{electionId}", 119 | "DELETE", 120 | apirest.MethodAccessTypePublic, 121 | admin.electionController.Delete, 122 | ); err != nil { 123 | log.Fatal(err) 124 | } 125 | 126 | if err := admin.api.RegisterMethod( 127 | "/elections/{electionId}/users", 128 | "GET", 129 | apirest.MethodAccessTypePublic, 130 | admin.userElectionController.List, 131 | ); err != nil { 132 | log.Fatal(err) 133 | } 134 | 135 | if err := admin.api.RegisterMethod( 136 | "/elections/{electionId}/users", 137 | "POST", 138 | apirest.MethodAccessTypePublic, 139 | admin.userElectionController.Create, 140 | ); err != nil { 141 | log.Fatal(err) 142 | } 143 | 144 | if err := admin.api.RegisterMethod( 145 | "/elections/{electionId}/users/{userId}", 146 | "GET", 147 | apirest.MethodAccessTypePublic, 148 | admin.userElectionController.Userelection, 149 | ); err != nil { 150 | log.Fatal(err) 151 | } 152 | 153 | if err := admin.api.RegisterMethod( 154 | "/elections/{electionId}/users/{userId}", 155 | "PUT", 156 | apirest.MethodAccessTypePublic, 157 | admin.userElectionController.Update, 158 | ); err != nil { 159 | log.Fatal(err) 160 | } 161 | 162 | if err := admin.api.RegisterMethod( 163 | "/elections/{electionId}/users/{userId}", 164 | "DELETE", 165 | apirest.MethodAccessTypePublic, 166 | admin.userElectionController.Delete, 167 | ); err != nil { 168 | log.Fatal(err) 169 | } 170 | 171 | if err := admin.api.RegisterMethod( 172 | "/elections/{electionId}/users/search", 173 | "POST", 174 | apirest.MethodAccessTypePublic, 175 | admin.userElectionController.Search, 176 | ); err != nil { 177 | log.Fatal(err) 178 | } 179 | 180 | if err := admin.api.RegisterMethod( 181 | "/users/{userId}", 182 | "GET", 183 | apirest.MethodAccessTypePublic, 184 | admin.userElectionController.GetUserElections, 185 | ); err != nil { 186 | log.Fatal(err) 187 | } 188 | 189 | return nil 190 | } 191 | -------------------------------------------------------------------------------- /admin/admin_token.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "time" 7 | 8 | ethcrypto "github.com/ethereum/go-ethereum/crypto" 9 | "github.com/vocdoni/blind-csp/types" 10 | "go.vocdoni.io/dvote/crypto/ethereum" 11 | "go.vocdoni.io/dvote/vochain/processid" 12 | ) 13 | 14 | // GenerateAdminToken generates a new admin token for an election working for the current month 15 | // Token is generated using Keccak256 with a secret key so that ensures that the token 16 | // cannot be easily guessed or tampered with without knowledge of the key. 17 | // Token will be valid for the "current" month (UTC) and the election ID. 18 | func GenerateAdminToken(electionId types.HexBytes) (string, error) { 19 | secretKey := os.Getenv("CSP_ADMIN_TOKEN_SECRET") 20 | 21 | currentTime := time.Now() 22 | dateString := currentTime.Format("200601") 23 | data := append([]byte(secretKey), append(electionId, []byte(dateString)...)...) 24 | token := ethcrypto.Keccak256Hash(data).String() 25 | return token, nil 26 | } 27 | 28 | // ValidateAdminToken validates an admin token for an election 29 | func ValidateAdminToken(electionId types.HexBytes, adminToken string) (bool, error) { 30 | generatedToken, err := GenerateAdminToken(electionId) 31 | if err != nil { 32 | return false, err 33 | } 34 | 35 | return adminToken == generatedToken, nil 36 | } 37 | 38 | // VerifySignatureForElection verifies that the signature is from the election creator 39 | func VerifySignatureForElection(electionId types.HexBytes, signature types.HexBytes, data types.HexBytes) (bool, error) { 40 | address, err := ethereum.AddrFromSignature(data, signature) 41 | if err != nil { 42 | return false, err 43 | } 44 | 45 | // Verify the signer address is from the election creator 46 | p := processid.ProcessID{} 47 | if err := p.Unmarshal(electionId); err != nil { 48 | return false, err 49 | } 50 | 51 | return bytes.Equal(p.Addr().Bytes(), address.Bytes()), nil 52 | } 53 | -------------------------------------------------------------------------------- /admin/admin_token_test.go: -------------------------------------------------------------------------------- 1 | package admin_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | qt "github.com/frankban/quicktest" 8 | "github.com/vocdoni/blind-csp/admin" 9 | "github.com/vocdoni/blind-csp/types" 10 | "go.vocdoni.io/dvote/crypto/ethereum" 11 | ) 12 | 13 | func TestValidateAdminToken(t *testing.T) { 14 | electionIdString := "c5d2460186f7bb73137b620cffde1b3971a0c9023b480c851b70020400000000" 15 | var electionId types.HexBytes 16 | err := electionId.FromString(electionIdString) 17 | qt.Assert(t, err, qt.IsNil) 18 | 19 | adminToken, err := admin.GenerateAdminToken(electionId) 20 | qt.Assert(t, err, qt.IsNil) 21 | qt.Assert(t, adminToken, qt.Not(qt.IsNil)) 22 | 23 | valid, err := admin.ValidateAdminToken(electionId, adminToken) 24 | qt.Assert(t, err, qt.IsNil) 25 | qt.Assert(t, valid, qt.IsTrue) 26 | } 27 | 28 | func TestVerifySignatureForElection(t *testing.T) { 29 | // Generate a new ethereum key pair 30 | s := ethereum.NewSignKeys() 31 | if err := s.Generate(); err != nil { 32 | t.Fatal(err) 33 | } 34 | addr := s.AddressString() 35 | 36 | // Generate a fake electionId 37 | electionIdString := "c5d2460186f7020400000000" 38 | electionIdString = electionIdString[:12] + strings.TrimPrefix(addr, "0x") + electionIdString[12:] 39 | var electionId types.HexBytes 40 | err := electionId.FromString(electionIdString) 41 | qt.Assert(t, err, qt.IsNil) 42 | 43 | // Sign the message 44 | messageText := "hello" 45 | message := []byte(messageText) 46 | msgSign, err := s.SignEthereum(message) 47 | qt.Assert(t, err, qt.IsNil) 48 | 49 | valid, err := admin.VerifySignatureForElection(electionId, msgSign, message) 50 | qt.Assert(t, err, qt.IsNil) 51 | qt.Assert(t, valid, qt.IsTrue) 52 | } 53 | -------------------------------------------------------------------------------- /admin/api_response.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | const ( 8 | CodeOk = 400 9 | 10 | CodeErrInvalidAuth = 401 11 | ReasonErrInvalidAuth = "Authentication failed" 12 | ) 13 | 14 | // ApiResponse is the response format for the admin API 15 | type ApiResponse struct { 16 | Ok bool `json:"ok"` 17 | Code int `json:"code"` 18 | Reason string `json:"reason"` 19 | Data interface{} `json:"data"` 20 | } 21 | 22 | // Set sets the data for a successful response 23 | func (e *ApiResponse) Set(data interface{}) *ApiResponse { 24 | e.Data = data 25 | e.Code = CodeOk 26 | e.Ok = true 27 | return e 28 | } 29 | 30 | // SetError sets the error code and reason for a failed response 31 | func (e *ApiResponse) SetError(code int, reason string) *ApiResponse { 32 | e.Code = code 33 | e.Reason = reason 34 | e.Ok = false 35 | return e 36 | } 37 | 38 | // MustMarshall marshalls the response and panics if it fails 39 | func (e *ApiResponse) MustMarshall() []byte { 40 | data, err := json.Marshal(e) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | return data 46 | } 47 | -------------------------------------------------------------------------------- /admin/docker-compose-mongo.yml: -------------------------------------------------------------------------------- 1 | # Use root/example as user/password credentials 2 | version: '3.1' 3 | 4 | services: 5 | 6 | mongo: 7 | image: mongo 8 | restart: always 9 | ports: 10 | - 27017:27017 11 | environment: 12 | MONGO_INITDB_ROOT_USERNAME: root 13 | MONGO_INITDB_ROOT_PASSWORD: vocdoni 14 | 15 | mongo-express: 16 | image: mongo-express 17 | restart: always 18 | ports: 19 | - 8081:8081 20 | environment: 21 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 22 | ME_CONFIG_MONGODB_ADMINPASSWORD: vocdoni 23 | ME_CONFIG_MONGODB_URL: mongodb://root:vocdoni@mongo:27017/ 24 | -------------------------------------------------------------------------------- /admin/election_controller.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/vocdoni/blind-csp/model" 9 | "github.com/vocdoni/blind-csp/types" 10 | "go.vocdoni.io/dvote/httprouter" 11 | "go.vocdoni.io/dvote/httprouter/apirest" 12 | ) 13 | 14 | // ElectionWithTokenResponse is the response to the election creation or admin token request. 15 | // Containing the admin token needed for permissioned actions. 16 | type ElectionWithTokenResponse struct { 17 | AdminToken string `json:"adminToken"` 18 | Election model.Election `json:"election"` 19 | } 20 | 21 | // ElectionController is the interface for the election controller 22 | type ElectionController struct { 23 | store model.ElectionStore 24 | } 25 | 26 | // NewElectionController creates a new election controller 27 | func NewElectionController(store model.ElectionStore) *ElectionController { 28 | return &ElectionController{store: store} 29 | } 30 | 31 | // Create creates a new election with it's users 32 | func (c *ElectionController) Create(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 33 | newElection := model.Election{} 34 | if err := json.Unmarshal(msg.Data, &newElection); err != nil { 35 | return err 36 | } 37 | 38 | election, err := c.store.CreateElection(&newElection) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | electionBearerToken, err := GenerateAdminToken(election.ID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | response := ElectionWithTokenResponse{ 49 | AdminToken: electionBearerToken, 50 | Election: *election, 51 | } 52 | 53 | return ctx.Send(new(ApiResponse).Set(response).MustMarshall(), apirest.HTTPstatusOK) 54 | } 55 | 56 | // Election returns the election data 57 | func (c *ElectionController) Election(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 58 | var electionID types.HexBytes 59 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | election, err := c.store.Election(electionID) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return ctx.Send(new(ApiResponse).Set(election).MustMarshall(), apirest.HTTPstatusOK) 70 | } 71 | 72 | // Delete deletes an election 73 | func (c *ElectionController) Delete(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 74 | var electionID types.HexBytes 75 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 81 | if !valid || err != nil { 82 | return ctx.Send( 83 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 84 | apirest.HTTPstatusBadRequest, 85 | ) 86 | } 87 | 88 | if err := c.store.DeleteElection(electionID); err != nil { 89 | return err 90 | } 91 | 92 | return ctx.Send(new(ApiResponse).Set(nil).MustMarshall(), apirest.HTTPstatusOK) 93 | } 94 | 95 | // List returns the list of elections 96 | func (c *ElectionController) List(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 97 | elections, err := c.store.ListElection() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return ctx.Send(new(ApiResponse).Set(elections).MustMarshall(), apirest.HTTPstatusOK) 103 | } 104 | 105 | // AdminTokenRequest is the request to get the admin token, 106 | // containing the signature and the data (thas has been signed) 107 | type AdminTokenRequest struct { 108 | Signature string `json:"signature"` 109 | Data string `json:"data"` 110 | } 111 | 112 | // AdminToken returns the admin token for an election 113 | func (c *ElectionController) AdminToken(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 114 | var electionID types.HexBytes 115 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | adminTokenRequest := AdminTokenRequest{} 121 | if err := json.Unmarshal(msg.Data, &adminTokenRequest); err != nil { 122 | return err 123 | } 124 | 125 | signature, err := hexStringToBytes(adminTokenRequest.Signature) 126 | if err != nil { 127 | return ctx.Send(new(ApiResponse).SetError(CodeErrInvalidAuth, "Invalid signature").MustMarshall(), apirest.HTTPstatusOK) 128 | } 129 | 130 | // Verify the signature 131 | verified, err := VerifySignatureForElection(electionID, signature, types.HexBytes(adminTokenRequest.Data)) 132 | if !verified || err != nil { 133 | return ctx.Send(new(ApiResponse).SetError(CodeErrInvalidAuth, "Invalid signer").MustMarshall(), apirest.HTTPstatusOK) 134 | } 135 | 136 | // Get the election 137 | election, err := c.store.Election(electionID) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | // Get the admin token 143 | electionBearerToken, err := GenerateAdminToken(electionID) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | response := ElectionWithTokenResponse{ 149 | AdminToken: electionBearerToken, 150 | Election: *election, 151 | } 152 | 153 | return ctx.Send(new(ApiResponse).Set(response).MustMarshall(), apirest.HTTPstatusOK) 154 | } 155 | 156 | // Helper function to decode a hex string to byte slice 157 | func hexStringToBytes(hexString string) ([]byte, error) { 158 | hexString = strings.TrimPrefix(hexString, "0x") 159 | 160 | if len(hexString)%2 != 0 { 161 | hexString = "0" + hexString 162 | } 163 | return hex.DecodeString(hexString) 164 | } 165 | -------------------------------------------------------------------------------- /admin/userelection_controller.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/vocdoni/blind-csp/model" 7 | "github.com/vocdoni/blind-csp/types" 8 | "go.vocdoni.io/dvote/httprouter" 9 | "go.vocdoni.io/dvote/httprouter/apirest" 10 | ) 11 | 12 | // userElectionController is the interface for the user controller 13 | type UserelectionController struct { 14 | store model.UserelectionStore 15 | } 16 | 17 | // NewUserelectionController creates a new user controller 18 | func NewUserelectionController(store model.UserelectionStore) *UserelectionController { 19 | return &UserelectionController{store: store} 20 | } 21 | 22 | // Create creates a new user in an election 23 | func (c *UserelectionController) Create(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 24 | var electionID types.HexBytes 25 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 31 | if !valid || err != nil { 32 | return ctx.Send( 33 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 34 | apirest.HTTPstatusBadRequest, 35 | ) 36 | } 37 | 38 | userData := model.UserelectionRequest{} 39 | if err := json.Unmarshal(msg.Data, &userData); err != nil { 40 | return err 41 | } 42 | 43 | handler := model.HandlerConfig{ 44 | Handler: userData.Handler, 45 | Service: userData.Service, 46 | Mode: userData.Mode, 47 | } 48 | 49 | user, err := c.store.CreateUserelection(electionID, handler, userData.Data) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | return ctx.Send(new(ApiResponse).Set(user).MustMarshall(), apirest.HTTPstatusOK) 55 | } 56 | 57 | // Update updates a user in an election 58 | func (c *UserelectionController) Update(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 59 | var electionID types.HexBytes 60 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 66 | if !valid || err != nil { 67 | return ctx.Send( 68 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 69 | apirest.HTTPstatusBadRequest, 70 | ) 71 | } 72 | 73 | var userID types.HexBytes 74 | if err := userID.FromString(ctx.URLParam("userId")); err != nil { 75 | return err 76 | } 77 | 78 | userData := model.UserelectionRequest{} 79 | if err := json.Unmarshal(msg.Data, &userData); err != nil { 80 | return err 81 | } 82 | 83 | user, err := c.store.UpdateUserelection(electionID, userID, userData) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | return ctx.Send(new(ApiResponse).Set(user).MustMarshall(), apirest.HTTPstatusOK) 89 | } 90 | 91 | // Userelection returns a user in an election 92 | func (c *UserelectionController) Userelection(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 93 | var electionID types.HexBytes 94 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 100 | if !valid || err != nil { 101 | return ctx.Send( 102 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 103 | apirest.HTTPstatusBadRequest, 104 | ) 105 | } 106 | 107 | var userID types.HexBytes 108 | if err := userID.FromString(ctx.URLParam("userId")); err != nil { 109 | return err 110 | } 111 | 112 | user, err := c.store.Userelection(electionID, userID) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | return ctx.Send(new(ApiResponse).Set(user).MustMarshall(), apirest.HTTPstatusOK) 118 | } 119 | 120 | // Delete deletes a user from an election 121 | func (c *UserelectionController) Delete(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 122 | var electionID types.HexBytes 123 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 129 | if !valid || err != nil { 130 | return ctx.Send( 131 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 132 | apirest.HTTPstatusBadRequest, 133 | ) 134 | } 135 | 136 | var userID types.HexBytes 137 | if err := userID.FromString(ctx.URLParam("userId")); err != nil { 138 | return err 139 | } 140 | 141 | if err := c.store.DeleteUserelection(electionID, userID); err != nil { 142 | return err 143 | } 144 | return ctx.Send(new(ApiResponse).Set(nil).MustMarshall(), apirest.HTTPstatusOK) 145 | } 146 | 147 | // List returns a list of users for an election 148 | func (c *UserelectionController) List(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 149 | var electionID types.HexBytes 150 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 156 | if !valid || err != nil { 157 | return ctx.Send( 158 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 159 | apirest.HTTPstatusBadRequest, 160 | ) 161 | } 162 | 163 | users, err := c.store.ListUserelection(electionID) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | return ctx.Send(new(ApiResponse).Set(users).MustMarshall(), apirest.HTTPstatusOK) 169 | } 170 | 171 | // Search returns a list of users for a criteria 172 | func (c *UserelectionController) Search(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 173 | var electionID types.HexBytes 174 | electionID, err := hexStringToBytes(ctx.URLParam("electionId")) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | valid, err := ValidateAdminToken(electionID, msg.AuthToken) 180 | if !valid || err != nil { 181 | return ctx.Send( 182 | new(ApiResponse).SetError(CodeErrInvalidAuth, ReasonErrInvalidAuth).MustMarshall(), 183 | apirest.HTTPstatusBadRequest, 184 | ) 185 | } 186 | 187 | userData := model.UserelectionRequest{} 188 | if err := json.Unmarshal(msg.Data, &userData); err != nil { 189 | return err 190 | } 191 | 192 | users, err := c.store.SearchUserelection(electionID, userData) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | return ctx.Send(new(ApiResponse).Set(users).MustMarshall(), apirest.HTTPstatusOK) 198 | } 199 | 200 | // GetUserElections returns a list of elections for a user 201 | func (c *UserelectionController) GetUserElections(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 202 | var userID types.HexBytes 203 | userID, err := hexStringToBytes(ctx.URLParam("userId")) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | user, err := c.store.GetUserElections(userID) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | return ctx.Send(new(ApiResponse).Set(user).MustMarshall(), apirest.HTTPstatusOK) 214 | } 215 | -------------------------------------------------------------------------------- /certvalid/crlvalidator.go: -------------------------------------------------------------------------------- 1 | package certvalid 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // Encapsulate validation of X509 certificates via a CRL 13 | type X509CRLValidator struct { 14 | CA *x509.Certificate 15 | list map[string]bool 16 | crlURL string 17 | NextUpdate time.Time 18 | updateLock sync.RWMutex 19 | } 20 | 21 | // NewX509CRLValidator Create a new validator using the specified CA and the CRL url specified 22 | func NewX509CRLValidator(ca *x509.Certificate, crlURL string) *X509CRLValidator { 23 | return &X509CRLValidator{ 24 | ca, nil, crlURL, time.Now().AddDate(0, 0, -1), sync.RWMutex{}, 25 | } 26 | } 27 | 28 | // NextUpdateLimit Get when the next validation call will fail because needs to call Update() 29 | func (x *X509CRLValidator) NextUpdateLimit() time.Time { 30 | return x.NextUpdate 31 | } 32 | 33 | // Update the CRL, is sync safe, so it can be invocated within a goroutine 34 | // TODO: x509 validation should be re-checked since an update on the API might have broken it 35 | func (x *X509CRLValidator) Update() error { 36 | resp, err := http.Get(x.crlURL) 37 | if err != nil { 38 | return err 39 | } 40 | defer func() { 41 | if err := resp.Body.Close(); err != nil { 42 | fmt.Printf("error closing HTTP body: %v\n", err) 43 | } 44 | }() 45 | 46 | crlBytes, err := io.ReadAll(resp.Body) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | crl, err := x509.ParseRevocationList(crlBytes) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | if err := crl.CheckSignatureFrom(x.CA); err != nil { 57 | return err 58 | } 59 | 60 | currentTime := time.Now() 61 | if crl.ThisUpdate.Before(currentTime) { 62 | return fmt.Errorf("expired CRL") 63 | } 64 | 65 | x.NextUpdate = crl.NextUpdate 66 | updated := make(map[string]bool, len(crl.RevokedCertificates)) 67 | for _, revokedCert := range crl.RevokedCertificates { 68 | updated[revokedCert.SerialNumber.String()] = true 69 | } 70 | 71 | x.updateLock.Lock() 72 | x.list = updated 73 | x.updateLock.Unlock() 74 | return nil 75 | } 76 | 77 | func (x *X509CRLValidator) RevokatedListSize() int { 78 | x.updateLock.RLock() 79 | defer x.updateLock.RUnlock() 80 | return len(x.list) 81 | } 82 | 83 | // IsRevokated checks if a certificate is revokated, use strict mode for production 84 | func (x *X509CRLValidator) IsRevokated(cert *x509.Certificate, strict bool) (bool, error) { 85 | if strict { 86 | if len(cert.CRLDistributionPoints) != 1 || cert.CRLDistributionPoints[0] != x.crlURL { 87 | return false, fmt.Errorf("invald CRL distribution point") 88 | } 89 | 90 | if time.Now().After(x.NextUpdate) { 91 | return false, fmt.Errorf("CRL outdated, need to Sync()") 92 | } 93 | } 94 | x.updateLock.RLock() 95 | _, revokated := x.list[cert.SerialNumber.String()] 96 | x.updateLock.RUnlock() 97 | 98 | return revokated, nil 99 | } 100 | -------------------------------------------------------------------------------- /certvalid/x509type.go: -------------------------------------------------------------------------------- 1 | package certvalid 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type ExtractIDFunc func(*x509.Certificate) string 10 | 11 | type X509Type struct { 12 | verifyOptions x509.VerifyOptions 13 | crlValidator *X509CRLValidator 14 | extractID ExtractIDFunc 15 | } 16 | 17 | type X509Manager struct { 18 | types []X509Type 19 | } 20 | 21 | func NewX509Manager() *X509Manager { 22 | return &X509Manager{[]X509Type{}} 23 | } 24 | 25 | func (x *X509Manager) Add(chain []*x509.Certificate, crlURL string, extratIdFunc ExtractIDFunc) { 26 | rootPool := x509.NewCertPool() 27 | rootPool.AddCert(chain[0]) 28 | 29 | subPool := x509.NewCertPool() 30 | for _, sub := range chain[1:] { 31 | subPool.AddCert(sub) 32 | } 33 | 34 | crlValidator := NewX509CRLValidator(chain[len(chain)-1], crlURL) 35 | verifyOptions := x509.VerifyOptions{ 36 | Roots: rootPool, 37 | Intermediates: subPool, 38 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, 39 | } 40 | x.types = append(x.types, 41 | X509Type{ 42 | verifyOptions, 43 | crlValidator, 44 | extratIdFunc, 45 | }, 46 | ) 47 | } 48 | 49 | func (x *X509Manager) Update(nextUpdate time.Time) error { 50 | errm := "" 51 | for _, v := range x.types { 52 | if err := v.crlValidator.Update(); err != nil { 53 | errm += fmt.Sprintf("%s ", v.crlValidator.crlURL) 54 | continue // do not block CRL updates for other certificates 55 | } 56 | v.crlValidator.NextUpdate = nextUpdate 57 | } 58 | if errm != "" { 59 | return fmt.Errorf("some CRL updates failed: %s", errm) 60 | } 61 | return nil 62 | } 63 | 64 | func (x *X509Manager) RevokedListsSize() int { 65 | size := 0 66 | for _, v := range x.types { 67 | size += v.crlValidator.RevokatedListSize() 68 | } 69 | return size 70 | } 71 | 72 | func (x *X509Manager) Verify(cert *x509.Certificate, strict bool) (string, error) { 73 | for _, v := range x.types { 74 | if _, err := cert.Verify(v.verifyOptions); err == nil { 75 | isRevokated, err := v.crlValidator.IsRevokated(cert, strict) 76 | if err != nil { 77 | return "", err 78 | } 79 | if isRevokated { 80 | return "", fmt.Errorf("certificate is revokated") 81 | } 82 | cid := v.extractID(cert) 83 | if len(cid) == 0 { 84 | return "", fmt.Errorf("certificate ID invalid") 85 | } 86 | return cid, nil 87 | } 88 | } 89 | return "", fmt.Errorf("cannot find suitable CA") 90 | } 91 | -------------------------------------------------------------------------------- /csp/csp.go: -------------------------------------------------------------------------------- 1 | package csp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/vocdoni/blind-csp/handlers" 9 | "github.com/vocdoni/blind-csp/saltedkey" 10 | "go.vocdoni.io/dvote/db" 11 | "go.vocdoni.io/dvote/db/metadb" 12 | "go.vocdoni.io/dvote/httprouter" 13 | "go.vocdoni.io/dvote/httprouter/apirest" 14 | "go.vocdoni.io/dvote/log" 15 | ) 16 | 17 | const ( 18 | // PrivKeyHexSize is the hexadecimal length of a private key 19 | PrivKeyHexSize = 64 20 | 21 | // RandomTokenSize is the maximum size of the random token for auth queries 22 | RandomTokenSize = 32 23 | ) 24 | 25 | // BlindCSP is the blind signature API service for certification authorities 26 | type BlindCSP struct { 27 | callbacks *BlindCSPcallbacks 28 | router *httprouter.HTTProuter 29 | api *apirest.API 30 | signer *saltedkey.SaltedKey 31 | keys db.Database 32 | keysLock sync.RWMutex 33 | } 34 | 35 | type BlindCSPcallbacks struct { 36 | Auth handlers.AuthFunc 37 | Info handlers.InfoFunc 38 | Indexer handlers.IndexerFunc 39 | } 40 | 41 | // NewBlindCSP creates and initializes the CSP API with a private key (64 digits hexadecimal string) 42 | // and a custom callback authorization function. 43 | func NewBlindCSP(privKey, dataDir string, handlerCallbacks BlindCSPcallbacks) (*BlindCSP, error) { 44 | if len(privKey) != PrivKeyHexSize { 45 | return nil, fmt.Errorf("private key size is incorrect %d", len(privKey)) 46 | } 47 | csp := new(BlindCSP) 48 | csp.callbacks = &handlerCallbacks 49 | var err error 50 | // ECDSA/Blind signer 51 | if csp.signer, err = saltedkey.NewSaltedKey(privKey); err != nil { 52 | return nil, err 53 | } 54 | 55 | // Storage 56 | log.Debugf("initializing persistent storage on %s", dataDir) 57 | csp.keys, err = metadb.New(db.TypePebble, dataDir) 58 | 59 | return csp, err 60 | } 61 | 62 | // ServeAPI registers the API handlers into the router under the baseRoute path 63 | func (csp *BlindCSP) ServeAPI(r *httprouter.HTTProuter, baseRoute string) error { 64 | if len(baseRoute) == 0 || baseRoute[0] != '/' { 65 | return fmt.Errorf("invalid base route (%s), it must start with /", baseRoute) 66 | } 67 | // Remove trailing slash 68 | if len(baseRoute) > 1 { 69 | baseRoute = strings.TrimSuffix(baseRoute, "/") 70 | } 71 | if r == nil { 72 | return fmt.Errorf("router is nil") 73 | } 74 | // Initialize API 75 | csp.router = r 76 | var err error 77 | csp.api, err = apirest.NewAPI(csp.router, baseRoute) 78 | if err != nil { 79 | return err 80 | } 81 | return csp.registerHandlers() 82 | } 83 | -------------------------------------------------------------------------------- /csp/csp_test.go: -------------------------------------------------------------------------------- 1 | package csp 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "math/big" 10 | "net/http" 11 | "testing" 12 | 13 | "github.com/arnaucube/go-blindsecp256k1" 14 | qt "github.com/frankban/quicktest" 15 | "github.com/vocdoni/blind-csp/types" 16 | "go.vocdoni.io/dvote/crypto/ethereum" 17 | ) 18 | 19 | func TestBlindCA(t *testing.T) { 20 | // Generate a new signing key 21 | signer := ethereum.SignKeys{} 22 | err := signer.Generate() 23 | qt.Assert(t, err, qt.IsNil) 24 | _, priv := signer.HexString() 25 | pubdesc, err := ethereum.DecompressPubKey(signer.PublicKey()) 26 | qt.Assert(t, err, qt.IsNil) 27 | t.Logf("using root pubkey:%x privkey:%s", pubdesc, priv) 28 | 29 | // Use the key generated for initialize the CA with a dummy handler 30 | // Create the blind CA API and assign the IP auth function 31 | ca, err := NewBlindCSP(priv, t.TempDir(), BlindCSPcallbacks{Auth: testAuthHandler}) 32 | qt.Assert(t, err, qt.IsNil) 33 | 34 | // Generate a new R point for blinding 35 | signerR, err := ca.NewBlindRequestKey() 36 | qt.Assert(t, err, qt.IsNil) 37 | 38 | // Prepare the hash that will be signed 39 | hash := ethereum.HashRaw(randomBytes(128)) 40 | 41 | // Get a processId (will be used for salting the root key) 42 | pid := randomBytes(processIDSize) 43 | 44 | // Transform it to big.Int 45 | m := new(big.Int).SetBytes(hash) 46 | 47 | // Blind the message that is gonna be signed using the R point 48 | msgBlinded, userSecretData, err := blindsecp256k1.Blind(m, signerR) 49 | qt.Assert(t, err, qt.IsNil) 50 | 51 | // Perform the blind signature on the blinded message 52 | blindedSignature, err := ca.SignBlind(signerR, msgBlinded.Bytes(), pid) 53 | qt.Assert(t, err, qt.IsNil) 54 | 55 | // Unblind the signature 56 | signature := blindsecp256k1.Unblind(new(big.Int).SetBytes(blindedSignature), userSecretData) 57 | 58 | // Get the serialized signature 59 | b := signature.Bytes() 60 | t.Logf("signature %x", b) 61 | 62 | // Recover the serialized signature into signature2 var 63 | signature2, err := blindsecp256k1.NewSignatureFromBytes(b) 64 | qt.Assert(t, err, qt.IsNil) 65 | if !bytes.Equal(signature.Bytes(), signature2.Bytes()) { 66 | t.Fatalf("signature obtained with NewSignatureFromBytes and signature are different: %x != %x ", 67 | signature.Bytes(), signature2.Bytes()) 68 | } 69 | 70 | // For verify, use the public key from standard ECDSA (pubdesc) 71 | t.Logf("blind PubK: %s", ca.PubKeyBlind(pid)) 72 | 73 | // From the standard ECDSA pubkey, get the pubkey blind format 74 | pubKeyECDSA, err := hex.DecodeString(ca.PubKeyECDSA(pid)) 75 | qt.Assert(t, err, qt.IsNil) 76 | 77 | bpub2, err := blindsecp256k1.NewPublicKeyFromECDSA(pubKeyECDSA) 78 | qt.Assert(t, err, qt.IsNil) 79 | 80 | pubKeyBlind, err := hex.DecodeString(ca.PubKeyBlind(pid)) 81 | qt.Assert(t, err, qt.IsNil) 82 | if !bytes.Equal(pubKeyBlind, bpub2.Bytes()) { 83 | t.Fatalf("public key ECDSA and Blindsecp256k1 do not match: %x != %x", 84 | pubKeyBlind, bpub2.Bytes()) 85 | } 86 | qt.Assert(t, 87 | pubKeyBlind, 88 | qt.DeepEquals, 89 | bpub2.Bytes(), 90 | ) 91 | 92 | qt.Assert(t, 93 | blindsecp256k1.Verify(m, signature2, bpub2), 94 | qt.Equals, 95 | true, 96 | ) 97 | 98 | // Do the same with a wrong message hash and check verify fails 99 | hash = ethereum.HashRaw(randomBytes(128)) 100 | qt.Assert(t, 101 | blindsecp256k1.Verify(new(big.Int).SetBytes(hash), signature2, bpub2), 102 | qt.Equals, 103 | false, 104 | ) 105 | } 106 | 107 | func testAuthHandler(r *http.Request, m *types.Message, 108 | pid types.HexBytes, st string, step int, 109 | ) types.AuthResponse { 110 | return types.AuthResponse{ 111 | Success: true, 112 | Response: []string{fmt.Sprintf("hello %x!", pid)}, 113 | } 114 | } 115 | 116 | func randomBytes(n int) []byte { 117 | bytes := make([]byte, n) 118 | if _, err := io.ReadFull(rand.Reader, bytes); err != nil { 119 | panic(err) 120 | } 121 | return bytes 122 | } 123 | -------------------------------------------------------------------------------- /csp/handler.go: -------------------------------------------------------------------------------- 1 | package csp 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/arnaucube/go-blindsecp256k1" 9 | "github.com/vocdoni/blind-csp/types" 10 | "go.vocdoni.io/dvote/httprouter" 11 | "go.vocdoni.io/dvote/httprouter/apirest" 12 | ) 13 | 14 | const ( 15 | processIDSize = 32 16 | ) 17 | 18 | func (csp *BlindCSP) registerHandlers() error { 19 | if err := csp.api.RegisterMethod( 20 | "/ping", 21 | "GET", 22 | apirest.MethodAccessTypePublic, 23 | csp.ping, 24 | ); err != nil { 25 | return err 26 | } 27 | 28 | if err := csp.api.RegisterMethod( 29 | "/info", 30 | "GET", 31 | apirest.MethodAccessTypePublic, 32 | csp.info, 33 | ); err != nil { 34 | return err 35 | } 36 | 37 | if err := csp.api.RegisterMethod( 38 | "/indexer/{userId}", 39 | "GET", 40 | apirest.MethodAccessTypePublic, 41 | csp.indexer, 42 | ); err != nil { 43 | return err 44 | } 45 | 46 | if err := csp.api.RegisterMethod( 47 | "/{processId}/{signType}/auth/{step}", 48 | "POST", 49 | apirest.MethodAccessTypePublic, 50 | csp.signatureReq, 51 | ); err != nil { 52 | return err 53 | } 54 | 55 | if err := csp.api.RegisterMethod( 56 | "/{processId}/{signType}/auth", 57 | "POST", 58 | apirest.MethodAccessTypePublic, 59 | csp.signatureReq, 60 | ); err != nil { 61 | return err 62 | } 63 | 64 | if err := csp.api.RegisterMethod( 65 | "/{processId}/{signType}/sign", 66 | "POST", 67 | apirest.MethodAccessTypePublic, 68 | csp.signature, 69 | ); err != nil { 70 | return err 71 | } 72 | 73 | if err := csp.api.RegisterMethod( 74 | "/{processId}/sharedkey/{step}", 75 | "POST", 76 | apirest.MethodAccessTypePublic, 77 | csp.sharedKeyReq, 78 | ); err != nil { 79 | return err 80 | } 81 | 82 | return csp.api.RegisterMethod( 83 | "/{processId}/sharedkey", 84 | "POST", 85 | apirest.MethodAccessTypePublic, 86 | csp.sharedKeyReq, 87 | ) 88 | } 89 | 90 | // https://server/v1/auth/processes///auth/ 91 | 92 | // signatureReq is the signature request handler. 93 | // It executes the AuthCallback function to allow or deny the request to the client. 94 | func (csp *BlindCSP) signatureReq(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 95 | req := &types.Message{} 96 | if err := req.Unmarshal(msg.Data); err != nil { 97 | return err 98 | } 99 | 100 | // Process ID 101 | var pid types.HexBytes 102 | if err := pid.FromString(ctx.URLParam("processId")); err != nil { 103 | return fmt.Errorf("cannot decode processId: %w", err) 104 | } 105 | if len(pid) != processIDSize { 106 | return fmt.Errorf("wrong process id: %x", pid) 107 | } 108 | 109 | // Auth Step 110 | step, err := strconv.Atoi(ctx.URLParam("step")) 111 | if err != nil { 112 | step = 0 // For backwards compatibility 113 | } 114 | 115 | // Signature type and auth callback 116 | var authResp types.AuthResponse 117 | var resp types.Message 118 | signType := ctx.URLParam("signType") 119 | if authResp = csp.callbacks.Auth(ctx.Request, req, pid, signType, step); authResp.Success { 120 | switch signType { 121 | case types.SignatureTypeBlind: 122 | if authResp.AuthToken == nil { 123 | r, err := csp.NewBlindRequestKey() 124 | if err != nil { 125 | return err 126 | } 127 | resp.TokenR = r.BytesUncompressed() // use Uncompressed for blindsecp256k1-js compatibility 128 | } 129 | case types.SignatureTypeEthereum: 130 | if authResp.AuthToken == nil { 131 | resp.TokenR = csp.NewRequestKey() 132 | } 133 | default: 134 | return fmt.Errorf("invalid signature type") 135 | } 136 | } else { 137 | return fmt.Errorf("unauthorized: %s", authResp.String()) 138 | } 139 | resp.Response = authResp.Response 140 | resp.AuthToken = authResp.AuthToken 141 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 142 | } 143 | 144 | // https://server/v1/auth/processes///sign 145 | 146 | // signature is the performing signature handler. 147 | // If the token is valid and exist in cache, will perform a signature over the Hash 148 | func (csp *BlindCSP) signature(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 149 | req := &types.Message{} 150 | if err := req.Unmarshal(msg.Data); err != nil { 151 | return err 152 | } 153 | if req.TokenR == nil { 154 | return fmt.Errorf("token is empty") 155 | } 156 | if len(req.Payload) == 0 { 157 | return fmt.Errorf("message is empty") 158 | } 159 | pid, err := hex.DecodeString(trimHex(ctx.URLParam("processId"))) 160 | if err != nil { 161 | return fmt.Errorf("cannot decode processId: %w", err) 162 | } 163 | if len(pid) != processIDSize { 164 | return fmt.Errorf("wrong process id: %x", pid) 165 | } 166 | 167 | resp := types.Message{} 168 | switch ctx.URLParam("signType") { 169 | case types.SignatureTypeBlind: 170 | // use Uncompressed for blindsecp256k1-js compatibility 171 | r, err := blindsecp256k1.NewPointFromBytesUncompressed(req.TokenR) 172 | if err != nil { 173 | return err 174 | } 175 | resp.Signature, err = csp.SignBlind(r, req.Payload, pid) 176 | if err != nil { 177 | return err 178 | } 179 | case types.SignatureTypeEthereum: 180 | var err error 181 | resp.Signature, err = csp.SignECDSA(req.TokenR, req.Payload, pid) 182 | if err != nil { 183 | return err 184 | } 185 | default: 186 | return fmt.Errorf("invalid signature type") 187 | } 188 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 189 | } 190 | 191 | // https://server/v1/auth/processes//sharedkey 192 | 193 | // sharedKeyReq is the shared key request handler. 194 | // It executes the AuthCallback function to allow or deny the request to the client. 195 | // The shared key equals to signatureECDSA(hash(processId)). 196 | func (csp *BlindCSP) sharedKeyReq(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 197 | req := &types.Message{} 198 | if err := req.Unmarshal(msg.Data); err != nil { 199 | return err 200 | } 201 | pid, err := hex.DecodeString(trimHex(ctx.URLParam("processId"))) 202 | if err != nil { 203 | return fmt.Errorf("cannot decode processId: %w", err) 204 | } 205 | if len(pid) != processIDSize { 206 | return fmt.Errorf("wrong process id: %x", pid) 207 | } 208 | // Auth Step 209 | step, err := strconv.Atoi(ctx.URLParam("step")) 210 | if err != nil { 211 | // For backwards compatibility 212 | step = 0 213 | } 214 | 215 | var resp types.Message 216 | var authResp types.AuthResponse 217 | if authResp = csp.callbacks.Auth( 218 | ctx.Request, 219 | req, 220 | pid, 221 | types.SignatureTypeSharedKey, 222 | step, 223 | ); authResp.Success { 224 | if authResp.AuthToken == nil { 225 | resp.SharedKey, err = csp.SharedKey(pid) 226 | if err != nil { 227 | return err 228 | } 229 | } 230 | } else { 231 | return fmt.Errorf("unauthorized") 232 | } 233 | resp.Response = authResp.Response 234 | resp.AuthToken = authResp.AuthToken 235 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 236 | } 237 | 238 | func (csp *BlindCSP) info(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 239 | if csp.callbacks.Info == nil { 240 | return ctx.Send(nil, apirest.HTTPstatusOK) 241 | } 242 | resp := csp.callbacks.Info() 243 | if resp == nil { 244 | return ctx.Send(nil, apirest.HTTPstatusOK) 245 | } 246 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 247 | } 248 | 249 | func (csp *BlindCSP) indexer(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 250 | if csp.callbacks.Indexer == nil { 251 | return ctx.Send(nil, apirest.HTTPstatusOK) 252 | } 253 | userID, err := hex.DecodeString(trimHex(ctx.URLParam("userId"))) 254 | if err != nil { 255 | return fmt.Errorf("cannot get user id: %w", err) 256 | } 257 | var resp types.Message 258 | resp.Elections = csp.callbacks.Indexer(userID) 259 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 260 | } 261 | 262 | // https://server/v1/auth/ping 263 | 264 | // ping is a simple health check handler. 265 | func (csp *BlindCSP) ping(msg *apirest.APIdata, ctx *httprouter.HTTPContext) error { 266 | resp := &types.Message{Response: []string{"Ok"}} 267 | return ctx.Send(resp.Marshal(), apirest.HTTPstatusOK) 268 | } 269 | 270 | func trimHex(s string) string { 271 | if len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') { 272 | return s[2:] 273 | } 274 | return s 275 | } 276 | -------------------------------------------------------------------------------- /csp/helpers.go: -------------------------------------------------------------------------------- 1 | package csp 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math/big" 7 | 8 | blind "github.com/arnaucube/go-blindsecp256k1" 9 | "github.com/vocdoni/blind-csp/saltedkey" 10 | "go.vocdoni.io/dvote/log" 11 | ) 12 | 13 | // PubKeyBlind returns the public key of the blind CSP signer. 14 | // If processID is nil, returns the root public key. 15 | // If processID is not nil, returns the salted public key. 16 | func (csp *BlindCSP) PubKeyBlind(processID []byte) string { 17 | if processID == nil { 18 | return fmt.Sprintf("%x", csp.signer.BlindPubKey()) 19 | } 20 | var salt [saltedkey.SaltSize]byte 21 | copy(salt[:], processID[:saltedkey.SaltSize]) 22 | pk, err := saltedkey.SaltBlindPubKey(csp.signer.BlindPubKey(), salt) 23 | if err != nil { 24 | return "" 25 | } 26 | return fmt.Sprintf("%x", pk.Bytes()) 27 | } 28 | 29 | // PubKeyECDSA returns the public key of the plain CSP signer 30 | // If processID is nil, returns the root public key. 31 | // If processID is not nil, returns the salted public key. 32 | func (csp *BlindCSP) PubKeyECDSA(processID []byte) string { 33 | k, err := csp.signer.ECDSAPubKey() 34 | if err != nil { 35 | return "" 36 | } 37 | if processID == nil { 38 | return fmt.Sprintf("%x", k) 39 | } 40 | var salt [saltedkey.SaltSize]byte 41 | copy(salt[:], processID[:saltedkey.SaltSize]) 42 | pk, err := saltedkey.SaltECDSAPubKey(k, salt) 43 | if err != nil { 44 | return "" 45 | } 46 | return fmt.Sprintf("%x", pk) 47 | } 48 | 49 | // NewBlindRequestKey generates a new request key for blinding a content on the client side. 50 | // It returns SignerR and SignerQ values. 51 | func (csp *BlindCSP) NewBlindRequestKey() (*blind.Point, error) { 52 | k, signerR, err := blind.NewRequestParameters() 53 | if err != nil { 54 | log.Warn(err) 55 | return nil, err 56 | } 57 | index := signerR.X.String() + signerR.Y.String() 58 | if err := csp.addKey(index, k); err != nil { 59 | log.Warn(err) 60 | return nil, err 61 | } 62 | if k.Uint64() == 0 { 63 | return nil, fmt.Errorf("k can not be 0, k: %s", k) 64 | } 65 | return signerR, nil 66 | } 67 | 68 | // NewRequestKey generates a new request key for blinding a content on the client side. 69 | // It returns SignerR and SignerQ values. 70 | func (csp *BlindCSP) NewRequestKey() []byte { 71 | b := make([]byte, 32) 72 | _, err := rand.Read(b) 73 | if err != nil { 74 | panic(err) 75 | } 76 | if err := csp.addKey(string(b), new(big.Int).SetUint64(0)); err != nil { 77 | log.Warn(err) 78 | return nil 79 | } 80 | return b 81 | } 82 | 83 | // SignECDSA performs a blind signature over hash(msg). Also checks if token is valid 84 | // and removes it from the local storage. 85 | func (csp *BlindCSP) SignECDSA(token, msg []byte, processID []byte) ([]byte, error) { 86 | if k, err := csp.getKey(string(token)); err != nil || k == nil { 87 | return nil, fmt.Errorf("token not found") 88 | } 89 | defer func() { 90 | if err := csp.delKey(string(token)); err != nil { 91 | log.Warn(err) 92 | } 93 | }() 94 | var salt [saltedkey.SaltSize]byte 95 | copy(salt[:], processID[:saltedkey.SaltSize]) 96 | return csp.signer.SignECDSA(salt, msg) 97 | } 98 | 99 | // SignBlind performs a blind signature over hash. Also checks if R point is valid 100 | // and removes it from the local storage if err=nil. 101 | func (csp *BlindCSP) SignBlind(signerR *blind.Point, hash, processID []byte) ([]byte, error) { 102 | key := signerR.X.String() + signerR.Y.String() 103 | k, err := csp.getKey(key) 104 | if k == nil || err != nil { 105 | return nil, fmt.Errorf("unknown R point") 106 | } 107 | var salt [saltedkey.SaltSize]byte 108 | copy(salt[:], processID[:saltedkey.SaltSize]) 109 | signature, err := csp.signer.SignBlind(salt, hash, k) 110 | if err != nil { 111 | return nil, err 112 | } 113 | if err := csp.delKey(key); err != nil { 114 | return nil, err 115 | } 116 | return signature, nil 117 | } 118 | 119 | // SharedKey performs a signature over processId which might be used as shared key 120 | // for all users belonging to the same process. 121 | func (csp *BlindCSP) SharedKey(processID []byte) ([]byte, error) { 122 | var salt [saltedkey.SaltSize]byte 123 | copy(salt[:], processID[:saltedkey.SaltSize]) 124 | return csp.signer.SignECDSA(salt, processID) 125 | } 126 | 127 | // SyncMap helpers 128 | func (csp *BlindCSP) addKey(index string, point *big.Int) error { 129 | csp.keysLock.Lock() 130 | defer csp.keysLock.Unlock() 131 | tx := csp.keys.WriteTx() 132 | defer tx.Discard() 133 | if err := tx.Set([]byte(index), point.Bytes()); err != nil { 134 | log.Error(err) 135 | } 136 | return tx.Commit() 137 | } 138 | 139 | func (csp *BlindCSP) delKey(index string) error { 140 | csp.keysLock.Lock() 141 | defer csp.keysLock.Unlock() 142 | tx := csp.keys.WriteTx() 143 | defer tx.Discard() 144 | if err := tx.Delete([]byte(index)); err != nil { 145 | log.Error(err) 146 | } 147 | return tx.Commit() 148 | } 149 | 150 | func (csp *BlindCSP) getKey(index string) (*big.Int, error) { 151 | csp.keysLock.RLock() 152 | defer csp.keysLock.RUnlock() 153 | tx := csp.keys.WriteTx() 154 | defer tx.Discard() 155 | p, err := tx.Get([]byte(index)) 156 | if err != nil { 157 | return nil, err 158 | } 159 | return new(big.Int).SetBytes(p), nil 160 | } 161 | -------------------------------------------------------------------------------- /dockerfiles/.env.example: -------------------------------------------------------------------------------- 1 | ######################## 2 | # Docker configuration # 3 | ######################## 4 | 5 | CSP_TAG=master 6 | SERVER_NAME=csp.foo.bar 7 | LETSENCRYPT_EMAIL=email@foo.bar 8 | RESTART=unless-stopped 9 | 10 | ##################### 11 | # CSP configuration # 12 | #################### 13 | 14 | CSP_HANDLER=dummy 15 | #CSP_HANDLER=rsa 16 | #CSP_HANDLER=oauth 17 | #CSP_HANDLEROPTS=/handlerFiles/rsa.key 18 | 19 | CSP_DATADIR=/app/data 20 | CSP_LOGLEVEL=debug 21 | CSP_BASEURL=/v1/auth/elections 22 | CSP_PORT=5000 23 | #CSP_KEY= 24 | 25 | #################### 26 | ## For SMS handler # 27 | #################### 28 | 29 | #SMS_PROVIDER=twilio # messagebird also supported 30 | #SMS_PROVIDER_USERNAME=Twilio_SID 31 | #SMS_PROVIDER_AUTHTOKEN=Twilio_Token 32 | #SMS_FROM=vocdoni 33 | #SMS_BODY="Your authentication code is" 34 | 35 | #CSP_MONGODB_URL="mongodb+srv://.../?tls=true" 36 | #CSP_DATABASE=users 37 | #CSP_IMPORT_FILE=/handlerFiles/smshandler.csv 38 | #CSP_RESET_DB=true 39 | 40 | #ADMINAPI_LOGLEVEL=debug 41 | #ADMINAPI_AUTHTOKEN= 42 | ADMINAPI_PORT=5001 43 | 44 | 45 | ###################### 46 | ## For OAuth handler # 47 | ###################### 48 | 49 | # FACEBOOK_CLIENT_ID=123456789 # your FB app ID 50 | # FACEBOOK_CLIENT_SECRET=yourSecretFBClientSecret # your FB app ID 51 | # GITHUB_CLIENT_ID=123456789 # your FB app ID 52 | # GITHUB_CLIENT_SECRET=yourSecretGithubClientSecret # your Github app ID 53 | 54 | # CSP_ADMIN_TOKEN_SECRET=adminTokenGenerationSecret # a string to be used on the admin token generation 55 | 56 | # CSP_MONGODB_URL=mongodb://root:vocdoni@mongo:27017/ 57 | # CSP_DATABASE=oauth -------------------------------------------------------------------------------- /dockerfiles/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /dockerfiles/README.md: -------------------------------------------------------------------------------- 1 | # Docker compose deploy 2 | 3 | ## Standalone 4 | 5 | You need to `cp .env.example .env` and edit `.env` to adapt it to your particular deployment 6 | 7 | Then: 8 | 9 | ```sh 10 | docker compose up -d 11 | ``` 12 | 13 | Note that the RESTART variable can be set to change the behaviour across restarts. To ensure the node is started after a reboot, set it to `always` or `unless-stopped` 14 | -------------------------------------------------------------------------------- /dockerfiles/docker-compose-apismsadmin.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | blind-csp: 5 | image: ghcr.io/vocdoni/blind-csp:release-fcb 6 | env_file: ".env" 7 | sysctls: 8 | net.core.somaxconn: 8128 9 | volumes: 10 | - blind-csp:/app/data 11 | - "./handlerFiles:/handlerFiles" 12 | labels: 13 | - "traefik.enable=true" 14 | - "traefik.http.routers.blind-csp.rule=(Host(`${SERVER_NAME}`) && PathPrefix(`/v1`))" 15 | - "traefik.http.routers.blind-csp.entrypoints=websecure" 16 | - "traefik.http.routers.blind-csp.tls.certresolver=le" 17 | - "traefik.http.routers.blind-csp.service=blind-csp" 18 | - "traefik.http.services.blind-csp.loadbalancer.server.port=5000" 19 | 20 | smsapiadmin: 21 | image: ghcr.io/vocdoni/blind-csp:release-fcb 22 | entrypoint: "/app/smsApiAdmin" 23 | env_file: ".env" 24 | sysctls: 25 | net.core.somaxconn: 8128 26 | volumes: 27 | - smsapiadmin:/app/data 28 | labels: 29 | - "traefik.enable=true" 30 | - "traefik.http.routers.smsapiadmin.rule=(Host(`${SERVER_NAME}`) && PathPrefix(`/smsapi`))" 31 | - "traefik.http.routers.smsapiadmin.entrypoints=websecure" 32 | - "traefik.http.routers.smsapiadmin.tls.certresolver=le" 33 | - "traefik.http.routers.smsapiadmin.service=smsapiadmin" 34 | - "traefik.http.services.smsapiadmin.loadbalancer.server.port=5001" 35 | 36 | traefik: 37 | image: traefik:2.5 38 | ports: 39 | - 80:80 40 | - 443:443 41 | volumes: 42 | - "./letsencrypt:/letsencrypt" 43 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 44 | command: 45 | - "--log.level=DEBUG" 46 | - "--providers.docker=true" 47 | - "--providers.docker.exposedbydefault=false" 48 | - "--entrypoints.web.address=:80" 49 | - "--entrypoints.web.http.redirections.entryPoint.to=websecure" 50 | - "--entrypoints.web.http.redirections.entryPoint.scheme=https" 51 | - "--entrypoints.web.http.redirections.entrypoint.permanent=true" 52 | - "--entrypoints.websecure.address=:443" 53 | - "--certificatesresolvers.le.acme.httpchallenge=true" 54 | - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" 55 | - "--certificatesresolvers.le.acme.email=cloud@vocdoni.io" 56 | - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" 57 | restart: always 58 | watchtower: 59 | image: containrrr/watchtower 60 | volumes: 61 | - /var/run/docker.sock:/var/run/docker.sock 62 | labels: 63 | - com.centurylinklabs.watchtower.enable="false" 64 | command: --interval 30 --cleanup 65 | 66 | 67 | volumes: 68 | blind-csp: {} 69 | traefik: {} 70 | smsapiadmin: {} 71 | 72 | -------------------------------------------------------------------------------- /dockerfiles/docker-compose-oauth.yml: -------------------------------------------------------------------------------- 1 | # Use root/example as user/password credentials 2 | version: '3.1' 3 | 4 | services: 5 | 6 | mongo: 7 | image: mongo 8 | restart: ${RESTART:-no} 9 | ports: 10 | - 27017:27017 11 | environment: 12 | - MONGO_INITDB_ROOT_USERNAME=root 13 | - MONGO_INITDB_ROOT_PASSWORD=vocdoni 14 | - MONGO_INITDB_DATABASE=oauth 15 | volumes: 16 | - mongodb:/data/mongodb 17 | 18 | blind-csp: 19 | build: 20 | context: ../ 21 | image: ghcr.io/vocdoni/blind-csp:${CSP_TAG:-master} 22 | env_file: 23 | - .env 24 | ports: 25 | - 5000:5000 26 | depends_on: 27 | - mongo 28 | sysctls: 29 | net.core.somaxconn: 8128 30 | volumes: 31 | - blind-csp:/app/data 32 | - "./handlerFiles:/handlerFiles" 33 | restart: ${RESTART:-no} 34 | 35 | volumes: 36 | blind-csp: {} 37 | mongodb: {} 38 | -------------------------------------------------------------------------------- /dockerfiles/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | blind-csp: 5 | image: ghcr.io/vocdoni/blind-csp:${CSP_TAG:-master} 6 | env_file: .env 7 | sysctls: 8 | net.core.somaxconn: 8128 9 | volumes: 10 | - blind-csp:/app/data 11 | - ./handlerFiles:/handlerFiles 12 | labels: 13 | - "traefik.enable=true" 14 | - "traefik.http.routers.blind-csp.rule=Host(`${SERVER_NAME}`)" 15 | - "traefik.http.routers.blind-csp.entrypoints=websecure" 16 | - "traefik.http.routers.blind-csp.tls.certresolver=le" 17 | - "traefik.http.routers.blind-csp.service=blind-csp" 18 | - "traefik.http.services.blind-csp.loadbalancer.server.port=${CSP_PORT}" 19 | restart: ${RESTART:-no} 20 | 21 | traefik: 22 | image: traefik:2.5 23 | ports: 24 | - 80:80 25 | - 443:443 26 | volumes: 27 | - "./letsencrypt:/letsencrypt" 28 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 29 | command: 30 | - "--log.level=DEBUG" 31 | - "--providers.docker=true" 32 | - "--providers.docker.exposedbydefault=false" 33 | - "--entrypoints.web.address=:80" 34 | - "--entrypoints.web.http.redirections.entryPoint.to=websecure" 35 | - "--entrypoints.web.http.redirections.entryPoint.scheme=https" 36 | - "--entrypoints.web.http.redirections.entrypoint.permanent=true" 37 | - "--entrypoints.websecure.address=:443" 38 | - "--certificatesresolvers.le.acme.httpchallenge=true" 39 | - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" 40 | - "--certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}" 41 | - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" 42 | restart: ${RESTART:-no} 43 | 44 | watchtower: 45 | image: containrrr/watchtower 46 | volumes: 47 | - /var/run/docker.sock:/var/run/docker.sock 48 | labels: 49 | - com.centurylinklabs.watchtower.enable="false" 50 | command: --interval 30 --cleanup 51 | restart: ${RESTART:-no} 52 | 53 | volumes: 54 | blind-csp: {} 55 | traefik: {} 56 | 57 | -------------------------------------------------------------------------------- /dockerfiles/handlerFiles/.gitignore: -------------------------------------------------------------------------------- 1 | ** 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /dockerfiles/letsencrypt/.gitignore: -------------------------------------------------------------------------------- 1 | ** 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vocdoni/blind-csp 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/arnaucube/go-blindsecp256k1 v0.0.0-20220421060538-07077d895da5 7 | github.com/cockroachdb/errors v1.9.1 // indirect 8 | github.com/cockroachdb/pebble v0.0.0-20230620232302-06034ff014e0 // indirect 9 | github.com/docker/docker v23.0.5+incompatible // indirect 10 | github.com/enriquebris/goconcurrentqueue v0.6.3 11 | github.com/ethereum/go-ethereum v1.12.0 12 | github.com/frankban/quicktest v1.14.5 13 | github.com/google/uuid v1.3.0 14 | github.com/klauspost/compress v1.16.5 // indirect 15 | github.com/libp2p/go-reuseport v0.2.0 // indirect 16 | github.com/messagebird/go-rest-api/v7 v7.1.0 17 | github.com/nyaruka/phonenumbers v1.1.0 18 | github.com/spf13/pflag v1.0.5 19 | github.com/spf13/viper v1.15.0 20 | github.com/subosito/gotenv v1.4.2 // indirect 21 | github.com/testcontainers/testcontainers-go v0.20.1 22 | github.com/twilio/twilio-go v0.26.0 23 | go.mongodb.org/mongo-driver v1.10.0 24 | go.vocdoni.io/dvote v1.7.1-0.20230621075440-ed06c3517c05 25 | ) 26 | 27 | require ( 28 | github.com/766b/chi-prometheus v0.0.0-20211217152057-87afa9aa2ca8 // indirect 29 | github.com/DataDog/zstd v1.5.2 // indirect 30 | github.com/Microsoft/go-winio v0.6.0 // indirect 31 | github.com/beorn7/perks v1.0.1 // indirect 32 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect 33 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect 34 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 35 | github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect 36 | github.com/cockroachdb/redact v1.1.3 // indirect 37 | github.com/containerd/containerd v1.6.19 // indirect 38 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 39 | github.com/docker/distribution v2.8.1+incompatible // indirect 40 | github.com/docker/go-connections v0.4.0 // indirect 41 | github.com/docker/go-units v0.5.0 // indirect 42 | github.com/fsnotify/fsnotify v1.6.0 // indirect 43 | github.com/getsentry/sentry-go v0.18.0 // indirect 44 | github.com/glendc/go-external-ip v0.1.0 // indirect 45 | github.com/go-chi/chi v4.1.2+incompatible // indirect 46 | github.com/go-chi/cors v1.2.1 // indirect 47 | github.com/gogo/protobuf v1.3.2 // indirect 48 | github.com/golang/mock v1.6.0 // indirect 49 | github.com/golang/protobuf v1.5.3 // indirect 50 | github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect 51 | github.com/google/go-cmp v0.5.9 // indirect 52 | github.com/hashicorp/hcl v1.0.1-vault-5 // indirect 53 | github.com/kr/pretty v0.3.1 // indirect 54 | github.com/kr/text v0.2.0 // indirect 55 | github.com/magiconair/properties v1.8.7 // indirect 56 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 57 | github.com/mitchellh/mapstructure v1.5.0 // indirect 58 | github.com/moby/term v0.5.0 // indirect 59 | github.com/montanaflynn/stats v0.6.6 // indirect 60 | github.com/morikuni/aec v1.0.0 // indirect 61 | github.com/opencontainers/go-digest v1.0.0 // indirect 62 | github.com/opencontainers/image-spec v1.1.0-rc2 // indirect 63 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 64 | github.com/pkg/errors v0.9.1 // indirect 65 | github.com/prometheus/client_golang v1.16.0 // indirect 66 | github.com/prometheus/client_model v0.4.0 // indirect 67 | github.com/prometheus/common v0.44.0 // indirect 68 | github.com/prometheus/procfs v0.11.0 // indirect 69 | github.com/rogpeppe/go-internal v1.10.0 // indirect 70 | github.com/sirupsen/logrus v1.9.0 // indirect 71 | github.com/spf13/afero v1.9.3 // indirect 72 | github.com/spf13/cast v1.5.0 // indirect 73 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 74 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 75 | github.com/xdg-go/scram v1.1.1 // indirect 76 | github.com/xdg-go/stringprep v1.0.3 // indirect 77 | github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect 78 | golang.org/x/crypto v0.10.0 // indirect 79 | golang.org/x/exp v0.0.0-20230420155640-133eef4313cb // indirect 80 | golang.org/x/net v0.10.0 // indirect 81 | golang.org/x/sync v0.2.0 // indirect 82 | golang.org/x/sys v0.9.0 // indirect 83 | golang.org/x/text v0.10.0 // indirect 84 | google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect 85 | google.golang.org/grpc v1.53.0 // indirect 86 | google.golang.org/protobuf v1.30.0 // indirect 87 | gopkg.in/ini.v1 v1.67.0 // indirect 88 | gopkg.in/yaml.v3 v3.0.1 89 | ) 90 | 91 | require go.vocdoni.io/proto v1.14.5-0.20230426091403-1c1475660dc8 92 | 93 | require ( 94 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 95 | github.com/cenkalti/backoff/v4 v4.2.0 // indirect 96 | github.com/cockroachdb/tokenbucket v0.0.0-20230613231145-182959a1fad6 // indirect 97 | github.com/cometbft/cometbft v0.37.1 // indirect 98 | github.com/cosmos/gogoproto v1.4.1 // indirect 99 | github.com/cpuguy83/dockercfg v0.3.1 // indirect 100 | github.com/go-chi/chi/v5 v5.0.8 // indirect 101 | github.com/go-kit/log v0.2.1 // indirect 102 | github.com/go-logfmt/logfmt v0.5.1 // indirect 103 | github.com/hashicorp/golang-lru/v2 v2.0.2 // indirect 104 | github.com/holiman/uint256 v1.2.2 // indirect 105 | github.com/iden3/go-iden3-crypto v0.0.13 // indirect 106 | github.com/imdario/mergo v0.3.13 // indirect 107 | github.com/mattn/go-colorable v0.1.13 // indirect 108 | github.com/mattn/go-isatty v0.0.18 // indirect 109 | github.com/moby/patternmatcher v0.5.0 // indirect 110 | github.com/moby/sys/sequential v0.5.0 // indirect 111 | github.com/opencontainers/runc v1.1.5 // indirect 112 | github.com/petermattis/goid v0.0.0-20221018141743-354ef7f2fd21 // indirect 113 | github.com/rs/zerolog v1.28.0 // indirect 114 | github.com/sasha-s/go-deadlock v0.3.1 // indirect 115 | golang.org/x/mod v0.10.0 // indirect 116 | golang.org/x/tools v0.7.0 // indirect 117 | ) 118 | -------------------------------------------------------------------------------- /handlers/dummy.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/vocdoni/blind-csp/types" 9 | "go.vocdoni.io/dvote/httprouter" 10 | "go.vocdoni.io/dvote/log" 11 | ) 12 | 13 | // DummyHandler is a handler for testing that returns always true 14 | type DummyHandler struct{} 15 | 16 | // Init does nothing 17 | func (dh *DummyHandler) Init(r *httprouter.HTTProuter, baseURL string, opts ...string) error { 18 | return nil 19 | } 20 | 21 | // GetName returns the name of the handler 22 | func (dh *DummyHandler) Name() string { 23 | return "dummy" 24 | } 25 | 26 | // Info returns the handler options and required auth steps. 27 | func (dh *DummyHandler) Info() *types.Message { 28 | return &types.Message{ 29 | Title: "dummy handler", 30 | AuthType: "auth", 31 | SignType: types.AllSignatures, 32 | AuthSteps: []*types.AuthField{ 33 | {Title: "Name", Type: "text"}, 34 | }, 35 | } 36 | } 37 | 38 | // Indexer takes a unique user identifier and returns the list of processIDs where 39 | // the user is elegible for participation. This is a helper function that might not 40 | // be implemented (depends on the handler use case). 41 | func (dh *DummyHandler) Indexer(userID types.HexBytes) []types.Election { 42 | return nil 43 | } 44 | 45 | // Auth is the handler for the dummy handler 46 | func (dh *DummyHandler) Auth(r *http.Request, 47 | ca *types.Message, pid types.HexBytes, signType string, step int, 48 | ) types.AuthResponse { 49 | log.Infof(r.UserAgent()) 50 | ipaddr := strings.Split(r.RemoteAddr, ":")[0] 51 | log.Infof("new user registered with ip %s", ipaddr) 52 | return types.AuthResponse{ 53 | Success: true, 54 | Response: []string{fmt.Sprintf("welcome to process %s!", pid)}, 55 | AuthToken: nil, // make authToken nil explicit, so the auth process is considered ended 56 | } 57 | } 58 | 59 | // RequireCertificate must return true if the auth handler requires some kind of client 60 | // TLS certificate. If true then CertificateCheck() and HardcodedCertificate() methods 61 | // must be correctly implemented. Else both function can just return true and nil. 62 | func (dh *DummyHandler) RequireCertificate() bool { 63 | return false 64 | } 65 | 66 | // CertificateCheck is used by the Auth handler to ensure a specific certificate is 67 | // added to the CA cert pool on the HTTP/TLS layer (optional). 68 | func (dh *DummyHandler) CertificateCheck(subject []byte) bool { 69 | return true 70 | } 71 | 72 | // Certificates returns a hardcoded CA certificated that will be added to the 73 | // CA cert pool by the handler (optional). 74 | func (dh *DummyHandler) Certificates() [][]byte { 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /handlers/handlerlist/handlerlist.go: -------------------------------------------------------------------------------- 1 | package handlerlist 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/vocdoni/blind-csp/handlers" 8 | "github.com/vocdoni/blind-csp/handlers/idcathandler" 9 | "github.com/vocdoni/blind-csp/handlers/oauthhandler" 10 | "github.com/vocdoni/blind-csp/handlers/rsahandler" 11 | "github.com/vocdoni/blind-csp/handlers/smshandler" 12 | ) 13 | 14 | // Handlers contains the list of available handlers 15 | var Handlers = map[string]handlers.AuthHandler{ 16 | "dummy": &handlers.DummyHandler{}, 17 | "uniqueIp": &handlers.IpaddrHandler{}, 18 | "simpleMath": &handlers.SimpleMathHandler{}, 19 | "idCat": &idcathandler.IDcatHandler{ForTesting: false}, 20 | "idCatTesting": &idcathandler.IDcatHandler{ForTesting: true}, 21 | "rsa": &rsahandler.RsaHandler{}, 22 | "sms": &smshandler.SmsHandler{}, 23 | "oauth": &oauthhandler.OauthHandler{}, 24 | } 25 | 26 | // HandlersList returns a human friendly string with the list of available handlers. 27 | func HandlersList() string { 28 | var hl []string 29 | for k := range Handlers { 30 | hl = append(hl, k) 31 | } 32 | sort.Strings(hl) 33 | return strings.Join(hl, ",") 34 | } 35 | -------------------------------------------------------------------------------- /handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/vocdoni/blind-csp/types" 7 | "go.vocdoni.io/dvote/httprouter" 8 | ) 9 | 10 | // AuthFunc is the function type required for performing an authentication 11 | // via callback handler. 12 | type AuthFunc = func( 13 | httpRequest *http.Request, 14 | message *types.Message, 15 | electionID types.HexBytes, 16 | signaturetype string, 17 | authStep int) types.AuthResponse 18 | 19 | // InfoFunc is the function type required for providing the handler options 20 | // and description via callback handler. 21 | type InfoFunc = func() (message *types.Message) 22 | 23 | // IndexerFunc is the function type used for providing the user with the list of 24 | // processes where its participation is allowed. 25 | type IndexerFunc = func(userID types.HexBytes) (elections []types.Election) 26 | 27 | // AuthHandler is the interface that all CSP handlers should implement. 28 | // The Auth method must return either the request is valid or not. 29 | // The current signatureType supported are: 30 | // 1. ecdsa: performs a plain ECDSA signature over the payload provided by the user 31 | // 2. blind: performs a blind ECDSA signature over the payload provided by the user 32 | // 3. sharedkey: performs a plain ECDSA signature over hash(processId) 33 | type AuthHandler interface { 34 | Init(router *httprouter.HTTProuter, httpBaseRoute string, opts ...string) error 35 | Name() string 36 | Auth(httpRequest *http.Request, 37 | message *types.Message, 38 | electionID types.HexBytes, 39 | signatureType string, 40 | step int) types.AuthResponse 41 | RequireCertificate() bool 42 | Certificates() [][]byte 43 | CertificateCheck(subject []byte) bool 44 | Info() *types.Message 45 | Indexer(userID types.HexBytes) []types.Election 46 | } 47 | -------------------------------------------------------------------------------- /handlers/ipaddr.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | 10 | "go.vocdoni.io/dvote/httprouter" 11 | 12 | "github.com/vocdoni/blind-csp/types" 13 | "go.vocdoni.io/dvote/db" 14 | "go.vocdoni.io/dvote/db/metadb" 15 | "go.vocdoni.io/dvote/log" 16 | ) 17 | 18 | // IpaddrHandler is a handler that allows only 1 registration for IP 19 | type IpaddrHandler struct { 20 | kv db.Database 21 | keysLock sync.RWMutex 22 | } 23 | 24 | func (ih *IpaddrHandler) addKey(index, value []byte) { 25 | ih.keysLock.Lock() 26 | defer ih.keysLock.Unlock() 27 | tx := ih.kv.WriteTx() 28 | defer tx.Discard() 29 | if err := tx.Set(index, value); err != nil { 30 | log.Error(err) 31 | } 32 | if err := tx.Commit(); err != nil { 33 | log.Error(err) 34 | } 35 | } 36 | 37 | func (ih *IpaddrHandler) exist(index []byte) bool { 38 | ih.keysLock.RLock() 39 | defer ih.keysLock.RUnlock() 40 | tx := ih.kv.WriteTx() 41 | defer tx.Discard() 42 | _, err := tx.Get(index) 43 | return err == nil 44 | } 45 | 46 | // Name returns the name of the handler 47 | func (ih *IpaddrHandler) Name() string { 48 | return "uniqueIp" 49 | } 50 | 51 | // Init initializes the handler. 52 | // Takes one argument for persistent data directory. 53 | func (ih *IpaddrHandler) Init(r *httprouter.HTTProuter, baseRoute string, opts ...string) (err error) { 54 | ih.kv, err = metadb.New(db.TypePebble, filepath.Clean(opts[0])) 55 | return err 56 | } 57 | 58 | // Auth is the handler for the ipaddr handler 59 | func (ih *IpaddrHandler) Auth(r *http.Request, 60 | ca *types.Message, pid types.HexBytes, signType string, step int, 61 | ) types.AuthResponse { 62 | log.Infof(r.UserAgent()) 63 | ipaddr := strings.Split(r.RemoteAddr, ":")[0] 64 | if len(ipaddr) == 0 { 65 | log.Warnf("cannot get ip from request: %s", r.RemoteAddr) 66 | return types.AuthResponse{Response: []string{"cannot get IP from request"}} 67 | } 68 | if signType == types.SignatureTypeSharedKey { 69 | return types.AuthResponse{} 70 | } 71 | if ih.exist([]byte(ipaddr)) { 72 | log.Warnf("ip %s already registered", ipaddr) 73 | return types.AuthResponse{Response: []string{"already registered"}} 74 | } 75 | ih.addKey([]byte(ipaddr), nil) 76 | log.Infof("new user registered with ip %s", ipaddr) 77 | return types.AuthResponse{ 78 | Success: true, 79 | Response: []string{fmt.Sprintf("welcome %s", ipaddr)}, 80 | } 81 | } 82 | 83 | // Info returns the handler options and required auth steps. 84 | func (ih *IpaddrHandler) Info() *types.Message { 85 | return &types.Message{ 86 | Title: "unique IP address", 87 | AuthType: "auth", 88 | SignType: types.AllSignatures, 89 | AuthSteps: []*types.AuthField{}, 90 | } 91 | } 92 | 93 | // Indexer takes a unique user identifier and returns the list of processIDs where 94 | // the user is elegible for participation. This is a helper function that might not 95 | // be implemented (depends on the handler use case). 96 | func (ih *IpaddrHandler) Indexer(userID types.HexBytes) []types.Election { 97 | return nil 98 | } 99 | 100 | // RequireCertificate must return true if the auth handler requires some kind of client 101 | // TLS certificate. If true then CertificateCheck() and HardcodedCertificate() methods 102 | // must be correctly implemented. Else both function can just return true and nil. 103 | func (ih *IpaddrHandler) RequireCertificate() bool { 104 | return false 105 | } 106 | 107 | // CertificateCheck is used by the Auth handler to ensure a specific certificate is 108 | // added to the CA cert pool on the HTTP/TLS layer (optional). 109 | func (ih *IpaddrHandler) CertificateCheck(subject []byte) bool { 110 | return true 111 | } 112 | 113 | // Certificates returns a hardcoded CA certificated that will be added to the 114 | // CA cert pool by the handler (optional). 115 | func (ih *IpaddrHandler) Certificates() [][]byte { 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /handlers/oauthhandler/config.yml: -------------------------------------------------------------------------------- 1 | providers: 2 | facebook: 3 | name: Facebook 4 | auth_url: https://www.facebook.com/v16.0/dialog/oauth 5 | token_url: https://graph.facebook.com/v16.0/oauth/access_token 6 | profile_url: https://graph.facebook.com/me?fields=id,name,email 7 | client_id: FACEBOOK_CLIENT_ID 8 | client_secret: FACEBOOK_CLIENT_SECRET 9 | scope: email 10 | username_field: email 11 | github: 12 | name: Github 13 | auth_url: https://github.com/login/oauth/authorize 14 | token_url: https://github.com/login/oauth/access_token 15 | profile_url: https://api.github.com/user 16 | client_id: GITHUB_CLIENT_ID 17 | client_secret: GITHUB_CLIENT_SECRET 18 | scope: user:email 19 | username_field: login 20 | twitter: 21 | name: Twitter 22 | auth_url: https://api.twitter.com/oauth/authenticate 23 | token_url: https://api.twitter.com/oauth/access_token 24 | profile_url: https://api.twitter.com/1.1/account/verify_credentials.json 25 | client_id: 26 | client_secret: 27 | scope: email 28 | spotify: 29 | name: Spotify 30 | auth_url: https://accounts.spotify.com/authorize 31 | token_url: https://accounts.spotify.com/api/token 32 | profile_url: https://api.spotify.com/v1/me 33 | client_id: 34 | client_secret: 35 | scope: user-read-email 36 | linkedin: 37 | name: LinkedIn 38 | auth_url: https://www.linkedin.com/oauth/v2/authorization 39 | token_url: https://www.linkedin.com/oauth/v2/accessToken 40 | profile_url: https://api.linkedin.com/v2/me 41 | client_id: 42 | client_secret: 43 | scope: r_emailaddress 44 | google: 45 | name: Google 46 | auth_url: https://accounts.google.com/o/oauth2/v2/auth 47 | token_url: https://oauth2.googleapis.com/token 48 | profile_url: https://www.googleapis.com/oauth2/v1/userinfo 49 | client_id: 50 | client_secret: 51 | scope: email -------------------------------------------------------------------------------- /handlers/oauthhandler/oauthhandler.go: -------------------------------------------------------------------------------- 1 | package oauthhandler 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/google/uuid" 8 | "github.com/vocdoni/blind-csp/admin" 9 | "github.com/vocdoni/blind-csp/model" 10 | "github.com/vocdoni/blind-csp/types" 11 | "go.vocdoni.io/dvote/httprouter" 12 | "go.vocdoni.io/dvote/log" 13 | ) 14 | 15 | // OauthHandler is a handler that requires a verifiable oAuth token to be resolved. 16 | type OauthHandler struct{} 17 | 18 | // Init does nothing 19 | func (oh *OauthHandler) Init(r *httprouter.HTTProuter, baseURL string, opts ...string) error { 20 | admin, err := admin.NewAdmin() 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | if err := admin.ServeAPI(r, baseURL+"/admin"); err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | return nil 30 | } 31 | 32 | // GetName returns the name of the handler 33 | func (oh *OauthHandler) Name() string { 34 | return "oAuth" 35 | } 36 | 37 | // Info returns the handler options and required auth steps. 38 | func (oh *OauthHandler) Info() *types.Message { 39 | return &types.Message{ 40 | Title: "oAuth handler", 41 | AuthType: "auth", 42 | SignType: types.AllSignatures, 43 | AuthSteps: []*types.AuthField{ 44 | {Title: "GetAuthUrl", Type: "text"}, 45 | {Title: "VerifyElection", Type: "text"}, 46 | }, 47 | } 48 | } 49 | 50 | // Indexer takes a unique user identifier and returns the list of processIDs where 51 | // the user is elegible for participation. This is a helper function that might not 52 | // be implemented (depends on the handler use case). 53 | func (oh *OauthHandler) Indexer(userID types.HexBytes) []types.Election { 54 | // Init the Storage and get the user 55 | storage := &model.MongoStorage{} 56 | if err := storage.Init(); err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | userelectionStore := model.NewUserelectionStore(storage) 61 | user, err := userelectionStore.GetUserElections(userID) 62 | if err != nil { 63 | log.Warnf("cannot get indexer elections: %v", err) 64 | return nil 65 | } 66 | 67 | indexerElections := []types.Election{} 68 | for _, e := range user.Elections { 69 | remainingAttempts := 1 70 | if *e.Consumed { 71 | remainingAttempts = 0 72 | } 73 | 74 | ie := types.Election{ 75 | RemainingAttempts: remainingAttempts, 76 | Consumed: *e.Consumed, 77 | ElectionID: e.ElectionID, 78 | ExtraData: []string{user.Service, user.Handler, user.Mode, user.Data}, 79 | } 80 | indexerElections = append(indexerElections, ie) 81 | } 82 | 83 | return indexerElections 84 | } 85 | 86 | // Auth is the handler for the dummy handler 87 | func (oh *OauthHandler) Auth(r *http.Request, 88 | c *types.Message, pid types.HexBytes, signType string, step int, 89 | ) types.AuthResponse { 90 | if signType != types.SignatureTypeBlind { 91 | return types.AuthResponse{Response: []string{"incorrect signature type, only blind supported"}} 92 | } 93 | 94 | providers, err := Init(pid.String()) 95 | if err != nil { 96 | return types.AuthResponse{Response: []string{"failed to initialize providers"}} 97 | } 98 | 99 | switch step { 100 | case 0: 101 | if len(c.AuthData) != 2 { 102 | return types.AuthResponse{Response: []string{"missing auth data"}} 103 | } 104 | service := c.AuthData[0] 105 | redirectURL := c.AuthData[1] 106 | atoken := uuid.New() 107 | 108 | provider, ok := providers[service] 109 | if !ok { 110 | return types.AuthResponse{Response: []string{"Provider not found."}} 111 | } 112 | 113 | // Get the Service Auth URL from the electionID and requested service 114 | authURL := provider.GetAuthURL(redirectURL) 115 | 116 | return types.AuthResponse{ 117 | Success: true, 118 | AuthToken: &atoken, 119 | Response: []string{authURL}, 120 | } 121 | case 1: 122 | // Convert the provided "code" to an oAuth Token 123 | if len(c.AuthData) != 3 { 124 | return types.AuthResponse{Response: []string{"auth token not provided or missing auth data"}} 125 | } 126 | service := c.AuthData[0] 127 | oAuthCode := c.AuthData[1] 128 | redirectURL := c.AuthData[2] 129 | provider, ok := providers[service] 130 | if !ok { 131 | log.Warnw("Provider not found.", "service", service) 132 | return types.AuthResponse{Response: []string{"Provider not found."}} 133 | } 134 | 135 | oAuthToken, err := provider.GetOAuthToken(oAuthCode, redirectURL) 136 | if err != nil { 137 | log.Warnw("error obtaining the oAuthToken", "err", err) 138 | return types.AuthResponse{Response: []string{"error obtaining the oAuthToken"}} 139 | } 140 | 141 | // Get the profile 142 | profileRaw, err := provider.GetOAuthProfile(oAuthToken) 143 | if err != nil { 144 | log.Warnw("error obtaining the profile", "err", err) 145 | return types.AuthResponse{Response: []string{"error obtaining the profile"}} 146 | } 147 | 148 | var profile map[string]interface{} 149 | if err := json.Unmarshal(profileRaw, &profile); err != nil { 150 | log.Warnw("error marshalling the profile", "err", err) 151 | return types.AuthResponse{Response: []string{"error obtaining the profile"}} 152 | } 153 | 154 | // Init the Storage and get the user 155 | storage := &model.MongoStorage{} 156 | if err := storage.Init(); err != nil { 157 | log.Fatal(err) 158 | } 159 | 160 | consumed := false 161 | request := model.UserelectionRequest{ 162 | Handler: "oauth", 163 | Service: service, 164 | Consumed: &consumed, 165 | } 166 | 167 | userelectionStore := model.NewUserelectionStore(storage) // This is a bit ugly, but it's the only way to avoid services 168 | for _, mode := range []string{"usernames"} { 169 | request.Mode = mode 170 | 171 | if mode == "usernames" { 172 | request.Data = profile[provider.UsernameField].(string) 173 | } 174 | 175 | usersPtr, err := userelectionStore.SearchUserelection(pid, request) 176 | if err != nil { 177 | log.Fatal(err) 178 | } 179 | 180 | // Check the length of the users array 181 | users := *usersPtr 182 | consumedT := true 183 | if len(users) == 1 { 184 | if _, err := userelectionStore.UpdateUserelection(pid, 185 | users[0].UserID, model.UserelectionRequest{Consumed: &consumedT}); err != nil { 186 | return types.AuthResponse{Response: []string{"error updating the voter"}} 187 | } 188 | 189 | return types.AuthResponse{ 190 | Success: true, 191 | Response: []string{"Challenge completed!", string(profileRaw)}, 192 | } 193 | } 194 | } 195 | 196 | return types.AuthResponse{ 197 | Success: false, 198 | Response: []string{"No match found for the provided service"}, 199 | } 200 | } 201 | 202 | return types.AuthResponse{Response: []string{"invalid auth step"}} 203 | } 204 | 205 | // RequireCertificate must return true if the auth handler requires some kind of client 206 | // TLS certificate. If true then CertificateCheck() and HardcodedCertificate() methods 207 | // must be correctly implemented. Else both function can just return true and nil. 208 | func (oh *OauthHandler) RequireCertificate() bool { 209 | return false 210 | } 211 | 212 | // CertificateCheck is used by the Auth handler to ensure a specific certificate is 213 | // added to the CA cert pool on the HTTP/TLS layer (optional). 214 | func (oh *OauthHandler) CertificateCheck(subject []byte) bool { 215 | return true 216 | } 217 | 218 | // Certificates returns a hardcoded CA certificated that will be added to the 219 | // CA cert pool by the handler (optional). 220 | func (oh *OauthHandler) Certificates() [][]byte { 221 | return nil 222 | } 223 | -------------------------------------------------------------------------------- /handlers/oauthhandler/providers.go: -------------------------------------------------------------------------------- 1 | package oauthhandler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/spf13/viper" 14 | "go.vocdoni.io/dvote/log" 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | // Config represents the configuration file. 19 | type Config struct { 20 | Providers map[string]ProviderConfig `yaml:"providers"` 21 | } 22 | 23 | // ProviderConfig represents the configuration for an OAuth provider. 24 | type ProviderConfig struct { 25 | Name string `yaml:"name"` 26 | AuthURL string `yaml:"auth_url"` 27 | TokenURL string `yaml:"token_url"` 28 | ProfileURL string `yaml:"profile_url"` 29 | ClientID string `yaml:"client_id"` 30 | ClientSecret string `yaml:"client_secret"` 31 | Scope string `yaml:"scope"` 32 | UsernameField string `yaml:"username_field"` 33 | } 34 | 35 | // Provider is the OAuth provider. 36 | type Provider struct { 37 | Name string 38 | AuthURL string 39 | TokenURL string 40 | ProfileURL string 41 | ClientID string 42 | ClientSecret string 43 | Scope string 44 | UsernameField string 45 | } 46 | 47 | // OAuthToken is the OAuth token. 48 | type OAuthToken struct { 49 | AccessToken string `json:"access_token"` 50 | TokenType string `json:"token_type"` 51 | ExpiresIn int `json:"expires_in"` 52 | } 53 | 54 | // NewProvider creates a new OAuth provider. 55 | func NewProvider(name, authURL, tokenURL, profileURL, clientID, clientSecret, scope string, usernameField string) *Provider { 56 | return &Provider{ 57 | Name: name, 58 | AuthURL: authURL, 59 | TokenURL: tokenURL, 60 | ProfileURL: profileURL, 61 | ClientID: clientID, 62 | ClientSecret: clientSecret, 63 | Scope: scope, 64 | UsernameField: usernameField, 65 | } 66 | } 67 | 68 | func Init(pid string) (map[string]*Provider, error) { 69 | // Load the environment variables. 70 | viper := viper.New() 71 | viper.AutomaticEnv() 72 | 73 | // Read the configuration file. 74 | filename := filepath.Join("handlers", "oauthhandler", "config.yml") 75 | data, err := os.ReadFile(filename) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to read configuration file: %v", err) 78 | } 79 | 80 | // Parse the configuration file. 81 | var cfg Config 82 | err = yaml.Unmarshal(data, &cfg) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to parse configuration file: %v", err) 85 | } 86 | 87 | // Initialize the providers. 88 | providers := make(map[string]*Provider, len(cfg.Providers)) 89 | for name, conf := range cfg.Providers { 90 | provider := NewProvider( 91 | conf.Name, 92 | conf.AuthURL, 93 | conf.TokenURL, 94 | conf.ProfileURL, 95 | viper.GetString(conf.ClientID), 96 | viper.GetString(conf.ClientSecret), 97 | conf.Scope, 98 | conf.UsernameField, 99 | ) 100 | providers[name] = provider 101 | } 102 | 103 | return providers, nil 104 | } 105 | 106 | // GetAuthURL returns the OAuth authorize URL for the provider. 107 | func (p *Provider) GetAuthURL(redirectURL string) string { 108 | u, _ := url.Parse(p.AuthURL) 109 | q := u.Query() 110 | q.Set("client_id", p.ClientID) 111 | q.Set("redirect_uri", redirectURL) 112 | q.Set("scope", p.Scope) 113 | u.RawQuery = q.Encode() 114 | return u.String() 115 | } 116 | 117 | // GetOAuthToken obtains the OAuth token for the provider using the authorization code. 118 | func (p *Provider) GetOAuthToken(code string, redirectURL string) (*OAuthToken, error) { 119 | data := url.Values{} 120 | data.Set("grant_type", "authorization_code") 121 | data.Set("client_id", p.ClientID) 122 | data.Set("client_secret", p.ClientSecret) 123 | data.Set("redirect_uri", redirectURL) 124 | data.Set("code", code) 125 | 126 | req, err := http.NewRequest("POST", p.TokenURL, strings.NewReader(data.Encode())) 127 | if err != nil { 128 | return nil, err 129 | } 130 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 131 | req.Header.Set("Accept", "application/json") 132 | 133 | client := &http.Client{} 134 | resp, err := client.Do(req) 135 | if err != nil { 136 | return nil, err 137 | } 138 | defer func() { 139 | if err := resp.Body.Close(); err != nil { 140 | log.Warnw("error closing HTTP body: %v\n", err) 141 | } 142 | }() 143 | 144 | body, err := io.ReadAll(resp.Body) 145 | if err != nil { 146 | return nil, err 147 | } 148 | if resp.StatusCode != http.StatusOK { 149 | return nil, fmt.Errorf("failed to get OAuth token: %s", body) 150 | } 151 | 152 | var token OAuthToken 153 | if err := json.Unmarshal(body, &token); err != nil { 154 | log.Warnf("failed to unmarshal OAuth token: %s", body) 155 | return nil, err 156 | } 157 | 158 | return &token, nil 159 | } 160 | 161 | // GetOAuthProfile obtains the OAuth profile for the provider using the OAuth token. 162 | func (p *Provider) GetOAuthProfile(token *OAuthToken) ([]byte, error) { 163 | req, err := http.NewRequest("GET", p.ProfileURL, nil) 164 | if err != nil { 165 | return nil, err 166 | } 167 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) 168 | req.Header.Set("Accept", "application/json") 169 | 170 | client := &http.Client{} 171 | resp, err := client.Do(req) 172 | if err != nil { 173 | return nil, err 174 | } 175 | defer func() { 176 | if err := resp.Body.Close(); err != nil { 177 | log.Warnw("error closing HTTP body", "err", err) 178 | } 179 | }() 180 | 181 | body, err := io.ReadAll(resp.Body) 182 | if err != nil { 183 | return nil, err 184 | } 185 | if resp.StatusCode != http.StatusOK { 186 | return nil, fmt.Errorf("failed to get OAuth profile: %s", body) 187 | } 188 | 189 | return body, nil 190 | } 191 | -------------------------------------------------------------------------------- /handlers/rsahandler/rsa.go: -------------------------------------------------------------------------------- 1 | package rsahandler 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/rsa" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "encoding/hex" 10 | "encoding/pem" 11 | "fmt" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "sync" 16 | 17 | "github.com/vocdoni/blind-csp/types" 18 | "go.vocdoni.io/dvote/db" 19 | "go.vocdoni.io/dvote/db/metadb" 20 | "go.vocdoni.io/dvote/httprouter" 21 | "go.vocdoni.io/dvote/log" 22 | ) 23 | 24 | const ( 25 | electionIDStrLength = 64 26 | voterIDStrLength = 64 27 | signedMessageBytesLength = 64 28 | ) 29 | 30 | // RsaHandler is a handler that allows only 1 registration for IP 31 | type RsaHandler struct { 32 | kv db.Database 33 | keysLock sync.RWMutex 34 | rsaPubKey *rsa.PublicKey 35 | } 36 | 37 | func (rh *RsaHandler) addKey(voterID, processID []byte) error { 38 | rh.keysLock.Lock() 39 | defer rh.keysLock.Unlock() 40 | tx := rh.kv.WriteTx() 41 | defer tx.Discard() 42 | var key bytes.Buffer 43 | _, err := key.Write(processID) 44 | if err != nil { 45 | return err 46 | } 47 | _, err = key.Write(voterID) 48 | if err != nil { 49 | return err 50 | } 51 | if err := tx.Set(key.Bytes(), nil); err != nil { 52 | return err 53 | } 54 | return tx.Commit() 55 | } 56 | 57 | func (rh *RsaHandler) exist(voterID, processID []byte) bool { 58 | rh.keysLock.RLock() 59 | defer rh.keysLock.RUnlock() 60 | var key bytes.Buffer 61 | _, err := key.Write(processID) 62 | if err != nil { 63 | return false 64 | } 65 | _, err = key.Write(voterID) 66 | if err != nil { 67 | return false 68 | } 69 | _, err = rh.kv.Get(key.Bytes()) 70 | return err == nil 71 | } 72 | 73 | // Name returns the name of the handler 74 | func (rh *RsaHandler) Name() string { 75 | return "rsa" 76 | } 77 | 78 | // Init initializes the handler. 79 | // Takes one argument for persistent data directory. 80 | func (rh *RsaHandler) Init(r *httprouter.HTTProuter, baseURL string, opts ...string) (err error) { 81 | if len(opts) != 2 { 82 | return fmt.Errorf("rsa handler requires a file path with the validation RSA key") 83 | } 84 | 85 | rh.kv, err = metadb.New(db.TypePebble, filepath.Clean(opts[0])) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | pubKeyBytes, err := os.ReadFile(opts[1]) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | rh.rsaPubKey, err = parseRsaPublicKey(string(pubKeyBytes)) 96 | return err 97 | } 98 | 99 | // Info returns the handler options and required auth steps. 100 | // TODO: needs to be adapted! 101 | func (rh *RsaHandler) Info() *types.Message { 102 | return &types.Message{ 103 | Title: "RSA signature", 104 | AuthType: "auth", 105 | SignType: types.AllSignatures, 106 | AuthSteps: []*types.AuthField{ 107 | {Title: "Election ID", Type: "hex32"}, 108 | {Title: "Voter ID", Type: "hex32"}, 109 | {Title: "Signature", Type: "text"}, 110 | }, 111 | } 112 | } 113 | 114 | // Indexer takes a unique user identifier and returns the list of processIDs where 115 | // the user is elegible for participation. This is a helper function that might not 116 | // be implemented (depends on the handler use case). 117 | func (ih *RsaHandler) Indexer(userID types.HexBytes) []types.Election { 118 | return nil 119 | } 120 | 121 | // Auth is the handler for the rsa handler 122 | func (rh *RsaHandler) Auth(r *http.Request, 123 | ca *types.Message, pid types.HexBytes, st string, step int, 124 | ) types.AuthResponse { 125 | authData, err := parseRsaAuthData(ca.AuthData) 126 | if err != nil { 127 | log.Warn(err) 128 | return types.AuthResponse{} 129 | } 130 | if !bytes.Equal(pid, authData.ProcessId) { 131 | return types.AuthResponse{ 132 | Response: []string{"the provided electionId does not match the URL one"}, 133 | } 134 | } 135 | 136 | // Verify signature 137 | if err := validateRsaSignature(authData.Signature, authData.Message, rh.rsaPubKey); err != nil { 138 | return types.AuthResponse{Response: []string{"invalid signature"}} 139 | } 140 | 141 | if st == types.SignatureTypeSharedKey { 142 | return types.AuthResponse{Response: []string{"please, do not share the key"}} 143 | } 144 | 145 | if rh.exist(authData.VoterId, authData.ProcessId) { 146 | return types.AuthResponse{Response: []string{"already registered"}} 147 | } 148 | 149 | err = rh.addKey(authData.VoterId, authData.ProcessId) 150 | if err != nil { 151 | return types.AuthResponse{Response: []string{"could not add key"}} 152 | } 153 | log.Infof("new user registered with id %x", authData.VoterId) 154 | 155 | return types.AuthResponse{Success: true} 156 | } 157 | 158 | // RequireCertificate must return true if the auth handler requires some kind of client 159 | // TLS certificate. If true then CertificateCheck() and HardcodedCertificate() methods 160 | // must be correctly implemented. Else both function can just return true and nil. 161 | func (rh *RsaHandler) RequireCertificate() bool { 162 | return false 163 | } 164 | 165 | // CertificateCheck is used by the Auth handler to ensure a specific certificate is 166 | // added to the CA cert pool on the HTTP/TLS layer (optional). 167 | func (rh *RsaHandler) CertificateCheck(subject []byte) bool { 168 | return true 169 | } 170 | 171 | // Certificates returns a hardcoded CA certificated that will be added to the 172 | // CA cert pool by the handler (optional). 173 | func (rh *RsaHandler) Certificates() [][]byte { 174 | return nil 175 | } 176 | 177 | // Internal data handlers 178 | 179 | func parseRsaPublicKey(pubKey string) (*rsa.PublicKey, error) { 180 | block, rest := pem.Decode([]byte(pubKey)) 181 | if len(rest) > 0 { 182 | return nil, fmt.Errorf("failed to parse the public key") 183 | } 184 | 185 | parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes) 186 | if err != nil { 187 | return nil, err 188 | } 189 | 190 | switch parsedKey.(type) { 191 | case *rsa.PublicKey: 192 | break 193 | default: 194 | return nil, fmt.Errorf("cannot parse the public key") 195 | } 196 | return parsedKey.(*rsa.PublicKey), nil 197 | } 198 | 199 | type rsaAuthData struct { 200 | ProcessId []byte 201 | VoterId []byte 202 | Message []byte 203 | Signature []byte 204 | } 205 | 206 | // parseRsaAuthData transforms the incoming authData string array and returns a digested output 207 | // of the relevant parameters for the handler 208 | func parseRsaAuthData(authData []string) (*rsaAuthData, error) { 209 | if len(authData) != 3 { 210 | return nil, fmt.Errorf("invalid params (3 items expected)") 211 | } 212 | 213 | // Catenate hex 214 | processId := authData[0] 215 | if len(authData[0]) != electionIDStrLength { 216 | return nil, fmt.Errorf("invalid electionId") 217 | } 218 | processIdBytes, err := hex.DecodeString(authData[0]) 219 | if err != nil { 220 | return nil, fmt.Errorf("cannot decode processId") 221 | } 222 | voterId := authData[1] 223 | if len(voterId) != voterIDStrLength { 224 | return nil, fmt.Errorf("invalid voterId") 225 | } 226 | voterIdBytes, err := hex.DecodeString(voterId) 227 | if err != nil || len(voterIdBytes) != voterIDStrLength/2 { 228 | return nil, fmt.Errorf("invalid voterId format: %w", err) 229 | } 230 | message, err := hex.DecodeString(processId + voterId) 231 | if err != nil || len(message) != signedMessageBytesLength { 232 | // By discard, only processId can be invalid 233 | return nil, fmt.Errorf("invalid electionId: %w", err) 234 | } 235 | signature, err := hex.DecodeString(authData[2]) 236 | if err != nil || len(signature) == 0 { 237 | return nil, fmt.Errorf("invalid signature format") 238 | } 239 | 240 | return &rsaAuthData{ 241 | ProcessId: processIdBytes, 242 | VoterId: voterIdBytes, 243 | Message: message, 244 | Signature: signature, 245 | }, nil 246 | } 247 | 248 | // validateRsaSignature hashes the given message and verifies the signature against 249 | // the given public key 250 | func validateRsaSignature(signature []byte, message []byte, rsaPublicKey *rsa.PublicKey) error { 251 | msgHash := sha256.Sum256(message) 252 | 253 | return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, msgHash[:], signature) 254 | } 255 | -------------------------------------------------------------------------------- /handlers/rsahandler/rsa_test.go: -------------------------------------------------------------------------------- 1 | package rsahandler 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "encoding/hex" 10 | "encoding/pem" 11 | "fmt" 12 | "os" 13 | "testing" 14 | 15 | qt "github.com/frankban/quicktest" 16 | "github.com/vocdoni/blind-csp/types" 17 | "go.vocdoni.io/dvote/util" 18 | ) 19 | 20 | const ( 21 | processId = "11898e5652ccadf0d2a84a1f462d9f29a123bdb21315e92c59c56b0bb1b7d422" 22 | voterId = "51bc804fdb2122c0a8b221bf5b3683395151f30ac6e86d014bb38854eff483de" 23 | signature = "9076e34e9e0cf2d4071829985dc525da186686af6084ec12105083d42601099a" + 24 | "b2cf44f4eeb3a1897d9fbf4254a6fe94b44e9dd267adfc7c3b2fee32af88caef" + 25 | "0630ca5852043d4914d3b66aaaddab6b381338d058ba727a2e819e9c09318483" + 26 | "088d11d8fa3ce5d6e0c333add6866926b7fbcdc1b1c9754c22db85b896bb5c21" + 27 | "5fd8461ec34204a3524c655548c0b46a7a7178dbae8c6b8c84570459ed439e25" + 28 | "ecfcf2fe22f9237e8f9f90d55e65a179e5f5a6749b0874182a37015e08bd2376" + 29 | "35ca586231370b46e53dc0d1730f8fa9a08bf428ab9b8a083d3035c86727b648" + 30 | "7e5796b994977a3d5e1692ab45dd0068bc71e9446ae897f1fbe3ab9c95081c81" 31 | rsaPubKey = `-----BEGIN PUBLIC KEY----- 32 | MIIBIDANBgkqhkiG9w0BAQEFAAOCAQ0AMIIBCAKCAQEApc2hU8zulyJzdQE5IPAv 33 | B2BgveoZmYUmPEjSb4DViBoATK1hlaY8Psp5vj0H0L4tM8AlXRhPQlECibhgccig 34 | xQFcG7CLXiSAn7c4XoR+J2SCgx76Fwl9L3WhQigxyKsmpGIqubseydmwfJi4TBnq 35 | qnX4prsW1PT8GpG35t8Qi8PtkXVGmL7G5pkPXtF0hRzKSfhzsDBbJsl6Jk/Rn5Id 36 | pKHXL22FdbE9fGzIlW2a6Zdd0b0Q3FZBMnWLSwo0OwBtC/qNnDTCzboig9djiFmA 37 | yuj8jVhsy050nI72TAONjGKi+xn4lYfdOV2k6TyvpRHfylHouK2v0/bktSlkFI0y 38 | nwIBAw== 39 | -----END PUBLIC KEY-----` 40 | ) 41 | 42 | func TestPublicKey(t *testing.T) { 43 | pubK, err := parseRsaPublicKey(rsaPubKey) 44 | qt.Assert(t, err, qt.IsNil) 45 | qt.Assert(t, pubK, qt.IsNotNil) 46 | } 47 | 48 | func TestAuthDataParserErr(t *testing.T) { 49 | inputVectors := [][]string{ 50 | {"", "", ""}, 51 | {"", ""}, 52 | {""}, 53 | {"___66cdf1cac93ded9c8d13b7cc74601c7f25bf50d392549e1146eaa8429ab01", voterId, signature}, 54 | {processId, "___66cdf1cac93ded9c8d13b7cc74601c7f25bf50d392549e1146eaa8429ab01", signature}, 55 | {processId, voterId, "___66cdf1cac93ded9c8d13b7cc74601c7f25bf50d392549e1146eaa8429ab01"}, 56 | {"1234", "1234", "1234"}, 57 | } 58 | 59 | for _, input := range inputVectors { 60 | res, err := parseRsaAuthData(input) 61 | qt.Assert(t, res, qt.IsNil) 62 | qt.Assert(t, err, qt.IsNotNil) 63 | } 64 | } 65 | 66 | func TestAuthDataParser(t *testing.T) { 67 | res, err := parseRsaAuthData([]string{ 68 | processId, 69 | voterId, 70 | signature, 71 | }) 72 | 73 | qt.Assert(t, res.VoterId, qt.IsNotNil) 74 | qt.Assert(t, res.Message, qt.IsNotNil) 75 | qt.Assert(t, res.Signature, qt.IsNotNil) 76 | qt.Assert(t, err, qt.IsNil) 77 | 78 | aa := hex.EncodeToString(res.VoterId) 79 | qt.Assert(t, aa, qt.Equals, voterId) 80 | 81 | bb := hex.EncodeToString(res.Message) 82 | qt.Assert(t, bb, qt.Equals, processId+voterId) 83 | 84 | cc := hex.EncodeToString(res.Signature) 85 | qt.Assert(t, cc, qt.Equals, signature) 86 | } 87 | 88 | func TestSignature1(t *testing.T) { 89 | // Raw inputs 90 | res, err := parseRsaAuthData([]string{ 91 | processId, 92 | voterId, 93 | signature, 94 | }) 95 | qt.Assert(t, err, qt.IsNil) 96 | 97 | pubK, _ := parseRsaPublicKey(rsaPubKey) 98 | err = validateRsaSignature(res.Signature, res.Message, pubK) 99 | 100 | qt.Assert(t, err, qt.IsNil) 101 | 102 | // Digested message 103 | msg, _ := hex.DecodeString("11898e5652ccadf0d2a84a1f462d9f29a123bdb21315e92c59c56b0bb1b7d422" + 104 | "51bc804fdb2122c0a8b221bf5b3683395151f30ac6e86d014bb38854eff483de") 105 | sig, _ := hex.DecodeString(signature) 106 | err = validateRsaSignature(sig, msg, pubK) 107 | 108 | qt.Assert(t, err, qt.IsNil) 109 | } 110 | 111 | func TestSignature2(t *testing.T) { 112 | // Manual inputs verified on cyberchef 113 | message := []byte("hello") 114 | sig, err := hex.DecodeString( 115 | "6ff990e9522a18c5ec75576ac2c3477ae2f85c3e51ac659b37fd87ea1e35955c" + 116 | "5b5a72af46dc80224968001369c5c022ae3ae304bef4e6ba992685881b5290c6" + 117 | "84cd25cc2c694215abf79609d049260fe2bf01e54be0e7ceecbe5e31be95fc86" + 118 | "78877d634f3577bde74d09074348fa6aad90a8449defb12fda3136b8eeb58148", 119 | ) 120 | qt.Assert(t, err, qt.IsNil) 121 | pubKstr := `-----BEGIN PUBLIC KEY----- 122 | MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHBhoIO6WsbQR6Dr+fyzwdUfrqz4 123 | G1s4fKvcQR1NqfvGchXHTZZply7P+1NZnO4UX8z7T9VoMRSoS7lM8jdIeOjoyZuk 124 | 0WmNHZXGFeDNhoWtX/IZwy7z/e4qUD+rt1xVU3jjJqkQBSyar1FB+x9tG2qMGPhC 125 | 4cKjDWyJtRlopwbtAgMBAAE= 126 | -----END PUBLIC KEY-----` 127 | 128 | // Using the first one available so far 129 | block, _ := pem.Decode([]byte(pubKstr)) 130 | 131 | parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes) 132 | qt.Assert(t, err, qt.IsNil) 133 | 134 | qt.Assert(t, 135 | validateRsaSignature(sig, message, parsedKey.(*rsa.PublicKey)), 136 | qt.IsNil, 137 | ) 138 | } 139 | 140 | func TestSignature3(t *testing.T) { 141 | // Manual inputs verified with openssl 142 | message, _ := hex.DecodeString("88e66cdf1cac93ded9c8d13b7cc74601c7f25bf50d392549e1146eaa8429ab01" + 143 | "51bc804fdb2122c0a8b221bf5b3683395151f30ac6e86d014bb38854eff483de") 144 | sig, err := hex.DecodeString("538cd5175e9b03f01dcb7ec725202a268fbbb60355b570c61938e46a5c6de882" + 145 | "0d3a567402e785cdc70251c98c2671d9c02a90cafd8b510e2241f978d3ee4c07" + 146 | "dc1b67c7fd2313baf1e50a2655ae6c88aa61a4e31243854f8519abfb7c70c33b" + 147 | "a0048a34660a8e93d37449b5ed93ef61291ff797e250409ba53119bc4e731f17", 148 | ) 149 | qt.Assert(t, err, qt.IsNil) 150 | pubKstr := `-----BEGIN PUBLIC KEY----- 151 | MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHBhoIO6WsbQR6Dr+fyzwdUfrqz4 152 | G1s4fKvcQR1NqfvGchXHTZZply7P+1NZnO4UX8z7T9VoMRSoS7lM8jdIeOjoyZuk 153 | 0WmNHZXGFeDNhoWtX/IZwy7z/e4qUD+rt1xVU3jjJqkQBSyar1FB+x9tG2qMGPhC 154 | 4cKjDWyJtRlopwbtAgMBAAE= 155 | -----END PUBLIC KEY-----` 156 | 157 | // Using the first one available so far 158 | block, _ := pem.Decode([]byte(pubKstr)) 159 | 160 | parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes) 161 | qt.Assert(t, err, qt.IsNil) 162 | 163 | qt.Assert(t, 164 | validateRsaSignature(sig, message, parsedKey.(*rsa.PublicKey)), 165 | qt.IsNil, 166 | ) 167 | } 168 | 169 | func TestAuth(t *testing.T) { 170 | handler := RsaHandler{} 171 | 172 | rsaPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) 173 | qt.Assert(t, err, qt.IsNil) 174 | 175 | rsaEncodedKey, err := x509.MarshalPKIXPublicKey(rsaPrivKey.Public()) 176 | qt.Assert(t, err, qt.IsNil) 177 | rsaPemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: rsaEncodedKey}) 178 | qt.Assert(t, rsaPemKey, qt.IsNotNil) 179 | 180 | // Create a temporary file fo rstoring the RSA pubKey 181 | keyFile, err := os.CreateTemp("", "") 182 | qt.Assert(t, err, qt.IsNil) 183 | keyFilePath := keyFile.Name() 184 | _, err = keyFile.Write(rsaPemKey) 185 | qt.Assert(t, err, qt.IsNil) 186 | err = keyFile.Close() 187 | qt.Assert(t, err, qt.IsNil) 188 | 189 | defer func() { 190 | if err := os.Remove(keyFilePath); err != nil { 191 | t.Log(err) 192 | } 193 | }() 194 | 195 | // Init the handler 196 | err = handler.Init(nil, "", t.TempDir(), keyFilePath) 197 | qt.Assert(t, err, qt.IsNil) 198 | 199 | // Build a valid message with the RSA signature 200 | processID := util.RandomBytes(32) 201 | voterID := util.RandomBytes(32) 202 | message, err := hex.DecodeString(fmt.Sprintf("%x%x", processID, voterID)) 203 | msgHash := sha256.Sum256(message) 204 | qt.Assert(t, err, qt.IsNil) 205 | 206 | signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivKey, crypto.SHA256, msgHash[:]) 207 | qt.Assert(t, err, qt.IsNil) 208 | 209 | msg := types.Message{ 210 | AuthData: []string{ 211 | fmt.Sprintf("%x", processID), 212 | fmt.Sprintf("%x", voterID), 213 | fmt.Sprintf("%x", signature), 214 | }, 215 | } 216 | 217 | r := handler.Auth(nil, &msg, processID, types.SignatureTypeBlind, 0) 218 | qt.Assert(t, r.Success, qt.IsTrue) 219 | 220 | // Try again (double spend) 221 | r = handler.Auth(nil, &msg, processID, types.SignatureTypeBlind, 0) 222 | qt.Assert(t, r.Success, qt.IsFalse) 223 | 224 | // Build an invalid message 225 | msg.AuthData[1] = fmt.Sprintf("%x", util.RandomBytes(32)) 226 | r = handler.Auth(nil, &msg, processID, types.SignatureTypeBlind, 0) 227 | qt.Assert(t, r.Success, qt.IsFalse) 228 | } 229 | -------------------------------------------------------------------------------- /handlers/simpleMath.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "math/rand" 7 | "net/http" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/google/uuid" 15 | "github.com/vocdoni/blind-csp/types" 16 | "go.vocdoni.io/dvote/db" 17 | "go.vocdoni.io/dvote/db/metadb" 18 | "go.vocdoni.io/dvote/httprouter" 19 | "go.vocdoni.io/dvote/log" 20 | ) 21 | 22 | // SimpleMathHandler is a handler that requires a simple math operation to be resolved. 23 | type SimpleMathHandler struct { 24 | kv db.Database 25 | keysLock sync.RWMutex 26 | mathRandom *rand.Rand 27 | } 28 | 29 | func (ih *SimpleMathHandler) addToken(token string, solution int) { 30 | ih.keysLock.Lock() 31 | defer ih.keysLock.Unlock() 32 | tx := ih.kv.WriteTx() 33 | defer tx.Discard() 34 | n := make([]byte, 32) 35 | binary.BigEndian.PutUint32(n, uint32(solution)) 36 | if err := tx.Set([]byte(token), n); err != nil { 37 | log.Error(err) 38 | } 39 | if err := tx.Commit(); err != nil { 40 | log.Error(err) 41 | } 42 | } 43 | 44 | func (ih *SimpleMathHandler) getToken(token string) (int, error) { 45 | ih.keysLock.RLock() 46 | defer ih.keysLock.RUnlock() 47 | tx := ih.kv.WriteTx() 48 | defer tx.Discard() 49 | v, err := tx.Get([]byte(token)) 50 | if err != nil { 51 | return 0, err 52 | } 53 | return int(binary.BigEndian.Uint32(v)), nil 54 | } 55 | 56 | func (ih *SimpleMathHandler) delToken(token string) { 57 | ih.keysLock.RLock() 58 | defer ih.keysLock.RUnlock() 59 | tx := ih.kv.WriteTx() 60 | defer tx.Discard() 61 | if err := tx.Delete([]byte(token)); err != nil { 62 | log.Warn(err) 63 | } 64 | if err := tx.Commit(); err != nil { 65 | log.Error(err) 66 | } 67 | } 68 | 69 | // GetName returns the name of the handler 70 | func (ih *SimpleMathHandler) Name() string { 71 | return "simpleMath" 72 | } 73 | 74 | // Init initializes the handler. 75 | // Takes one argument for persistent data directory. 76 | func (ih *SimpleMathHandler) Init(r *httprouter.HTTProuter, baseURL string, opts ...string) (err error) { 77 | ih.kv, err = metadb.New(db.TypePebble, filepath.Clean(opts[0])) 78 | ih.mathRandom = rand.New(rand.NewSource(time.Now().UnixNano())) 79 | return err 80 | } 81 | 82 | // Info returns the handler options and information. 83 | func (ih *SimpleMathHandler) Info() *types.Message { 84 | return &types.Message{ 85 | Title: "Simple math challenge", 86 | AuthType: "auth", 87 | SignType: types.AllSignatures, 88 | AuthSteps: []*types.AuthField{ 89 | {Title: "Name", Type: "text"}, 90 | {Title: "Solution", Type: "int4"}, 91 | }, 92 | } 93 | } 94 | 95 | // Indexer takes a unique user identifier and returns the list of processIDs where 96 | // the user is elegible for participation. This is a helper function that might not 97 | // be implemented (depends on the handler use case). 98 | func (ih *SimpleMathHandler) Indexer(userID types.HexBytes) []types.Election { 99 | return nil 100 | } 101 | 102 | // Redirect handler takes a client identifier and returns 103 | func (ih *SimpleMathHandler) Redirect(clientID []byte) ([][]byte, error) { 104 | return nil, nil 105 | } 106 | 107 | // Auth is the handler method for managing the simple math authentication challenge. 108 | func (ih *SimpleMathHandler) Auth(r *http.Request, 109 | c *types.Message, pid types.HexBytes, signType string, step int, 110 | ) types.AuthResponse { 111 | switch step { 112 | case 0: 113 | // If first step, build new challenge 114 | if len(c.AuthData) != 1 { 115 | return types.AuthResponse{Response: []string{"incorrect auth data fields"}} 116 | } 117 | name := c.AuthData[0] 118 | token := uuid.New() 119 | r1 := ih.mathRandom.Intn(400) + 100 120 | r2 := ih.mathRandom.Intn(400) + 100 121 | ih.addToken(token.String(), r1+r2) 122 | ipaddr := strings.Split(r.RemoteAddr, ":")[0] 123 | log.Infof("user %s from %s challenged with math question %d + %d", name, ipaddr, r1, r2) 124 | return types.AuthResponse{ 125 | Success: true, 126 | Response: []string{fmt.Sprintf("%d", r1), fmt.Sprintf("%d", r2)}, 127 | AuthToken: &token, 128 | } 129 | 130 | case 1: 131 | // If second step, check for solution 132 | if c.AuthToken == nil || len(c.AuthData) != 1 { 133 | return types.AuthResponse{Response: []string{"auth token not provided or missing auth data"}} 134 | } 135 | solution, err := ih.getToken(c.AuthToken.String()) 136 | if err != nil { 137 | return types.AuthResponse{Response: []string{"auth token not found"}} 138 | } 139 | userSolution, err := strconv.Atoi(c.AuthData[0]) 140 | if err != nil { 141 | return types.AuthResponse{Response: []string{"invalid solution format"}} 142 | } 143 | if solution != userSolution { 144 | return types.AuthResponse{Response: []string{"invalid math challenge solution"}} 145 | } 146 | ih.delToken(c.AuthToken.String()) 147 | log.Infof("new user registered, challenge resolved %s", c.AuthData[0]) 148 | return types.AuthResponse{ 149 | Response: []string{"challenge resolved!"}, 150 | Success: true, 151 | } 152 | } 153 | 154 | return types.AuthResponse{Response: []string{"invalid auth step"}} 155 | } 156 | 157 | // RequireCertificate must return true if the auth handler requires some kind of client 158 | // TLS certificate. If true then CertificateCheck() and HardcodedCertificate() methods 159 | // must be correctly implemented. Else both function can just return true and nil. 160 | func (ih *SimpleMathHandler) RequireCertificate() bool { 161 | return false 162 | } 163 | 164 | // CertificateCheck is used by the Auth handler to ensure a specific certificate is 165 | // added to the CA cert pool on the HTTP/TLS layer (optional). 166 | func (ih *SimpleMathHandler) CertificateCheck(subject []byte) bool { 167 | return true 168 | } 169 | 170 | // Certificates returns a hardcoded CA certificated that will be added to the 171 | // CA cert pool by the handler (optional). 172 | func (ih *SimpleMathHandler) Certificates() [][]byte { 173 | return nil 174 | } 175 | -------------------------------------------------------------------------------- /handlers/smshandler/adminapi/README.md: -------------------------------------------------------------------------------- 1 | # SMS handler admin API for mongoDB 2 | 3 | This is an admin tool for managing the smshandler users database. 4 | 5 | ## Run the API backend 6 | 7 | `CSP_MONGODB_URL="" CSP_DATABASE=users go run . --logLevel=debug --port=5001` 8 | 9 | ## API methods 10 | 11 | The API requires a bearer authentication token, if not provided by the user the token is autogenerated. 12 | The following examples with `curl` include an implicit header flag such as: 13 | 14 | `curl -H "Authorization: Bearer 63d97da8-86e7-4313-92e7-2d8ae99e6c6e" ` 15 | 16 | 17 | ### 1. Database dump 18 | Dump all users and elections (JSON). 19 | Note that this method can be quite heavy and reach HTTP body size limit if the database is too big. 20 | Only suitable for debug purposes. 21 | 22 | - Request 23 | ```bash 24 | curl http://127.0.0.1:5001/smsapi/dump 25 | ``` 26 | - Response OK 27 | ```json 28 | { 29 | "userID": "6d2347cf59313bdb4038f0c6643e9289d694c1c67d4d1d66f56968e374d48669", 30 | "elections": [ 31 | { 32 | "electionId": "1111111111111111111111111111111111111111111111111111111111111111", 33 | "remainingAttempts": 5, 34 | "consumed": false 35 | }, 36 | { 37 | "electionId": "2222222222222222222222222222222222222222222222222222222222222222", 38 | "remainingAttempts": 5, 39 | "consumed": false 40 | }, 41 | { 42 | "electionId": "3333333333333333333333333333333333333333333333333333333333333333", 43 | "remainingAttempts": 5, 44 | "consumed": false 45 | } 46 | ], 47 | "extraData": "Vocdoni", 48 | "phone": { 49 | "country_code": 34, 50 | "national_number": 651200042 51 | } 52 | } 53 | ``` 54 | - Response Error 55 | ```json 56 | { 57 | "error": "auth token not valid" 58 | } 59 | ``` 60 | 61 | ### 2. List users 62 | List all users identifiers (userID). 63 | 64 | - Request 65 | ```bash 66 | curl http://127.0.0.1:5001/smsapi/users 67 | ``` 68 | - Response OK 69 | ```json 70 | { 71 | "users": [ 72 | "6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac", 73 | "bf5b6a9c69a5abee870b3667e92c589ef9c13458be0fc0493b2ba5a9658c690b", 74 | "87b1161ad7a5290dd1d2b4b8ded948950d7420551648000887fb2529be58a39e" 75 | ] 76 | } 77 | ``` 78 | - Response Error 79 | ```json 80 | { 81 | "error": "auth token not valid" 82 | } 83 | ``` 84 | 85 | ### 3. Get user data 86 | Retrieve the user data given a userID. 87 | 88 | - Request 89 | ```bash 90 | curl http://127.0.0.1:5001/smsapi/user/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac 91 | ``` 92 | - Response OK 93 | ```json 94 | { 95 | "userID": "6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac", 96 | "elections": [ 97 | { 98 | "electionId": "1111111111111111111111111111111111111111111111111111111111111111", 99 | "remainingAttempts": 5, 100 | "consumed": false 101 | }, 102 | { 103 | "electionId": "2222222222222222222222222222222222222222222222222222222222222222", 104 | "remainingAttempts": 3, 105 | "consumed": false 106 | }, 107 | { 108 | "electionId": "3333333333333333333333333333333333333333333333333333333333333333", 109 | "remainingAttempts": 5, 110 | "consumed": true 111 | } 112 | ], 113 | "extraData": "John 16/05/1984", 114 | "phone": { 115 | "country_code": 34, 116 | "national_number": 66778899 117 | } 118 | } 119 | ``` 120 | - Response Error 121 | ```json 122 | { 123 | "error": "user is unknown" 124 | } 125 | ``` 126 | 127 | ### 4. Add SMS attempt 128 | Increase by 1 the number of remaning SMS authentication attempts for a given userID and a given electionID. 129 | 130 | - Request 131 | ```bash 132 | # .../addAttempt// 133 | curl http://127.0.0.1:5001/smsapi/addAttempt/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac/2222222222222222222222222222222222222222222222222222222222222222 134 | ``` 135 | - Response OK 136 | ```json 137 | { 138 | "ok":"true" 139 | } 140 | ``` 141 | - Response Error 142 | ```json 143 | { 144 | "error": "user does not belong to election" 145 | } 146 | ``` 147 | 148 | ### 5. Set consumed 149 | The consumed bool indicates if a user represented by its userID has already fetched a CSP proof for a given electionId. 150 | 151 | - Request 152 | ```bash 153 | curl http://127.0.0.1:5001/smsapi/setConsumed/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac/2222222222222222222222222222222222222222222222222222222222222222/true 154 | ``` 155 | - Response OK 156 | ```json 157 | { 158 | "ok": "true" 159 | } 160 | ``` 161 | - Response Error 162 | ```json 163 | { 164 | "error": 165 | } 166 | ``` 167 | 168 | After the two previous operations, the user looks like this: 169 | 170 | ```bash 171 | curl http://127.0.0.1:5001/smsapi/user/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac 172 | ``` 173 | ```json 174 | { 175 | "userID": "6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac", 176 | "elections": [ 177 | { 178 | "electionId": "1111111111111111111111111111111111111111111111111111111111111111", 179 | "remainingAttempts": 5, 180 | "consumed": false 181 | }, 182 | { 183 | "electionId": "2222222222222222222222222222222222222222222222222222222222222222", 184 | "remainingAttempts": 6, 185 | "consumed": true 186 | }, 187 | { 188 | "electionId": "3333333333333333333333333333333333333333333333333333333333333333", 189 | "remainingAttempts": 5, 190 | "consumed": false 191 | } 192 | ], 193 | "extraData": "John 16/05/1984", 194 | "phone": { 195 | "country_code": 34, 196 | "national_number": 66778899 197 | } 198 | } 199 | ``` 200 | 201 | ### 6. Clone user 202 | Make a copy of a user with a new userID. The list of elections is preserved but not its internal status (consumed or remainingAttempts). 203 | The first URL parameter is the source userID and the second the new userID. 204 | 205 | - Request 206 | ```bash 207 | curl http://127.0.0.1:5001/smsapi/cloneUser/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac/b3deea91021d8c3dd6b52b2b1ba5defbe4b0b2fe03bc4ad6944148effb3e1222 208 | ``` 209 | - Response OK 210 | ```json 211 | { 212 | "ok": "true" 213 | } 214 | ``` 215 | - Response Error 216 | ```json 217 | { 218 | "error": "user already exists" 219 | } 220 | ``` 221 | 222 | The status of the user after the previous command is: 223 | 224 | ```bash 225 | curl http://127.0.0.1:5001/smsapi/user/b3deea91021d8c3dd6b52b2b1ba5defbe4b0b2fe03bc4ad6944148effb3e1222 226 | ``` 227 | ```json 228 | { 229 | "userID": "b3deea91021d8c3dd6b52b2b1ba5defbe4b0b2fe03bc4ad6944148effb3e1222", 230 | "elections": [ 231 | { 232 | "electionId": "1111111111111111111111111111111111111111111111111111111111111111", 233 | "remainingAttempts": 5, 234 | "consumed": false 235 | }, 236 | { 237 | "electionId": "2222222222222222222222222222222222222222222222222222222222222222", 238 | "remainingAttempts": 5, 239 | "consumed": false 240 | }, 241 | { 242 | "electionId": "3333333333333333333333333333333333333333333333333333333333333333", 243 | "remainingAttempts": 5, 244 | "consumed": false 245 | } 246 | ], 247 | "extraData": "John 16/05/1984", 248 | "phone": { 249 | "country_code": 34, 250 | "national_number": 66778899 251 | } 252 | } 253 | ``` 254 | 255 | ### 7. Add a new user 256 | Creates a new user with a `phone` and an `extra` field containing arbitrary data. 257 | 258 | - Request 259 | ```bash 260 | curl http://127.0.0.1:5001/smsapi/newUser/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4 -d '{"phone":"+34700605040","extra":"Alice 02/04/1991"}' -X POST 261 | ``` 262 | - Response OK 263 | ```json 264 | { 265 | "ok": "true" 266 | } 267 | ``` 268 | - Response Error 269 | ```json 270 | { 271 | "error": "user already exists" 272 | } 273 | ``` 274 | Then we can query the new user: 275 | ```bash 276 | curl http://127.0.0.1:5001/smsapi/user/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4` 277 | ``` 278 | ```json 279 | { 280 | "userID": "ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4", 281 | "extraData": "Alice 02/04/1991", 282 | "phone": { 283 | "country_code": 34, 284 | "national_number": 700605040 285 | } 286 | } 287 | ``` 288 | 289 | ### 8. Add and delete elections 290 | Adds a new election for a given user. 291 | 292 | - Request 293 | ```bash 294 | curl http://127.0.0.1:5001/smsapi/addElection/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4/3333333333333333333333333333333333333333333333333333333333333333 295 | ``` 296 | - Response OK 297 | ```json 298 | { 299 | "ok": "true" 300 | } 301 | ``` 302 | - Response Error 303 | ```json 304 | { 305 | "error": "user is unknown" 306 | } 307 | ``` 308 | 309 | Now the new user has the previous election configured: 310 | 311 | ```bash 312 | curl http://127.0.0.1:5001/smsapi/user/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4 313 | ``` 314 | ```json 315 | { 316 | "userID": "ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4", 317 | "elections": [ 318 | { 319 | "electionId": "3333333333333333333333333333333333333333333333333333333333333333", 320 | "remainingAttempts": 5, 321 | "consumed": false 322 | } 323 | ], 324 | "extraData": "Alice 02/04/1991", 325 | "phone": { 326 | "country_code": 34, 327 | "national_number": 700605040 328 | } 329 | } 330 | ``` 331 | 332 | Delete an election for a user. 333 | 334 | - Request 335 | ```bash 336 | curl http://127.0.0.1:5001/smsapi/delElection/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4/3333333333333333333333333333333333333333333333333333333333333333 337 | ``` 338 | - Response OK 339 | ```json 340 | { 341 | "ok": "true" 342 | } 343 | ``` 344 | - Response Error 345 | ```json 346 | { 347 | "error": "error goes here" 348 | } 349 | ``` 350 | 351 | 352 | ### 9. Search term on extraData field 353 | 354 | The `extraData` field can store any arbitrary data regarding the user (full name, national ID, birth date, etc.). 355 | The search endpoint returns the list of userID for a specific term contained inside the extraData field. 356 | The search text function matches full words, such as "Alice" but not "Ali". 357 | 358 | ```bash 359 | curl http://127.0.0.1:5001/smsapi/search -d '{"term":"Alice"}' -X POST 360 | ``` 361 | ```json 362 | { 363 | "users": ["ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4"] 364 | } 365 | ``` 366 | 367 | ### 10. Delete a user 368 | A user can be deleted by its userID. 369 | 370 | ```bash 371 | curl http://127.0.0.1:5001/smsapi/delUser/ff29acb484cc721c102715295af1698ff90e90cb1b70f4d05aaa19674dbddce4 372 | ``` 373 | ` Response OK 374 | ```json 375 | { 376 | "ok": "true" 377 | } 378 | ``` 379 | - Response Error 380 | ```json 381 | { 382 | "error": "user is unknown" 383 | } 384 | ``` 385 | ### 11. Import 386 | Import can be used to insert (or update) the database using the output from a dump. 387 | 388 | ```bash 389 | curl http://127.0.0.1:5001/smsapi/dump > dump.json 390 | ``` 391 | 392 | ```bash 393 | curl http://127.0.0.1:5001/smsapi/import -d @dump.json 394 | ``` 395 | ` Response OK 396 | ```json 397 | { 398 | "ok": "true" 399 | } 400 | ``` 401 | - Response Error 402 | ```json 403 | { 404 | "error": "error goes here" 405 | } 406 | ``` 407 | 408 | ### 12. Set user data 409 | Modifies the existing user fields `phone` and `extra` by using the following POST call. 410 | 411 | 412 | - Request 413 | ```bash 414 | curl http://127.0.0.1:5001/smsapi/setUserData/6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac -d '{"phone":"+34722847182", "extra":"John Smith"}' 415 | ``` 416 | - Response OK 417 | ```json 418 | { 419 | "ok": "true" 420 | } 421 | ``` 422 | - Response Error 423 | ```json 424 | { 425 | "error": "error goes here" 426 | } 427 | ``` 428 | -------------------------------------------------------------------------------- /handlers/smshandler/adminapi/adminapi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vocdoni/blind-csp/fc8f087ae88da5d47847c288be56bf839d517dd4/handlers/smshandler/adminapi/adminapi -------------------------------------------------------------------------------- /handlers/smshandler/adminapi/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Use root/example as user/password credentials 2 | version: '3.1' 3 | 4 | services: 5 | 6 | mongo: 7 | image: mongo 8 | restart: always 9 | ports: 10 | - 27017:27017 11 | environment: 12 | MONGO_INITDB_ROOT_USERNAME: root 13 | MONGO_INITDB_ROOT_PASSWORD: vocdoni 14 | 15 | mongo-express: 16 | image: mongo-express 17 | restart: always 18 | ports: 19 | - 8081:8081 20 | environment: 21 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 22 | ME_CONFIG_MONGODB_ADMINPASSWORD: vocdoni 23 | ME_CONFIG_MONGODB_URL: mongodb://root:vocdoni@mongo:27017/ 24 | -------------------------------------------------------------------------------- /handlers/smshandler/challenge.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | messagebird "github.com/messagebird/go-rest-api/v7" 8 | mbsms "github.com/messagebird/go-rest-api/v7/sms" 9 | 10 | "github.com/nyaruka/phonenumbers" 11 | "github.com/twilio/twilio-go" 12 | openapi "github.com/twilio/twilio-go/rest/api/v2010" 13 | "go.vocdoni.io/dvote/log" 14 | ) 15 | 16 | type TwilioSMS struct { 17 | client *twilio.RestClient 18 | from string 19 | body string 20 | } 21 | 22 | func NewTwilioSMS() *TwilioSMS { 23 | accountSid := os.Getenv("SMS_PROVIDER_USERNAME") 24 | authToken := os.Getenv("SMS_PROVIDER_AUTHTOKEN") 25 | var tw TwilioSMS 26 | tw.from = os.Getenv("SMS_FROM") 27 | if tw.from == "" { 28 | tw.from = "vocdoni" 29 | } 30 | tw.body = os.Getenv("SMS_BODY") 31 | if tw.body == "" { 32 | tw.body = "Your authentication code is" 33 | } 34 | tw.client = twilio.NewRestClientWithParams(twilio.ClientParams{ 35 | Username: accountSid, 36 | Password: authToken, 37 | }) 38 | return &tw 39 | } 40 | 41 | func (tw *TwilioSMS) SendChallenge(phone *phonenumbers.PhoneNumber, challenge int) error { 42 | phoneStr := fmt.Sprintf("+%d%d", phone.GetCountryCode(), phone.GetNationalNumber()) 43 | log.Infof("sending challenge to %s", phoneStr) 44 | params := &openapi.CreateMessageParams{} 45 | params.SetTo(phoneStr) 46 | params.SetFrom(tw.from) 47 | params.SetBody(fmt.Sprintf("%s %d", tw.body, challenge)) 48 | _, err := tw.client.Api.CreateMessage(params) 49 | return err 50 | } 51 | 52 | type MessageBirdSMS struct { 53 | client *messagebird.Client 54 | from string 55 | body string 56 | } 57 | 58 | func NewMessageBirdSMS() *MessageBirdSMS { 59 | var sms MessageBirdSMS 60 | sms.from = os.Getenv("SMS_FROM") 61 | if sms.from == "" { 62 | sms.from = "vocdoni" 63 | } 64 | sms.body = os.Getenv("SMS_BODY") 65 | if sms.body == "" { 66 | sms.body = "Your authentication code is" 67 | } 68 | accessKey := os.Getenv("SMS_PROVIDER_AUTHTOKEN") 69 | sms.client = messagebird.New(accessKey) 70 | return &sms 71 | } 72 | 73 | func (sms *MessageBirdSMS) SendChallenge(phone *phonenumbers.PhoneNumber, challenge int) error { 74 | phoneStr := fmt.Sprintf("+%d%d", phone.GetCountryCode(), phone.GetNationalNumber()) 75 | body := fmt.Sprintf("%s %d", sms.body, challenge) 76 | log.Infof("sending challenge to %s", phoneStr) 77 | _, err := mbsms.Create(sms.client, sms.from, []string{phoneStr}, body, nil) 78 | 79 | return err 80 | } 81 | -------------------------------------------------------------------------------- /handlers/smshandler/challenge_test.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/nyaruka/phonenumbers" 8 | "go.vocdoni.io/dvote/log" 9 | ) 10 | 11 | type challengeMock struct { 12 | lock sync.RWMutex 13 | solutions map[string]int 14 | indexes map[string]int 15 | } 16 | 17 | func newChallengeMock() *challengeMock { 18 | return &challengeMock{ 19 | solutions: make(map[string]int), 20 | indexes: make(map[string]int), 21 | } 22 | } 23 | 24 | func (cm *challengeMock) sendChallenge(phone *phonenumbers.PhoneNumber, challenge int) error { 25 | cm.lock.Lock() 26 | defer cm.lock.Unlock() 27 | p := fmt.Sprintf("%d", phone.GetNationalNumber()) 28 | index, ok := cm.indexes[p] 29 | if !ok { 30 | index = 0 31 | } else { 32 | index++ 33 | } 34 | cm.solutions[challengeSolutionKey(phone, index)] = challenge 35 | cm.indexes[p] = index 36 | log.Debugf("challenge mock added %d/%d/%d", index, phone.GetNationalNumber(), challenge) 37 | return nil 38 | } 39 | 40 | func (cm *challengeMock) getSolution(phone *phonenumbers.PhoneNumber, index int) int { 41 | cm.lock.RLock() 42 | defer cm.lock.RUnlock() 43 | solution, ok := cm.solutions[challengeSolutionKey(phone, index)] 44 | if !ok { 45 | panic("no challenge solution for phone with index") 46 | } 47 | return solution 48 | } 49 | 50 | func challengeSolutionKey(phone *phonenumbers.PhoneNumber, index int) string { 51 | return fmt.Sprintf("%d_%d", phone.GetNationalNumber(), index) 52 | } 53 | -------------------------------------------------------------------------------- /handlers/smshandler/jsonstorage.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "github.com/nyaruka/phonenumbers" 13 | "github.com/vocdoni/blind-csp/types" 14 | "go.vocdoni.io/dvote/db" 15 | "go.vocdoni.io/dvote/db/metadb" 16 | "go.vocdoni.io/dvote/log" 17 | ) 18 | 19 | const ( 20 | userPrefix = "u_" 21 | authTokenIndexPrefix = "a_" 22 | ) 23 | 24 | // JSONstorage uses a local KV database (Pebble) for storing the smshandler user data. 25 | // JSON is used for data serialization. 26 | type JSONstorage struct { 27 | kv db.Database 28 | keysLock sync.RWMutex 29 | maxSmsAttempts int 30 | coolDownTime time.Duration 31 | } 32 | 33 | func (js *JSONstorage) Init(dataDir string, maxAttempts int, coolDownTime time.Duration) error { 34 | var err error 35 | js.kv, err = metadb.New(db.TypePebble, filepath.Clean(dataDir)) 36 | if err != nil { 37 | return err 38 | } 39 | js.maxSmsAttempts = maxAttempts 40 | js.coolDownTime = coolDownTime 41 | return nil 42 | } 43 | 44 | // Reset does nothing on this storage 45 | func (js *JSONstorage) Reset() error { 46 | return nil 47 | } 48 | 49 | func (js *JSONstorage) Users() (*Users, error) { 50 | var us Users 51 | if err := js.kv.Iterate(nil, func(key, value []byte) bool { 52 | us.Users = append(us.Users, key2userID(key)) 53 | return true 54 | }); err != nil { 55 | return nil, err 56 | } 57 | return &us, nil 58 | } 59 | 60 | func userIDkey(u types.HexBytes) []byte { 61 | return append([]byte(userPrefix), u...) 62 | } 63 | 64 | func key2userID(key []byte) (u types.HexBytes) { 65 | _ = u.FromString(fmt.Sprintf("%x", key[len(userPrefix):])) 66 | return u 67 | } 68 | 69 | func (js *JSONstorage) AddUser(userID types.HexBytes, processIDs []types.HexBytes, 70 | phone, extra string, 71 | ) error { 72 | phoneNum, err := phonenumbers.Parse(phone, DefaultPhoneCountry) 73 | if err != nil { 74 | return err 75 | } 76 | js.keysLock.Lock() 77 | defer js.keysLock.Unlock() 78 | tx := js.kv.WriteTx() 79 | defer tx.Discard() 80 | // maxAttempts := js.maxSmsAttempts * len(processIDs) 81 | // if maxAttempts == 0 { 82 | // // nolint[:gosimple] 83 | // maxAttempts = js.maxSmsAttempts 84 | // } 85 | user := UserData{ 86 | ExtraData: extra, 87 | Phone: phoneNum, 88 | } 89 | user.Elections = make(map[string]UserElection, len(processIDs)) 90 | for _, e := range HexBytesToElection(processIDs, js.maxSmsAttempts) { 91 | user.Elections[e.ElectionID.String()] = e 92 | } 93 | 94 | userData, err := json.Marshal(user) 95 | if err != nil { 96 | return err 97 | } 98 | if err := tx.Set(userIDkey(userID), userData); err != nil { 99 | return err 100 | } 101 | return tx.Commit() 102 | } 103 | 104 | func (js *JSONstorage) MaxAttempts() int { 105 | js.keysLock.RLock() 106 | defer js.keysLock.RUnlock() 107 | return js.maxSmsAttempts 108 | } 109 | 110 | func (js *JSONstorage) User(userID types.HexBytes) (*UserData, error) { 111 | js.keysLock.RLock() 112 | defer js.keysLock.RUnlock() 113 | userData, err := js.kv.Get(userIDkey(userID)) 114 | if err != nil { 115 | return nil, err 116 | } 117 | var user UserData 118 | if err := json.Unmarshal(userData, &user); err != nil { 119 | return nil, err 120 | } 121 | return &user, nil 122 | } 123 | 124 | func (js *JSONstorage) UpdateUser(udata *UserData) error { 125 | js.keysLock.RLock() 126 | defer js.keysLock.RUnlock() 127 | tx := js.kv.WriteTx() 128 | defer tx.Discard() 129 | if udata.UserID == nil { 130 | return ErrUserUnknown 131 | } 132 | userData, err := json.Marshal(udata) 133 | if err != nil { 134 | return err 135 | } 136 | if err := tx.Set(userIDkey(udata.UserID), userData); err != nil { 137 | return err 138 | } 139 | return tx.Commit() 140 | } 141 | 142 | func (js *JSONstorage) BelongsToElection(userID types.HexBytes, 143 | electionID types.HexBytes, 144 | ) (bool, error) { 145 | js.keysLock.RLock() 146 | defer js.keysLock.RUnlock() 147 | userData, err := js.kv.Get(userIDkey(userID)) 148 | if err != nil { 149 | return false, err 150 | } 151 | var user UserData 152 | if err := json.Unmarshal(userData, &user); err != nil { 153 | return false, err 154 | } 155 | _, ok := user.Elections[electionID.String()] 156 | return ok, nil 157 | } 158 | 159 | func (js *JSONstorage) SetAttempts(userID, electionID types.HexBytes, delta int) error { 160 | js.keysLock.Lock() 161 | defer js.keysLock.Unlock() 162 | tx := js.kv.WriteTx() 163 | defer tx.Discard() 164 | userData, err := tx.Get(userIDkey(userID)) 165 | if err != nil { 166 | return err 167 | } 168 | var user UserData 169 | if err := json.Unmarshal(userData, &user); err != nil { 170 | return err 171 | } 172 | election, ok := user.Elections[electionID.String()] 173 | if !ok { 174 | return ErrUserNotBelongsToElection 175 | } 176 | election.RemainingAttempts += delta 177 | user.Elections[electionID.String()] = election 178 | userData, err = json.Marshal(user) 179 | if err != nil { 180 | return err 181 | } 182 | if err := tx.Set(userIDkey(userID), userData); err != nil { 183 | return err 184 | } 185 | return tx.Commit() 186 | } 187 | 188 | func (js *JSONstorage) NewAttempt(userID, electionID types.HexBytes, 189 | challenge int, token *uuid.UUID, 190 | ) (*phonenumbers.PhoneNumber, error) { 191 | js.keysLock.Lock() 192 | defer js.keysLock.Unlock() 193 | tx := js.kv.WriteTx() 194 | defer tx.Discard() 195 | userData, err := tx.Get(userIDkey(userID)) 196 | if err != nil { 197 | return nil, err 198 | } 199 | var user UserData 200 | if err := json.Unmarshal(userData, &user); err != nil { 201 | return nil, err 202 | } 203 | election, ok := user.Elections[electionID.String()] 204 | if !ok { 205 | return nil, ErrUserNotBelongsToElection 206 | } 207 | if election.Consumed { 208 | return nil, ErrUserAlreadyVerified 209 | } 210 | if election.LastAttempt != nil { 211 | if time.Now().Before(election.LastAttempt.Add(js.coolDownTime)) { 212 | return nil, ErrAttemptCoolDownTime 213 | } 214 | } 215 | if election.RemainingAttempts < 1 { 216 | return nil, ErrTooManyAttempts 217 | } 218 | election.AuthToken = token 219 | election.Challenge = challenge 220 | t := time.Now() 221 | election.LastAttempt = &t 222 | user.Elections[electionID.String()] = election 223 | userData, err = json.Marshal(user) 224 | if err != nil { 225 | return nil, err 226 | } 227 | // Save the user data 228 | if err := tx.Set(userIDkey(userID), userData); err != nil { 229 | return nil, err 230 | } 231 | // Save the token as index for finding the userID 232 | if err := tx.Set([]byte(authTokenIndexPrefix+token.String()), userID); err != nil { 233 | return nil, err 234 | } 235 | 236 | return user.Phone, tx.Commit() 237 | } 238 | 239 | func (js *JSONstorage) Exists(userID types.HexBytes) bool { 240 | js.keysLock.RLock() 241 | defer js.keysLock.RUnlock() 242 | _, err := js.kv.Get(userIDkey(userID)) 243 | return err == nil 244 | } 245 | 246 | func (js *JSONstorage) Verified(userID, electionID types.HexBytes) (bool, error) { 247 | js.keysLock.RLock() 248 | defer js.keysLock.RUnlock() 249 | userData, err := js.kv.Get(userIDkey(userID)) 250 | if err != nil { 251 | return false, err 252 | } 253 | var user UserData 254 | if err := json.Unmarshal(userData, &user); err != nil { 255 | return false, err 256 | } 257 | election, ok := user.Elections[electionID.String()] 258 | if !ok { 259 | return false, ErrUserNotBelongsToElection 260 | } 261 | return election.Consumed, nil 262 | } 263 | 264 | func (js *JSONstorage) VerifyChallenge(electionID types.HexBytes, 265 | token *uuid.UUID, solution int, 266 | ) error { 267 | js.keysLock.Lock() 268 | defer js.keysLock.Unlock() 269 | tx := js.kv.WriteTx() 270 | defer tx.Discard() 271 | 272 | // fetch the user ID by token 273 | userID, err := tx.Get([]byte(authTokenIndexPrefix + token.String())) 274 | if err != nil { 275 | return ErrInvalidAuthToken 276 | } 277 | 278 | // with the user ID fetch the user data 279 | userData, err := tx.Get(userIDkey(userID)) 280 | if err != nil { 281 | return err 282 | } 283 | var user UserData 284 | if err := json.Unmarshal(userData, &user); err != nil { 285 | return err 286 | } 287 | 288 | // find the election and check the solution 289 | election, ok := user.Elections[electionID.String()] 290 | if !ok { 291 | return ErrUserNotBelongsToElection 292 | } 293 | if election.Consumed { 294 | return ErrUserAlreadyVerified 295 | } 296 | if election.AuthToken == nil { 297 | return fmt.Errorf("no auth token available for this election") 298 | } 299 | if election.AuthToken.String() != token.String() { 300 | return ErrInvalidAuthToken 301 | } 302 | 303 | // clean token data (we only allow 1 chance) 304 | election.AuthToken = nil 305 | if err := tx.Delete([]byte(authTokenIndexPrefix + token.String())); err != nil { 306 | return err 307 | } 308 | 309 | // set consumed to true or false depending on the challenge solution 310 | election.Consumed = election.Challenge == solution 311 | 312 | // save the user data 313 | user.Elections[electionID.String()] = election 314 | userData, err = json.Marshal(user) 315 | if err != nil { 316 | return err 317 | } 318 | if err := tx.Set(userIDkey(userID), userData); err != nil { 319 | return err 320 | } 321 | if err := tx.Commit(); err != nil { 322 | return err 323 | } 324 | 325 | // return error if the solution does not match the challenge 326 | if election.Challenge != solution { 327 | return ErrChallengeCodeFailure 328 | } 329 | return nil 330 | } 331 | 332 | func (js *JSONstorage) DelUser(userID types.HexBytes) error { 333 | js.keysLock.Lock() 334 | defer js.keysLock.Unlock() 335 | tx := js.kv.WriteTx() 336 | defer tx.Discard() 337 | if err := tx.Delete(userIDkey(userID)); err != nil { 338 | return err 339 | } 340 | return tx.Commit() 341 | } 342 | 343 | func (js *JSONstorage) Search(term string) (*Users, error) { 344 | var users Users 345 | if err := js.kv.Iterate(nil, func(key, value []byte) bool { 346 | if !strings.Contains(string(value), term) { 347 | return true 348 | } 349 | users.Users = append(users.Users, key2userID(key)) 350 | return true 351 | }); err != nil { 352 | return nil, err 353 | } 354 | 355 | return &users, nil 356 | } 357 | 358 | func (js *JSONstorage) String() string { 359 | js.keysLock.RLock() 360 | defer js.keysLock.RUnlock() 361 | output := make(map[string]UserData) 362 | if err := js.kv.Iterate(nil, func(key, value []byte) bool { 363 | var data UserData 364 | 365 | err := json.Unmarshal(value, &data) 366 | if err != nil { 367 | log.Warn(err) 368 | } 369 | // nolint[:ineffassign] 370 | output[key2userID(key).String()] = data 371 | return true 372 | }); err != nil { 373 | log.Warn(err) 374 | return "" 375 | } 376 | outputData, err := json.MarshalIndent(output, "", " ") 377 | if err != nil { 378 | log.Warn(err) 379 | return "" 380 | } 381 | return string(outputData) 382 | } 383 | 384 | // TODO 385 | func (js *JSONstorage) Import(data []byte) error { 386 | return nil 387 | } 388 | -------------------------------------------------------------------------------- /handlers/smshandler/queue.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/enriquebris/goconcurrentqueue" 8 | "github.com/nyaruka/phonenumbers" 9 | "github.com/vocdoni/blind-csp/types" 10 | "go.vocdoni.io/dvote/log" 11 | ) 12 | 13 | type challengeData struct { 14 | userID types.HexBytes 15 | electionID types.HexBytes 16 | phone *phonenumbers.PhoneNumber 17 | challenge int 18 | startTime time.Time 19 | retries int 20 | success bool 21 | } 22 | 23 | func (c challengeData) String() string { 24 | return fmt.Sprintf("%d[%d]", c.phone.GetNationalNumber(), c.challenge) 25 | } 26 | 27 | type smsQueue struct { 28 | queue *goconcurrentqueue.FIFO 29 | ttl time.Duration 30 | throttle time.Duration 31 | sendChallenge []SendChallengeFunc 32 | response chan (challengeData) 33 | } 34 | 35 | func newSmsQueue(ttl, throttle time.Duration, sChFns []SendChallengeFunc) *smsQueue { 36 | return &smsQueue{ 37 | queue: goconcurrentqueue.NewFIFO(), 38 | response: make(chan challengeData, 1), 39 | sendChallenge: sChFns, 40 | ttl: ttl, 41 | throttle: throttle, 42 | } 43 | } 44 | 45 | func (sq *smsQueue) add(userID, electionID types.HexBytes, phone *phonenumbers.PhoneNumber, challenge int) error { 46 | c := challengeData{ 47 | userID: userID, 48 | electionID: electionID, 49 | phone: phone, 50 | challenge: challenge, 51 | startTime: time.Now(), 52 | retries: 0, 53 | } 54 | defer log.Debugf("%s: enqueued new sms with challenge", c) 55 | return sq.queue.Enqueue(c) 56 | } 57 | 58 | func (sq *smsQueue) run() { 59 | for { 60 | time.Sleep(sq.throttle) 61 | c, err := sq.queue.DequeueOrWaitForNextElement() 62 | if err != nil { 63 | log.Warn(err) 64 | continue 65 | } 66 | challenge := c.(challengeData) 67 | // if multiple providers are defined, use them in round-robin 68 | // (try #0 will use first provider, retry #1 second provider, retry #2 first provider again) 69 | sendChallenge := sq.sendChallenge[challenge.retries%2] 70 | if err := sendChallenge(challenge.phone, challenge.challenge); err != nil { 71 | // Fail 72 | log.Warnf("%s: failed to send sms: %v", challenge, err) 73 | if err := sq.reenqueue(challenge); err != nil { 74 | log.Warnf("%s: removed from sms queue: %v", challenge, err) 75 | // Send a signal (channel) to let the caller know we are removing this element 76 | challenge.success = false 77 | sq.response <- challenge 78 | } 79 | continue 80 | } 81 | // Success 82 | log.Debugf("%s: sms with challenge successfully sent", challenge) 83 | // Send a signal (channel) to let the caller know we succeed 84 | challenge.success = true 85 | sq.response <- challenge 86 | } 87 | } 88 | 89 | func (sq *smsQueue) reenqueue(challenge challengeData) error { 90 | // check if we have to enqueue it again or not 91 | if challenge.retries >= DefaultSMSqueueMaxRetries || time.Now().After(challenge.startTime.Add(sq.ttl)) { 92 | return fmt.Errorf("TTL or max retries reached") 93 | } 94 | // enqueue it again 95 | challenge.retries++ 96 | if err := sq.queue.Enqueue(challenge); err != nil { 97 | return fmt.Errorf("cannot enqueue sms: %w", err) 98 | } 99 | log.Infof("%s: re-enqueued sms, retry #%d", challenge, challenge.retries) 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /handlers/smshandler/smshandler_test.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "testing" 7 | "time" 8 | 9 | qt "github.com/frankban/quicktest" 10 | "github.com/nyaruka/phonenumbers" 11 | "github.com/vocdoni/blind-csp/types" 12 | "go.vocdoni.io/dvote/log" 13 | ) 14 | 15 | func TestSmsHandler(t *testing.T) { 16 | log.Init("debug", "stderr", nil) 17 | 18 | dir := t.TempDir() 19 | challenge := newChallengeMock() 20 | sh := SmsHandler{SendChallenge: []SendChallengeFunc{challenge.sendChallenge}} 21 | err := sh.Init(nil, "", dir, "2", "200", "5") // MaxAttempts:2 CoolDownSeconds:200ms Throttle:5ms 22 | qt.Check(t, err, qt.IsNil) 23 | 24 | // add the users 25 | for _, ud := range usersMockData { 26 | err := sh.stg.AddUser(ud.userID, ud.elections, fmt.Sprintf("%d", ud.phone.GetNationalNumber()), "") 27 | qt.Check(t, err, qt.IsNil) 28 | } 29 | t.Log(sh.stg.String()) 30 | 31 | // Try first user (only auth step 0) 32 | 33 | // first attempt (should work) 34 | msg := types.Message{} 35 | msg.AuthData = []string{usersMockData[0].userID.String()} 36 | resp := sh.Auth(nil, &msg, usersMockData[0].elections[0], "blind", 0) 37 | qt.Check(t, resp.Success, qt.IsTrue) 38 | 39 | // attempt (should fail because of cooldown time) 40 | resp = sh.Auth(nil, &msg, usersMockData[0].elections[0], "blind", 0) 41 | qt.Check(t, resp.Success, qt.IsFalse) 42 | 43 | // second attempt (should work) 44 | time.Sleep(time.Millisecond * 200) // cooldown time 45 | resp = sh.Auth(nil, &msg, usersMockData[0].elections[0], "blind", 0) 46 | qt.Check(t, resp.Success, qt.IsTrue) 47 | 48 | // third attempt (should fail) 49 | time.Sleep(time.Millisecond * 200) // cooldown time 50 | resp = sh.Auth(nil, &msg, usersMockData[0].elections[0], "blind", 0) 51 | qt.Check(t, resp.Success, qt.IsFalse) 52 | 53 | // Try second user with step 1 (solution) 54 | msg.AuthData = []string{usersMockData[1].userID.String()} 55 | 56 | // first attempt with wrong solution (should fail) index:0 57 | resp = sh.Auth(nil, &msg, usersMockData[1].elections[0], "blind", 0) 58 | qt.Check(t, resp.Success, qt.IsTrue) 59 | 60 | msg.AuthToken = resp.AuthToken 61 | msg.AuthData = []string{"1234"} 62 | resp = sh.Auth(nil, &msg, usersMockData[1].elections[0], "blind", 1) 63 | qt.Check(t, resp.Success, qt.IsFalse) 64 | 65 | // second attempt with right solution (should work) index:1 66 | time.Sleep(time.Millisecond * 250) // cooldown time 67 | msg.AuthData = []string{usersMockData[1].userID.String()} 68 | resp = sh.Auth(nil, &msg, usersMockData[1].elections[0], "blind", 0) 69 | qt.Check(t, resp.Success, qt.IsTrue) 70 | 71 | time.Sleep(time.Millisecond * 250) 72 | msg.AuthToken = resp.AuthToken 73 | solution := challenge.getSolution(usersMockData[1].phone, 1) 74 | 75 | msg.AuthData = []string{fmt.Sprintf("%d", solution)} 76 | resp = sh.Auth(nil, &msg, usersMockData[1].elections[0], "blind", 1) 77 | qt.Check(t, resp.Success, qt.IsTrue, qt.Commentf("%s", resp.Response)) 78 | 79 | // Try again, it should fail 80 | time.Sleep(time.Millisecond * 250) 81 | msg.AuthToken = resp.AuthToken 82 | msg.AuthData = []string{fmt.Sprintf("%d", solution)} 83 | resp = sh.Auth(nil, &msg, usersMockData[1].elections[0], "blind", 1) 84 | qt.Check(t, resp.Success, qt.IsFalse) 85 | } 86 | 87 | type usersMock struct { 88 | userID types.HexBytes 89 | elections []types.HexBytes 90 | phone *phonenumbers.PhoneNumber 91 | } 92 | 93 | var usersMockData = []usersMock{ 94 | { 95 | userID: testStrToHex("6c0b6e1020b6354c714fc65aa198eb95e663f038e32026671c58677e0e0f8eac"), 96 | elections: []types.HexBytes{testStrToHex("c3095ff57150285cccf880e712e353a16251de6670f7aa1b069e6416cb641f5a")}, 97 | phone: mockPhone(), 98 | }, 99 | { 100 | userID: testStrToHex("bf5b6a9c69a5abee870b3667e92c589ef9c13458be0fc0493b2ba5a9658c690b"), 101 | elections: []types.HexBytes{testStrToHex("7ad9ef89d38a0e55cd8eb6b5f532d34c9ea8d4fe630e0d29247aa94e2e854402")}, 102 | phone: mockPhone(), 103 | }, 104 | } 105 | 106 | // mockPhone returns a phonenumber between +34722000000 and +34722999999 107 | func mockPhone() *phonenumbers.PhoneNumber { 108 | mathRandom := rand.New(rand.NewSource(time.Now().UnixNano())) 109 | n := 722000000 + mathRandom.Intn(999999) 110 | ph, _ := phonenumbers.Parse(fmt.Sprintf("%d", n), "ES") 111 | return ph 112 | } 113 | 114 | func testStrToHex(payload string) types.HexBytes { 115 | h := types.HexBytes{} 116 | if err := h.FromString(payload); err != nil { 117 | panic(err) 118 | } 119 | return h 120 | } 121 | -------------------------------------------------------------------------------- /handlers/smshandler/storage.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/nyaruka/phonenumbers" 9 | "github.com/vocdoni/blind-csp/types" 10 | ) 11 | 12 | var ( 13 | // ErrTooManyAttempts is returned when no more SMS attempts available for a user. 14 | ErrTooManyAttempts = fmt.Errorf("too many SMS attempts") 15 | // ErrUserUnknown is returned if the userID is not found in the database. 16 | ErrUserUnknown = fmt.Errorf("user is unknown") 17 | // ErrUserAlreadyVerified is returned if the user is already verified when trying to verify it. 18 | ErrUserAlreadyVerified = fmt.Errorf("user is already verified") 19 | // ErrUserNotBelongsToElection is returned if the user does not has participation rights. 20 | ErrUserNotBelongsToElection = fmt.Errorf("user does not belong to election") 21 | // ErrInvalidAuthToken is returned if the authtoken does not match with the election. 22 | ErrInvalidAuthToken = fmt.Errorf("invalid authentication token") 23 | // ErrChallengeCodeFailure is returned when the challenge code does not match. 24 | ErrChallengeCodeFailure = fmt.Errorf("challenge code do not match") 25 | // ErrAttemptCoolDownTime is returned if the cooldown time for a challenge attempt is not reached. 26 | ErrAttemptCoolDownTime = fmt.Errorf("attempt cooldown time not reached") 27 | ) 28 | 29 | // Users is the list of smshandler users. 30 | type Users struct { 31 | Users []types.HexBytes `json:"users"` 32 | } 33 | 34 | // UserData represents a user of the SMS handler. 35 | type UserData struct { 36 | UserID types.HexBytes `json:"userID,omitempty" bson:"_id"` 37 | Elections map[string]UserElection `json:"elections,omitempty" bson:"elections,omitempty"` 38 | ExtraData string `json:"extraData,omitempty" bson:"extradata,omitempty"` 39 | Phone *phonenumbers.PhoneNumber `json:"phone,omitempty" bson:"phone,omitempty"` 40 | } 41 | 42 | // UserElection represents an election and its details owned by a user (UserData). 43 | type UserElection struct { 44 | ElectionID types.HexBytes `json:"electionId" bson:"_id"` 45 | RemainingAttempts int `json:"remainingAttempts" bson:"remainingattempts"` 46 | LastAttempt *time.Time `json:"lastAttempt,omitempty" bson:"lastattempt,omitempty"` 47 | Consumed bool `json:"consumed" bson:"consumed"` 48 | AuthToken *uuid.UUID `json:"authToken,omitempty" bson:"authtoken,omitempty"` 49 | Challenge int `json:"challenge,omitempty" bson:"challenge,omitempty"` 50 | } 51 | 52 | // AuthTokenIndex is used by the storage to index a token with its userID (from UserData). 53 | type AuthTokenIndex struct { 54 | AuthToken *uuid.UUID `json:"authToken" bson:"_id"` 55 | UserID types.HexBytes `json:"userID" bson:"userid"` 56 | } 57 | 58 | // UserCollection is a dataset containing several users (used for dump and import). 59 | type UserCollection struct { 60 | Users []UserData `json:"users" bson:"users"` 61 | } 62 | 63 | // HexBytesToElection transforms a slice of HexBytes to []Election. 64 | // All entries are set with RemainingAttempts = attempts. 65 | func HexBytesToElection(electionIDs []types.HexBytes, attempts int) []UserElection { 66 | elections := []UserElection{} 67 | 68 | for _, e := range electionIDs { 69 | ue := UserElection{} 70 | ue.ElectionID = e 71 | ue.RemainingAttempts = attempts 72 | elections = append(elections, ue) 73 | } 74 | return elections 75 | } 76 | 77 | // Storage interface implements the storage layer for the smshandler 78 | type Storage interface { 79 | // Init initializes the storage, maxAttempts is used to set the default maximum SMS attempts. 80 | // CoolDownTime is the time period on which attempts are allowed. 81 | Init(dataDir string, maxAttempts int, coolDownTime time.Duration) (err error) 82 | // Reset clears the storage content 83 | Reset() (err error) 84 | // AddUser adds a new user to the storage 85 | AddUser(userID types.HexBytes, processIDs []types.HexBytes, phone, extra string) (err error) 86 | // Users returns the list of users 87 | Users() (users *Users, err error) 88 | // User returns the full information of a user, including the election list. 89 | User(userID types.HexBytes) (user *UserData, err error) 90 | // UpdateUser updates a user 91 | UpdateUser(udata *UserData) (err error) 92 | // BelongsToElection returns true if the user belongs to the electionID 93 | BelongsToElection(userID, electionID types.HexBytes) (belongs bool, err error) 94 | // SetAttempts increment or decrement remaining challenge attempts by delta 95 | SetAttempts(userID, electionID types.HexBytes, delta int) (err error) 96 | // MaxAttempts returns the default max attempts 97 | MaxAttempts() (attempts int) 98 | // NewAttempt returns the phone and decrease attempt counter 99 | NewAttempt(userID, electionID types.HexBytes, challenge int, 100 | token *uuid.UUID) (phone *phonenumbers.PhoneNumber, err error) 101 | // Exists returns true if the user exists in the database 102 | Exists(userID types.HexBytes) (exists bool) 103 | // Verified returns true if the user is verified 104 | Verified(userID, electionID types.HexBytes) (verified bool, error error) 105 | // VerifyChallenge returns nil if the challenge is solved correctly. Sets verified to true and removes the 106 | // temporary auth token from the storage 107 | VerifyChallenge(electionID types.HexBytes, token *uuid.UUID, solution int) (err error) 108 | // DelUser removes an user from the storage 109 | DelUser(userID types.HexBytes) (err error) 110 | // Search for a term within the extraData user field and returns the list of matching userIDs 111 | Search(term string) (users *Users, err error) 112 | // String returns the string representation of the storage 113 | String() string 114 | // Import insert or update a collection of users. Follows the Dump() syntax 115 | Import(data []byte) (err error) 116 | } 117 | -------------------------------------------------------------------------------- /handlers/smshandler/storage_test.go: -------------------------------------------------------------------------------- 1 | package smshandler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | qt "github.com/frankban/quicktest" 10 | "github.com/google/uuid" 11 | "github.com/vocdoni/blind-csp/test" 12 | "github.com/vocdoni/blind-csp/types" 13 | ) 14 | 15 | func TestStorageJSON(t *testing.T) { 16 | js := &JSONstorage{} 17 | testStorage(t, js) 18 | } 19 | 20 | func TestStorageMongoDB(t *testing.T) { 21 | ctx := context.Background() 22 | container, err := test.StartMongoContainer(ctx) 23 | qt.Assert(t, err, qt.IsNil) 24 | defer func() { _ = container.Terminate(ctx) }() 25 | 26 | mongoURI, err := container.Endpoint(ctx, "mongodb") 27 | qt.Assert(t, err, qt.IsNil) 28 | 29 | _ = os.Setenv("CSP_MONGODB_URL", mongoURI) 30 | _ = os.Setenv("CSP_DATABASE", test.RandomDatabaseName()) 31 | _ = os.Setenv("CSP_RESET_DB", "true") 32 | 33 | testStorage(t, &MongoStorage{}) 34 | 35 | qt.Assert(t, err, qt.IsNil) 36 | } 37 | 38 | func testStorage(t *testing.T, stg Storage) { 39 | dataDir := t.TempDir() 40 | err := stg.Init(dataDir, 2, time.Millisecond*50) 41 | qt.Assert(t, err, qt.IsNil) 42 | // Add users 43 | for user, data := range testStorageUsers { 44 | t.Logf("adding user %s", user) 45 | uh, ph := testStorageToHex(t, user, data.elections) 46 | err := stg.AddUser(uh, ph, data.phone, data.extra) 47 | qt.Assert(t, err, qt.IsNil) 48 | } 49 | t.Logf(stg.String()) 50 | 51 | users, err := stg.Users() 52 | qt.Assert(t, err, qt.IsNil) 53 | qt.Assert(t, users.Users, qt.HasLen, 3) 54 | t.Logf("Users: %s", users.Users) 55 | 56 | // Check user 1 with process 1 (should be valid) 57 | valid, err := stg.BelongsToElection( 58 | testStrToHex(testStorageUser1), 59 | testStrToHex(testStorageProcess1), 60 | ) 61 | qt.Assert(t, err, qt.IsNil) 62 | qt.Assert(t, valid, qt.IsTrue) 63 | 64 | // Check user 1 with process 2 (should be invalid) 65 | valid, err = stg.BelongsToElection( 66 | testStrToHex(testStorageUser1), 67 | testStrToHex(testStorageProcess2), 68 | ) 69 | qt.Assert(t, err, qt.IsNil) 70 | qt.Assert(t, valid, qt.IsFalse) 71 | 72 | // Check user 3 with process 1 (should be valid) 73 | valid, err = stg.BelongsToElection( 74 | testStrToHex(testStorageUser3), 75 | testStrToHex(testStorageProcess1), 76 | ) 77 | qt.Assert(t, err, qt.IsNil) 78 | qt.Assert(t, valid, qt.IsTrue) 79 | 80 | // Check user 3 with process 2 (should be valid) 81 | valid, err = stg.BelongsToElection( 82 | testStrToHex(testStorageUser3), 83 | testStrToHex(testStorageProcess2), 84 | ) 85 | qt.Assert(t, err, qt.IsNil) 86 | qt.Assert(t, valid, qt.IsTrue) 87 | 88 | // Test exists 89 | valid = stg.Exists(testStrToHex(testStorageUser1)) 90 | qt.Assert(t, valid, qt.IsTrue) 91 | valid = stg.Exists(testStrToHex(testStorageUserNonExists)) 92 | qt.Assert(t, valid, qt.IsFalse) 93 | 94 | // Test get elections 95 | user, err := stg.User(testStrToHex(testStorageUser3)) 96 | qt.Assert(t, err, qt.IsNil) 97 | qt.Assert(t, user.Elections, qt.HasLen, 2) 98 | 99 | // Test verified 100 | valid, err = stg.Verified(testStrToHex(testStorageUser1), testStrToHex(testStorageProcess1)) 101 | qt.Assert(t, err, qt.IsNil) 102 | qt.Assert(t, valid, qt.IsFalse) 103 | 104 | // Test attempts 105 | token1 := uuid.New() 106 | challenge1 := 1987 107 | phoneN, err := stg.NewAttempt(testStrToHex(testStorageUser1), 108 | testStrToHex(testStorageProcess1), challenge1, &token1) 109 | qt.Assert(t, err, qt.IsNil) 110 | qt.Assert(t, int(*phoneN.CountryCode), qt.Equals, 34) 111 | 112 | // try wrong process 113 | err = stg.VerifyChallenge(testStrToHex(testStorageProcess2), &token1, challenge1) 114 | qt.Assert(t, err, qt.ErrorIs, ErrUserNotBelongsToElection) 115 | 116 | // try wrong solution 117 | err = stg.VerifyChallenge(testStrToHex(testStorageProcess1), &token1, 1234) 118 | qt.Assert(t, err, qt.ErrorIs, ErrChallengeCodeFailure) 119 | 120 | // try valid solution but should not be allowed (already tried before) 121 | err = stg.VerifyChallenge(testStrToHex(testStorageProcess1), &token1, challenge1) 122 | qt.Assert(t, err, qt.ErrorIs, ErrInvalidAuthToken) 123 | 124 | // try another attempt 125 | challenge1 = 1989 126 | token1 = uuid.New() 127 | time.Sleep(time.Millisecond * 50) // cooldown time 128 | _, err = stg.NewAttempt(testStrToHex(testStorageUser1), 129 | testStrToHex(testStorageProcess1), challenge1, &token1) 130 | qt.Assert(t, err, qt.IsNil) 131 | 132 | // try valid solution, should work 133 | err = stg.VerifyChallenge(testStrToHex(testStorageProcess1), &token1, challenge1) 134 | qt.Assert(t, err, qt.IsNil) 135 | 136 | // now user is verified, we should not be able to ask for more challenges 137 | token1 = uuid.New() 138 | time.Sleep(time.Millisecond * 50) // cooldown time 139 | _, err = stg.NewAttempt(testStrToHex(testStorageUser1), 140 | testStrToHex(testStorageProcess1), challenge1, &token1) 141 | qt.Assert(t, err, qt.ErrorIs, ErrUserAlreadyVerified) 142 | 143 | // try to consume all attempts for user2 144 | err = stg.SetAttempts(testStrToHex(testStorageUser2), 145 | testStrToHex(testStorageProcess2), -1) 146 | qt.Assert(t, err, qt.IsNil) 147 | 148 | err = stg.SetAttempts(testStrToHex(testStorageUser2), 149 | testStrToHex(testStorageProcess2), -1) 150 | qt.Assert(t, err, qt.IsNil) 151 | 152 | token1 = uuid.New() 153 | _, err = stg.NewAttempt(testStrToHex(testStorageUser2), 154 | testStrToHex(testStorageProcess2), challenge1, &token1) 155 | qt.Assert(t, err, qt.ErrorIs, ErrTooManyAttempts) 156 | 157 | // test verified 158 | valid, err = stg.Verified(testStrToHex(testStorageUser1), testStrToHex(testStorageProcess1)) 159 | qt.Assert(t, err, qt.IsNil) 160 | qt.Assert(t, valid, qt.IsTrue) 161 | 162 | valid, err = stg.Verified(testStrToHex(testStorageUser2), testStrToHex(testStorageProcess2)) 163 | qt.Assert(t, err, qt.IsNil) 164 | qt.Assert(t, valid, qt.IsFalse) 165 | 166 | t.Logf(stg.String()) 167 | 168 | // test search term 169 | users, err = stg.Search("Smith") 170 | qt.Assert(t, err, qt.IsNil) 171 | qt.Assert(t, users.Users, qt.HasLen, 2) 172 | t.Logf("%s (%d)", users.Users, len(users.Users)) 173 | users, err = stg.Search("Rocky") 174 | qt.Assert(t, err, qt.IsNil) 175 | qt.Assert(t, users.Users, qt.HasLen, 0) 176 | users, err = stg.Search("1940") 177 | qt.Assert(t, err, qt.IsNil) 178 | qt.Assert(t, users.Users, qt.HasLen, 1) 179 | } 180 | 181 | func testStorageToHex(t *testing.T, user string, pids []string) (types.HexBytes, []types.HexBytes) { 182 | uh := types.HexBytes{} 183 | err := uh.FromString(user) 184 | qt.Assert(t, err, qt.IsNil) 185 | ph := []types.HexBytes{} 186 | for _, p := range pids { 187 | ph1 := types.HexBytes{} 188 | if err := ph1.FromString(p); err != nil { 189 | t.Fatal(err) 190 | } 191 | ph = append(ph, ph1) 192 | } 193 | return uh, ph 194 | } 195 | 196 | var ( 197 | testStorageProcess1 = "8e8353d179a60dc8e12f7c68c2b2dfebc7c34d3f01c49122a9ad4fe632c15216" 198 | testStorageProcess2 = "e1fed0c1bf0bf797cedfa30e1d92ecf7a9047b53043ea8a242388c276855ccaf" 199 | testStorageUser1 = "d763cda19aa52c2ff6e13a02989413e47abbee356bf0a8a21a73fc9af48d6ed2" 200 | testStoragePhone1 = "+34655111222" 201 | testStorageExtra1 = "John Smith 1977" 202 | testStorageUser2 = "316008c51db028fa544dbf68a4c70811728b602fee46a5d0c8dc0f6300a3c474" 203 | testStoragePhone2 = "677888999" 204 | testStorageExtra2 = "John Lewis 1940" 205 | testStorageUser3 = "0467aa5a72daf0286ad89c5220f84a7b2133fbd51ab5d3a85a049a645f54f32f" 206 | testStoragePhone3 = "+1-541-754-3010" 207 | testStorageExtra3 = "Alice Smith 1971" 208 | testStorageUserNonExists = "22fa4de0788c38755239589dfa18a8d7adbe8bb96425c4641389008470fa0377" 209 | 210 | testStorageUsers = map[string]testUserData{ 211 | testStorageUser1: { 212 | elections: []string{testStorageProcess1}, 213 | phone: testStoragePhone1, 214 | extra: testStorageExtra1, 215 | }, 216 | testStorageUser2: { 217 | elections: []string{testStorageProcess2}, 218 | phone: testStoragePhone2, 219 | extra: testStorageExtra2, 220 | }, 221 | testStorageUser3: { 222 | elections: []string{testStorageProcess1, testStorageProcess2}, 223 | phone: testStoragePhone3, 224 | extra: testStorageExtra3, 225 | }, 226 | } 227 | ) 228 | 229 | type testUserData struct { 230 | elections []string 231 | phone string 232 | extra string 233 | } 234 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | flag "github.com/spf13/pflag" 16 | "github.com/spf13/viper" 17 | "github.com/vocdoni/blind-csp/csp" 18 | "github.com/vocdoni/blind-csp/handlers/handlerlist" 19 | "go.vocdoni.io/dvote/crypto/ethereum" 20 | "go.vocdoni.io/dvote/httprouter" 21 | "go.vocdoni.io/dvote/log" 22 | ) 23 | 24 | func main() { 25 | home, err := os.UserHomeDir() 26 | if err != nil { 27 | panic("cannot get user home directory") 28 | } 29 | flag.String("key", "", 30 | "private CSP key as hexadecimal string (leave empty for autogenerate)") 31 | flag.String("dataDir", 32 | home+"/.blindcsp", "datadir for storing files and config") 33 | flag.String("domain", "", 34 | "domain name for tls with letsencrypt (port 443 must be forwarded)") 35 | flag.String("baseURL", "/v1/auth/elections", 36 | "base URL path for serving the API") 37 | flag.String("logLevel", "info", 38 | "log level {debug,info,warn,error}") 39 | flag.String("handler", "dummy", 40 | fmt.Sprintf("the authentication handler to use, available: {%s}", 41 | handlerlist.HandlersList())) 42 | flag.StringSlice("handlerOpts", []string{}, "options that will be passed to the handler") 43 | flag.Int("port", 5000, "port to listen") 44 | flag.Parse() 45 | 46 | // Setting up viper 47 | viper := viper.New() 48 | viper.SetConfigName("csp") 49 | viper.SetConfigType("yml") 50 | viper.SetEnvPrefix("CSP") 51 | viper.AutomaticEnv() 52 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 53 | 54 | // Set FlagVars first 55 | if err := viper.BindPFlag("dataDir", flag.Lookup("dataDir")); err != nil { 56 | panic(err) 57 | } 58 | dataDir := path.Clean(viper.GetString("dataDir")) 59 | viper.AddConfigPath(dataDir) 60 | fmt.Printf("Using path %s\n", dataDir) 61 | if err := viper.BindPFlag("logLevel", flag.Lookup("logLevel")); err != nil { 62 | panic(err) 63 | } 64 | if err := viper.BindPFlag("key", flag.Lookup("key")); err != nil { 65 | panic(err) 66 | } 67 | if err := viper.BindPFlag("domain", flag.Lookup("domain")); err != nil { 68 | panic(err) 69 | } 70 | if err := viper.BindPFlag("baseURL", flag.Lookup("baseURL")); err != nil { 71 | panic(err) 72 | } 73 | if err := viper.BindPFlag("port", flag.Lookup("port")); err != nil { 74 | panic(err) 75 | } 76 | if err := viper.BindPFlag("handler", flag.Lookup("handler")); err != nil { 77 | panic(err) 78 | } 79 | if err := viper.BindPFlag("handlerOpts", flag.Lookup("handlerOpts")); err != nil { 80 | panic(err) 81 | } 82 | 83 | // check if config file exists 84 | _, err = os.Stat(path.Join(dataDir, "csp.yml")) 85 | if os.IsNotExist(err) { 86 | fmt.Printf("creating new config file in %s\n", dataDir) 87 | // creting config folder if not exists 88 | err = os.MkdirAll(dataDir, os.ModePerm) 89 | if err != nil { 90 | panic(fmt.Sprintf("cannot create data directory: %v", err)) 91 | } 92 | // create config file if not exists 93 | if err := viper.SafeWriteConfig(); err != nil { 94 | panic(fmt.Sprintf("cannot write config file into config dir: %v", err)) 95 | } 96 | 97 | } else { 98 | // read config file 99 | err = viper.ReadInConfig() 100 | if err != nil { 101 | panic(fmt.Sprintf("cannot read loaded config file in %s: %v", dataDir, err)) 102 | } 103 | } 104 | // save config file 105 | if err := viper.WriteConfig(); err != nil { 106 | panic(fmt.Sprintf("cannot write config file into config dir: %v", err)) 107 | } 108 | 109 | // Set Viper/Flag variables 110 | domain := viper.GetString("domain") 111 | baseURL := viper.GetString("baseURL") 112 | privKey := viper.GetString("key") 113 | loglevel := viper.GetString("logLevel") 114 | handler := viper.GetString("handler") 115 | port := viper.GetInt("port") 116 | handlerOpts := []string{dataDir} 117 | for _, h := range viper.GetStringSlice("handlerOpts") { 118 | if !strings.Contains(h, "[") && len(h) > 0 { 119 | handlerOpts = append(handlerOpts, h) 120 | } 121 | } 122 | 123 | // Start 124 | log.Init(loglevel, "stdout", nil) 125 | signer := ethereum.SignKeys{} 126 | if privKey == "" { 127 | if err := signer.Generate(); err != nil { 128 | log.Fatal(err) 129 | } 130 | _, privKey = signer.HexString() 131 | log.Infof("new private key generated: %s", privKey) 132 | viper.Set("key", privKey) 133 | viper.Set("pubKey", fmt.Sprintf("%x", signer.PublicKey())) 134 | if err := viper.WriteConfig(); err != nil { 135 | log.Fatal(err) 136 | } 137 | } else { 138 | if err := signer.AddHexKey(privKey); err != nil { 139 | log.Fatal(err) 140 | } 141 | } 142 | log.Infof("using ECDSA signer with address %s", signer.Address().Hex()) 143 | 144 | // Create the HTTP router 145 | router := httprouter.HTTProuter{} 146 | router.TLSdomain = domain 147 | router.TLSdirCert = filepath.Join(dataDir, "tls") 148 | 149 | // Start the router 150 | if err := router.Init("0.0.0.0", port); err != nil { 151 | log.Fatal(err) 152 | } 153 | 154 | // Create the auth handler (currently a dummy one that only checks the IP) 155 | authHandler := handlerlist.Handlers[handler] 156 | if authHandler == nil { 157 | log.Fatalf("handler %s is unknown", handler) 158 | } 159 | if err := authHandler.Init(&router, baseURL, handlerOpts...); err != nil { 160 | log.Fatal(err) 161 | } 162 | log.Infof("using handler %s", handler) 163 | 164 | // Create the TLS configuration with the certificates (if required by the handler) 165 | if authHandler.RequireCertificate() { 166 | tls, err := tlsConfig(authHandler.Certificates()) 167 | if err != nil { 168 | log.Fatalf("cannot import tls certificate %v", err) 169 | } 170 | router.TLSconfig = tls 171 | // Check that the requiered certificate has been included (if any) 172 | certFound := false 173 | // nolint:staticcheck // ignoring tlsCert.RootCAs.Subjects is deprecated ERR because cert does not come from SystemCertPool. 174 | for _, cert := range tls.ClientCAs.Subjects() { 175 | certFound = authHandler.CertificateCheck(cert) 176 | if certFound { 177 | break 178 | } 179 | } 180 | if !certFound { 181 | log.Fatalf("handler %s requires a TLS CA valid certificate", handler) 182 | } 183 | } 184 | 185 | // Create the blind CSP API and assign the auth function 186 | pub, priv := signer.HexString() 187 | log.Infof("CSP root public key: %s", pub) 188 | cs, err := csp.NewBlindCSP( 189 | priv, 190 | path.Join(dataDir, authHandler.Name()), 191 | csp.BlindCSPcallbacks{ 192 | Auth: authHandler.Auth, 193 | Info: authHandler.Info, 194 | Indexer: authHandler.Indexer, 195 | }, 196 | ) 197 | if err != nil { 198 | log.Fatal(err) 199 | } 200 | if err := cs.ServeAPI(&router, baseURL); err != nil { 201 | log.Fatal(err) 202 | } 203 | 204 | // Wait for SIGTERM 205 | c := make(chan os.Signal, 1) 206 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 207 | <-c 208 | log.Warnf("received SIGTERM, exiting at %s", time.Now().Format(time.RFC850)) 209 | os.Exit(0) 210 | } 211 | 212 | func tlsConfig(x509certificates [][]byte) (*tls.Config, error) { 213 | caCertPool := x509.NewCertPool() 214 | for _, cert := range x509certificates { 215 | c, err := x509.ParseCertificate(cert) 216 | if err != nil { 217 | return nil, err 218 | } 219 | caCertPool.AddCert(c) 220 | log.Infof("imported CA certificate from %s", c.Issuer.String()) 221 | } 222 | tlsConfig := &tls.Config{ 223 | ClientCAs: caCertPool, 224 | ClientAuth: tls.RequestClientCert, 225 | } 226 | return tlsConfig, nil 227 | } 228 | -------------------------------------------------------------------------------- /misc/blind_csp_flow.svg: -------------------------------------------------------------------------------- 1 | VoterCABlockchainget processIdget CA public keygenerate new keyauthentication for processIdcheck identityCA decides the kind of authenticationsend blinded pubKey (bpk)send blind signature over bpkunblind signaturecast the vote (using CA unblinded signature as proof)validators check the CA signature over the voter's pubkeyVoterCABlockchain -------------------------------------------------------------------------------- /misc/idCat/ec_ciutadania.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vocdoni/blind-csp/fc8f087ae88da5d47847c288be56bf839d517dd4/misc/idCat/ec_ciutadania.crt -------------------------------------------------------------------------------- /misc/idCat/ec_ciutadania.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIF4TCCBMmgAwIBAgIQc+6uFePfrahUGpXs8lhiTzANBgkqhkiG9w0BAQsFADCB 3 | 8zELMAkGA1UEBhMCRVMxOzA5BgNVBAoTMkFnZW5jaWEgQ2F0YWxhbmEgZGUgQ2Vy 4 | dGlmaWNhY2lvIChOSUYgUS0wODAxMTc2LUkpMSgwJgYDVQQLEx9TZXJ2ZWlzIFB1 5 | YmxpY3MgZGUgQ2VydGlmaWNhY2lvMTUwMwYDVQQLEyxWZWdldSBodHRwczovL3d3 6 | dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAoYykwMzE1MDMGA1UECxMsSmVyYXJxdWlh 7 | IEVudGl0YXRzIGRlIENlcnRpZmljYWNpbyBDYXRhbGFuZXMxDzANBgNVBAMTBkVD 8 | LUFDQzAeFw0xNDA5MTgwODIxMDBaFw0zMDA5MTgwODIxMDBaMIGGMQswCQYDVQQG 9 | EwJFUzEzMDEGA1UECgwqQ09OU09SQ0kgQURNSU5JU1RSQUNJTyBPQkVSVEEgREUg 10 | Q0FUQUxVTllBMSowKAYDVQQLDCFTZXJ2ZWlzIFDDumJsaWNzIGRlIENlcnRpZmlj 11 | YWNpw7MxFjAUBgNVBAMMDUVDLUNpdXRhZGFuaWEwggEiMA0GCSqGSIb3DQEBAQUA 12 | A4IBDwAwggEKAoIBAQDFkHPRZPZlXTWZ5psJhbS/Gx+bxcTpGrlVQHHtIkgGz77y 13 | TA7UZUFb2EQMncfbOhR0OkvQQn1aMvhObFJSR6nI+caf2D+h/m/InMl1MyH3S0Ak 14 | YGZZsthnyC6KxqK2A/NApncrOreh70ULkQs45aOKsi1kR1W0zE+iFN+/P19P7AkL 15 | Rl3bXBCVd8w+DLhcwRrkf1FCDw6cEqaFm3cGgf5cbBDMaVYAweWTxwBZAq2RbQAW 16 | jE7mledcYghcZa4U6bUmCBPuLOnO8KMFAvH+aRzaf3ws5/ZoOVmryyLLJVZ54peZ 17 | OwnP9EL4OuWzmXCjBifXR2IAblxs5JYj57tls45nAgMBAAGjggHaMIIB1jASBgNV 18 | HRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUC2hZPofI 19 | oxUa4ECCIl+fHbLFNxUwHwYDVR0jBBgwFoAUoMOLRKo3pUW/l4Ba0fF4opvpXY0w 20 | gdYGA1UdIASBzjCByzCByAYEVR0gADCBvzAxBggrBgEFBQcCARYlaHR0cHM6Ly93 21 | d3cuYW9jLmNhdC9DQVRDZXJ0L1JlZ3VsYWNpbzCBiQYIKwYBBQUHAgIwfQx7QXF1 22 | ZXN0IGNlcnRpZmljYXQgw6lzIGVtw6hzIMO6bmljYSBpIGV4Y2x1c2l2YW1lbnQg 23 | YSBFbnRpdGF0cyBkZSBDZXJ0aWZpY2FjacOzLiBWZWdldSBodHRwczovL3d3dy5h 24 | b2MuY2F0L0NBVENlcnQvUmVndWxhY2lvMDMGCCsGAQUFBwEBBCcwJTAjBggrBgEF 25 | BQcwAYYXaHR0cDovL29jc3AuY2F0Y2VydC5jYXQwYgYDVR0fBFswWTBXoFWgU4Yn 26 | aHR0cDovL2Vwc2NkLmNhdGNlcnQubmV0L2NybC9lYy1hY2MuY3JshihodHRwOi8v 27 | ZXBzY2QyLmNhdGNlcnQubmV0L2NybC9lYy1hY2MuY3JsMA0GCSqGSIb3DQEBCwUA 28 | A4IBAQChqFTjlAH5PyIhLjLgEs68CyNNC1+vDuZXRhy22TI83JcvGmQrZosPvVIL 29 | PsUXx+C06Pfqmh48Q9S89X9K8w1SdJxP/rZeGEoRiKpwvQzM4ArD9QxyC8jirxex 30 | 3Umg9Ai/sXQ+1lBf6xw4HfUUr1WIp7pNHj0ZWLo106urqktcdeAFWme+/klis5fu 31 | labCSVPuT/QpwakPrtqOhRms8vgpKiXa/eLtL9ZiA28X/Mker0zlAeTA7Z7uAnp6 32 | oPJTlZu1Gg1ZDJueTWWsLlO+P+Wzm3MRRIbcgdRzm4mdO7ubu26SzX/aQXDhuih+ 33 | eVxXDTCfs7GUlxnjOp5j559X/N0A 34 | -----END CERTIFICATE----- 35 | -------------------------------------------------------------------------------- /misc/idCat/toPem.sh: -------------------------------------------------------------------------------- 1 | openssl x509 -in ec_ciutadania.crt -inform der -out ec_ciutadania.pem -outform PEM 2 | -------------------------------------------------------------------------------- /model/election.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/vocdoni/blind-csp/types" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/mongo/options" 11 | "go.vocdoni.io/dvote/log" 12 | "go.vocdoni.io/dvote/vochain/processid" 13 | "go.vocdoni.io/proto/build/go/models" 14 | ) 15 | 16 | // ErrElectionUnknown is returned when the election is not found 17 | var ErrElectionUnknown = fmt.Errorf("election is unknown") 18 | 19 | // ErrElectionUnknown is returned when the election is invalid 20 | var ErrElectionInvalid = fmt.Errorf("election invalid") 21 | 22 | // HandlerConfig is the configuration of a handler 23 | type HandlerConfig struct { 24 | Handler string `json:"handler" bson:"handler"` 25 | Service string `json:"service" bson:"service"` 26 | Mode string `json:"mode" bson:"mode"` 27 | Data []string `json:"data" bson:"data"` 28 | } 29 | 30 | // Election is the configuration of an election 31 | type Election struct { 32 | ID types.HexBytes `json:"electionId" bson:"_id"` 33 | Handlers []HandlerConfig `json:"handlers" bson:"handlers"` // List of handlers that will use this census 34 | } 35 | 36 | // ElectionStore is the interface to manage elections 37 | type ElectionStore interface { 38 | CreateElection(election *Election) (*Election, error) 39 | Election(id types.HexBytes) (*Election, error) 40 | DeleteElection(id types.HexBytes) error 41 | ListElection() (*[]types.HexBytes, error) 42 | } 43 | 44 | // electionStore is the implementation of ElectionStore 45 | type electionStore struct { 46 | db *MongoStorage 47 | } 48 | 49 | // NewElectionStore returns a new ElectionStore 50 | func NewElectionStore(db *MongoStorage) ElectionStore { 51 | return &electionStore{db: db} 52 | } 53 | 54 | // CreateElection creates a new election including the census data 55 | func (store *electionStore) CreateElection(election *Election) (*Election, error) { 56 | // Verify if the election census belongs to the CSP 57 | p := processid.ProcessID{} 58 | err := p.Unmarshal(election.ID) 59 | if err != nil || p.CensusOrigin() != models.CensusOrigin_OFF_CHAIN_CA { 60 | log.Warnw("Error! Election census is not from the CSP", "electionId", election.ID, "censusOrigin", p.CensusOrigin()) 61 | return nil, ErrElectionInvalid 62 | } 63 | 64 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 65 | defer cancel() 66 | 67 | if _, err := store.db.elections.InsertOne(ctx, election); err != nil { 68 | return nil, err 69 | } 70 | 71 | // Foreach handler in census data, create a User 72 | userelectionStore := NewUserelectionStore(store.db) // This is a bit ugly, but it's the only way to avoid services 73 | for _, handler := range election.Handlers { 74 | for _, userData := range handler.Data { 75 | if _, err := userelectionStore.CreateUserelection(election.ID, handler, userData); err != nil { 76 | return nil, err 77 | } 78 | } 79 | } 80 | 81 | created, err := store.Election(election.ID) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return created, nil 87 | } 88 | 89 | // Election returns an election 90 | func (store *electionStore) Election(electionID types.HexBytes) (*Election, error) { 91 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 92 | defer cancel() 93 | 94 | var election Election 95 | result := store.db.elections.FindOne(ctx, bson.M{"_id": electionID}) 96 | if err := result.Decode(&election); err != nil { 97 | log.Warnw("Error finding the Election", "err", err) 98 | return nil, ErrElectionUnknown 99 | } 100 | return &election, nil 101 | } 102 | 103 | // DeleteElection deletes an election 104 | func (store *electionStore) DeleteElection(electionID types.HexBytes) error { 105 | store.db.keysLock.Lock() 106 | defer store.db.keysLock.Unlock() 107 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 108 | defer cancel() 109 | _, err := store.db.elections.DeleteOne(ctx, bson.M{"_id": electionID}) 110 | return err 111 | } 112 | 113 | // ListElection returns a list of elections 114 | func (store *electionStore) ListElection() (*[]types.HexBytes, error) { 115 | store.db.keysLock.RLock() 116 | defer store.db.keysLock.RUnlock() 117 | opts := options.FindOptions{} 118 | opts.SetProjection(bson.M{"_id": true}) 119 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 120 | defer cancel() 121 | cur, err := store.db.elections.Find(ctx, bson.M{}, &opts) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | ctx, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) 127 | defer cancel2() 128 | var elections []types.HexBytes 129 | 130 | for cur.Next(ctx) { 131 | election := Election{} 132 | err := cur.Decode(&election) 133 | if err != nil { 134 | log.Warnw("Error decoding the Election data", "err", err) 135 | } 136 | elections = append(elections, election.ID) 137 | } 138 | 139 | return &elections, nil 140 | } 141 | -------------------------------------------------------------------------------- /model/election_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | qt "github.com/frankban/quicktest" 7 | "github.com/vocdoni/blind-csp/model" 8 | "github.com/vocdoni/blind-csp/types" 9 | "go.vocdoni.io/dvote/log" 10 | ) 11 | 12 | func createElection() (*model.Election, model.Election, error) { 13 | var id types.HexBytes 14 | if err := id.FromString("c5d2460186f7bb73137b620cffde1b3971a0c9023b480c851b700304000000" + generateID(2)); err != nil { 15 | log.Error(err) 16 | } 17 | 18 | newElection := model.Election{ 19 | ID: id, 20 | Handlers: []model.HandlerConfig{ 21 | { 22 | Handler: "handler1", 23 | Service: "service1", 24 | Mode: "mode1", 25 | Data: []string{"user1", "user2"}, 26 | }, 27 | }, 28 | } 29 | 30 | created, err := electionStore.CreateElection(&newElection) 31 | return created, newElection, err 32 | } 33 | 34 | func TestCreateElection(t *testing.T) { 35 | created, census, err := createElection() 36 | 37 | var electionID types.HexBytes 38 | if err := electionID.FromString(census.ID.String()); err != nil { 39 | log.Error(err) 40 | } 41 | 42 | qt.Assert(t, created, qt.Not(qt.IsNil)) 43 | qt.Assert(t, created.ID.String(), qt.Equals, electionID.String()) 44 | qt.Assert(t, *created, qt.DeepEquals, census) 45 | qt.Assert(t, err, qt.IsNil) 46 | } 47 | 48 | func TestGetElection(t *testing.T) { 49 | _, census, _ := createElection() 50 | var electionID types.HexBytes 51 | if err := electionID.FromString(census.ID.String()); err != nil { 52 | log.Error(err) 53 | } 54 | 55 | election, err := electionStore.Election(electionID) 56 | qt.Assert(t, election, qt.Not(qt.IsNil)) 57 | qt.Assert(t, election.ID.String(), qt.Equals, electionID.String()) 58 | qt.Assert(t, *election, qt.DeepEquals, census) 59 | qt.Assert(t, err, qt.IsNil) 60 | } 61 | 62 | func TestDeleteElection(t *testing.T) { 63 | _, census, _ := createElection() 64 | var electionID types.HexBytes 65 | if err := electionID.FromString(census.ID.String()); err != nil { 66 | log.Error(err) 67 | } 68 | 69 | err := electionStore.DeleteElection(electionID) 70 | qt.Assert(t, err, qt.IsNil) 71 | 72 | _, err = electionStore.Election(electionID) 73 | qt.Assert(t, err, qt.ErrorMatches, model.ErrElectionUnknown.Error()) 74 | } 75 | 76 | func TestListElection(t *testing.T) { 77 | _, _, _ = createElection() 78 | _, _, _ = createElection() 79 | 80 | elections, err := electionStore.ListElection() 81 | qt.Assert(t, err, qt.IsNil) 82 | greaterOrEqual := len(*elections) >= 2 83 | qt.Assert(t, greaterOrEqual, qt.IsTrue) 84 | } 85 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/vocdoni/blind-csp/model" 11 | "github.com/vocdoni/blind-csp/test" 12 | ) 13 | 14 | var ( 15 | electionStore model.ElectionStore 16 | userelectionStore model.UserelectionStore 17 | userStore model.UserStore 18 | ) 19 | 20 | func TestMain(m *testing.M) { 21 | ctx := context.Background() 22 | container, err := test.StartMongoContainer(ctx) 23 | if err != nil { 24 | panic(err) 25 | } 26 | defer func() { _ = container.Terminate(ctx) }() 27 | 28 | mongoURI, err := container.Endpoint(ctx, "mongodb") 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | _ = os.Setenv("CSP_MONGODB_URL", mongoURI) 34 | _ = os.Setenv("CSP_DATABASE", test.RandomDatabaseName()) 35 | _ = os.Setenv("CSP_RESET_DB", "true") 36 | 37 | // Storage 38 | db := &model.MongoStorage{} 39 | if err := db.Init(); err != nil { 40 | panic(err) 41 | } 42 | 43 | electionStore = model.NewElectionStore(db) 44 | userelectionStore = model.NewUserelectionStore(db) 45 | userStore = model.NewUserStore(db) 46 | 47 | exitCode := m.Run() 48 | 49 | os.Exit(exitCode) 50 | } 51 | 52 | func generateID(length int) string { 53 | // Initialize random number generator 54 | rand.New(rand.NewSource(time.Now().UnixNano())) 55 | 56 | // Define character set for ID 57 | const charset = "0123456789" 58 | 59 | // Generate ID 60 | id := make([]byte, length) 61 | for i := range id { 62 | id[i] = charset[rand.Intn(len(charset))] 63 | } 64 | 65 | return string(id) 66 | } 67 | -------------------------------------------------------------------------------- /model/mongodbstorage.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | "time" 11 | 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/mongo" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | "go.mongodb.org/mongo-driver/mongo/readpref" 16 | "go.vocdoni.io/dvote/log" 17 | ) 18 | 19 | type MongoStorage struct { 20 | keysLock sync.RWMutex 21 | elections *mongo.Collection 22 | users *mongo.Collection 23 | userelections *mongo.Collection 24 | } 25 | 26 | func (ms *MongoStorage) Init() error { 27 | var err error 28 | url := os.Getenv("CSP_MONGODB_URL") 29 | if url == "" { 30 | return fmt.Errorf("CSP_MONGODB_URL env var is not defined") 31 | } 32 | database := os.Getenv("CSP_DATABASE") 33 | if database == "" { 34 | return fmt.Errorf("CSP_DATABASE for mongodb is not defined") 35 | } 36 | log.Infow("connecting to mongodb", "url", url, "database", database) 37 | opts := options.Client() 38 | opts.ApplyURI(url) 39 | opts.SetMaxConnecting(20) 40 | timeout := time.Second * 10 41 | opts.ConnectTimeout = &timeout 42 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 43 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(url)) 44 | defer cancel() 45 | if err != nil { 46 | return err 47 | } 48 | // Shutdown database connection when SIGTERM received 49 | go func() { 50 | c := make(chan os.Signal, 1) 51 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 52 | <-c 53 | log.Warnw("received SIGTERM, disconnecting mongo database") 54 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 55 | err := client.Disconnect(ctx) 56 | if err != nil { 57 | log.Warnw("Disconnect error", "err", err) 58 | } 59 | cancel() 60 | }() 61 | 62 | ctx, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) 63 | defer cancel2() 64 | err = client.Ping(ctx, readpref.Primary()) 65 | if err != nil { 66 | return fmt.Errorf("cannot connect to mongodb: %w", err) 67 | } 68 | ms.elections = client.Database(database).Collection("elections") 69 | ms.users = client.Database(database).Collection("users") 70 | ms.userelections = client.Database(database).Collection("userelections") 71 | 72 | // Create an index on the 'ElectionId/data' field (used when searching for a user) 73 | indexModel := mongo.IndexModel{ 74 | Keys: bson.D{ 75 | {Key: "electionId", Value: 1}, 76 | {Key: "data", Value: 1}, 77 | }, 78 | } 79 | 80 | if _, err := ms.userelections.Indexes().CreateOne(context.Background(), indexModel); err != nil { 81 | log.Fatal(err) 82 | } 83 | 84 | // If reset flag is enabled, drop database documents 85 | // TODO: make the reset function part of the storage interface 86 | if reset := os.Getenv("CSP_RESET_DB"); reset != "" { 87 | log.Infow("reseting database") 88 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 89 | defer cancel() 90 | if err := ms.elections.Drop(ctx); err != nil { 91 | return err 92 | } 93 | if err := ms.users.Drop(ctx); err != nil { 94 | return err 95 | } 96 | if err := ms.userelections.Drop(ctx); err != nil { 97 | return err 98 | } 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/vocdoni/blind-csp/types" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | "go.vocdoni.io/dvote/log" 13 | ) 14 | 15 | var ( 16 | // ErrUserUnknown is returned when the user is not found 17 | ErrUserUnknown = fmt.Errorf("user is unknown") 18 | // ErrUserDuplicated is returned when the user is duplicated 19 | ErrUserDuplicated = fmt.Errorf("user is duplicated") 20 | ) 21 | 22 | // Userelection is the struct for a user in an election 23 | type User struct { 24 | ID types.HexBytes `json:"userId" bson:"_id"` 25 | Handler string `json:"handler" bson:"handler"` 26 | Service string `json:"service" bson:"service"` 27 | Mode string `json:"mode" bson:"mode"` 28 | Data string `json:"data" bson:"data"` 29 | } 30 | 31 | type UserComplete struct { 32 | ID types.HexBytes `json:"userId" bson:"_id"` 33 | Handler string `json:"handler" bson:"handler"` 34 | Service string `json:"service" bson:"service"` 35 | Mode string `json:"mode" bson:"mode"` 36 | Data string `json:"data" bson:"data"` 37 | Elections []Userelection `json:"elections" bson:"elections"` 38 | } 39 | 40 | // UserRequest is the request interface for the provided data 41 | type UserRequest struct { 42 | UserID string `json:"userId"` 43 | Handler string `json:"handler"` 44 | Service string `json:"service"` 45 | Mode string `json:"mode"` 46 | Data string `json:"data"` 47 | } 48 | 49 | type UserStore interface { 50 | CreateOrGetUser(handler HandlerConfig, userData string) (*User, error) 51 | User(userID types.HexBytes) (*User, error) 52 | SearchUser(userR UserRequest) (*[]User, error) 53 | } 54 | 55 | type userStore struct { 56 | db *MongoStorage 57 | } 58 | 59 | // NewUserStore returns a new UserStore 60 | func NewUserStore(db *MongoStorage) UserStore { 61 | return &userStore{db: db} 62 | } 63 | 64 | // CreateOrGetUser creates a new user or returns an existing one 65 | func (store *userStore) CreateOrGetUser(handler HandlerConfig, userData string) (*User, error) { 66 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 67 | defer cancel() 68 | 69 | var found User 70 | result := store.db.users.FindOne(ctx, bson.M{ 71 | "handler": handler.Handler, 72 | "service": handler.Service, 73 | "mode": handler.Mode, 74 | "data": userData, 75 | }) 76 | if error := result.Decode(&found); error == nil { 77 | return &found, nil 78 | } 79 | 80 | userElectionIDSize := 32 81 | user := User{ 82 | ID: randomBytes(userElectionIDSize), 83 | Handler: handler.Handler, 84 | Service: handler.Service, 85 | Mode: handler.Mode, 86 | Data: userData, 87 | } 88 | 89 | if _, err := store.db.users.InsertOne(ctx, user); err != nil { 90 | return nil, err 91 | } 92 | 93 | return &user, nil 94 | } 95 | 96 | // User returns a user by ID 97 | func (store *userStore) User(userID types.HexBytes) (*User, error) { 98 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 99 | defer cancel() 100 | 101 | var user User 102 | result := store.db.users.FindOne(ctx, bson.M{"_id": userID}) 103 | if err := result.Decode(&user); err != nil { 104 | log.Warnw("Error finding the user", "err", err) 105 | return nil, ErrUserelectionUnknown 106 | } 107 | return &user, nil 108 | } 109 | 110 | func (store *userStore) SearchUser(userR UserRequest) (*[]User, error) { 111 | store.db.keysLock.RLock() 112 | defer store.db.keysLock.RUnlock() 113 | opts := options.FindOptions{} 114 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 115 | defer cancel() 116 | 117 | var userID types.HexBytes 118 | if err := userID.FromString(userR.UserID); err != nil { 119 | return nil, err 120 | } 121 | 122 | var cur *mongo.Cursor 123 | filter := bson.M{} 124 | if len(userID) > 0 { 125 | filter["_id"] = userID 126 | } 127 | if userR.Handler != "" { 128 | filter["handler"] = userR.Handler 129 | } 130 | if userR.Service != "" { 131 | filter["service"] = userR.Service 132 | } 133 | if userR.Mode != "" { 134 | filter["mode"] = userR.Mode 135 | } 136 | if userR.Data != "" { 137 | filter["data"] = userR.Data 138 | } 139 | 140 | cur, err := store.db.users.Find(ctx, filter, &opts) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | ctx, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) 146 | defer cancel2() 147 | var users []User 148 | 149 | for cur.Next(ctx) { 150 | user := User{} 151 | err := cur.Decode(&user) 152 | if err != nil { 153 | log.Warnw("Error finding the user", "err", err) 154 | } 155 | users = append(users, user) 156 | } 157 | 158 | return &users, nil 159 | } 160 | -------------------------------------------------------------------------------- /model/user_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | qt "github.com/frankban/quicktest" 7 | "github.com/vocdoni/blind-csp/model" 8 | ) 9 | 10 | func createUser() (*model.User, model.HandlerConfig, error) { 11 | handler := model.HandlerConfig{ 12 | Handler: "oauth", 13 | Service: "github", 14 | Mode: "usernames", 15 | } 16 | 17 | created, err := userStore.CreateOrGetUser( 18 | handler, 19 | "user"+generateID(3)+"@gmail.com", 20 | ) 21 | 22 | return created, handler, err 23 | } 24 | 25 | func TestCreateOrGetUser(t *testing.T) { 26 | created, handler, err := createUser() 27 | 28 | qt.Assert(t, created, qt.Not(qt.IsNil)) 29 | qt.Assert(t, created.Handler, qt.Equals, handler.Handler) 30 | qt.Assert(t, created.Service, qt.Equals, handler.Service) 31 | qt.Assert(t, created.Mode, qt.Equals, handler.Mode) 32 | qt.Assert(t, err, qt.IsNil) 33 | } 34 | 35 | func TestGetUser(t *testing.T) { 36 | created, handler, err := createUser() 37 | qt.Assert(t, err, qt.IsNil) 38 | 39 | user, err := userStore.User(created.ID) 40 | qt.Assert(t, user, qt.Not(qt.IsNil)) 41 | qt.Assert(t, user.Handler, qt.Equals, handler.Handler) 42 | qt.Assert(t, user.Service, qt.Equals, handler.Service) 43 | qt.Assert(t, user.Mode, qt.Equals, handler.Mode) 44 | qt.Assert(t, err, qt.IsNil) 45 | } 46 | 47 | func TestSeachUser(t *testing.T) { 48 | created, handler, err := createUser() 49 | qt.Assert(t, err, qt.IsNil) 50 | 51 | ur := model.UserRequest{ 52 | UserID: created.ID.String(), 53 | Handler: handler.Handler, 54 | Service: handler.Service, 55 | Mode: handler.Mode, 56 | Data: created.Data, 57 | } 58 | 59 | users, err := userStore.SearchUser(ur) 60 | qt.Assert(t, err, qt.IsNil) 61 | qt.Assert(t, users, qt.Not(qt.IsNil)) 62 | qt.Assert(t, len(*users), qt.Equals, 1) 63 | 64 | user := (*users)[0] 65 | qt.Assert(t, user.Handler, qt.Equals, handler.Handler) 66 | qt.Assert(t, user.Service, qt.Equals, handler.Service) 67 | qt.Assert(t, user.Mode, qt.Equals, handler.Mode) 68 | qt.Assert(t, user.Data, qt.Equals, created.Data) 69 | } 70 | -------------------------------------------------------------------------------- /model/userelection_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | qt "github.com/frankban/quicktest" 7 | "github.com/vocdoni/blind-csp/model" 8 | "github.com/vocdoni/blind-csp/types" 9 | ) 10 | 11 | func createUserelection(electionID types.HexBytes) (*model.UserelectionComplete, model.HandlerConfig, error) { 12 | handler := model.HandlerConfig{ 13 | Handler: "oauth", 14 | Service: "github", 15 | Mode: "usernames", 16 | } 17 | 18 | created, err := userelectionStore.CreateUserelection( 19 | electionID, 20 | handler, 21 | "user"+generateID(3)+"@gmail.com", 22 | ) 23 | 24 | return created, handler, err 25 | } 26 | 27 | func TestCreateUserelection(t *testing.T) { 28 | var electionID types.HexBytes 29 | _ = electionID.FromString(generateID(5)) 30 | created, handler, err := createUserelection(electionID) 31 | qt.Assert(t, created, qt.Not(qt.IsNil)) 32 | qt.Assert(t, err, qt.IsNil) 33 | 34 | user, err := userStore.User(created.UserID) 35 | qt.Assert(t, err, qt.IsNil) 36 | qt.Assert(t, user.Handler, qt.Equals, handler.Handler) 37 | qt.Assert(t, user.Service, qt.Equals, handler.Service) 38 | qt.Assert(t, user.Mode, qt.Equals, handler.Mode) 39 | } 40 | 41 | func TestGetUserelection(t *testing.T) { 42 | var electionID types.HexBytes 43 | _ = electionID.FromString(generateID(5)) 44 | created, handler, err := createUserelection(electionID) 45 | qt.Assert(t, err, qt.IsNil) 46 | 47 | userelection, err := userelectionStore.Userelection(electionID, created.UserID) 48 | qt.Assert(t, userelection, qt.Not(qt.IsNil)) 49 | qt.Assert(t, userelection.ElectionID.String(), qt.Equals, electionID.String()) 50 | notConsumed := false 51 | qt.Assert(t, userelection.Consumed, qt.DeepEquals, ¬Consumed) 52 | qt.Assert(t, err, qt.IsNil) 53 | 54 | user, err := userStore.User(created.UserID) 55 | qt.Assert(t, err, qt.IsNil) 56 | qt.Assert(t, user.Handler, qt.Equals, handler.Handler) 57 | qt.Assert(t, user.Service, qt.Equals, handler.Service) 58 | qt.Assert(t, user.Mode, qt.Equals, handler.Mode) 59 | } 60 | 61 | func TestUpdateUserelection(t *testing.T) { 62 | var electionID types.HexBytes 63 | _ = electionID.FromString(generateID(5)) 64 | created, _, err := createUserelection(electionID) 65 | qt.Assert(t, err, qt.IsNil) 66 | 67 | consumed := true 68 | ur := model.UserelectionRequest{ 69 | Consumed: &consumed, 70 | } 71 | updated, err := userelectionStore.UpdateUserelection(electionID, created.UserID, ur) 72 | qt.Assert(t, *updated.Consumed, qt.IsTrue) 73 | qt.Assert(t, err, qt.IsNil) 74 | 75 | user, err := userelectionStore.Userelection(electionID, created.UserID) 76 | qt.Assert(t, *user.Consumed, qt.IsTrue) 77 | qt.Assert(t, err, qt.IsNil) 78 | } 79 | 80 | func TestDeleteUserelection(t *testing.T) { 81 | var electionID types.HexBytes 82 | _ = electionID.FromString(generateID(5)) 83 | created, _, err := createUserelection(electionID) 84 | qt.Assert(t, err, qt.IsNil) 85 | 86 | err = userelectionStore.DeleteUserelection(electionID, created.UserID) 87 | qt.Assert(t, err, qt.IsNil) 88 | 89 | _, err = userelectionStore.Userelection(electionID, created.UserID) 90 | qt.Assert(t, err, qt.ErrorMatches, model.ErrUserelectionUnknown.Error()) 91 | } 92 | 93 | func TestListUserelections(t *testing.T) { 94 | var electionID types.HexBytes 95 | _ = electionID.FromString(generateID(5)) 96 | _, _, _ = createUserelection(electionID) 97 | _, _, _ = createUserelection(electionID) 98 | 99 | users, err := userelectionStore.ListUserelection(electionID) 100 | qt.Assert(t, err, qt.IsNil) 101 | qt.Assert(t, len(*users), qt.Equals, 2) 102 | } 103 | 104 | func TestSearchUserelections(t *testing.T) { 105 | var electionID types.HexBytes 106 | _ = electionID.FromString(generateID(5)) 107 | _, _, _ = createUserelection(electionID) 108 | 109 | notConsumed := false 110 | ur := model.UserelectionRequest{ 111 | Handler: "oauth", 112 | Service: "github", 113 | Consumed: ¬Consumed, 114 | } 115 | users, err := userelectionStore.SearchUserelection(electionID, ur) 116 | qt.Assert(t, err, qt.IsNil) 117 | qt.Assert(t, len(*users), qt.Equals, 1) 118 | 119 | _, _, _ = createUserelection(electionID) 120 | _, _, _ = createUserelection(electionID) 121 | users, err = userelectionStore.SearchUserelection(electionID, ur) 122 | qt.Assert(t, err, qt.IsNil) 123 | qt.Assert(t, len(*users), qt.Equals, 3) 124 | 125 | consumed := true 126 | ur = model.UserelectionRequest{ 127 | Consumed: &consumed, 128 | } 129 | users, err = userelectionStore.SearchUserelection(electionID, ur) 130 | qt.Assert(t, err, qt.IsNil) 131 | qt.Assert(t, len(*users), qt.Equals, 0) 132 | } 133 | -------------------------------------------------------------------------------- /saltedkey/saltedkey.go: -------------------------------------------------------------------------------- 1 | package saltedkey 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "encoding/hex" 6 | "fmt" 7 | "math/big" 8 | 9 | blind "github.com/arnaucube/go-blindsecp256k1" 10 | ethcrypto "github.com/ethereum/go-ethereum/crypto" 11 | vocdonicrypto "go.vocdoni.io/dvote/crypto/ethereum" 12 | "go.vocdoni.io/dvote/crypto/saltedkey" 13 | ) 14 | 15 | const ( 16 | // PrivKeyHexSize is the hexadecimal length of a private key 17 | PrivKeyHexSize = 64 18 | // SaltSize is the size of the salt used for derive the new key 19 | SaltSize = 20 20 | ) 21 | 22 | // SaltedKey is a wrapper around ECDSA and ECDSA Blind that helps signing 23 | // messages with a known Salt. The Salt is added to the private key curve 24 | // point in order to derive a new deterministic signing key. 25 | // The same operation must be perform on the public key side in order to 26 | // verify the signed messages. 27 | type SaltedKey struct { 28 | rootKey *big.Int 29 | } 30 | 31 | // NewSaltedKey returns an initialized instance of SaltedKey using the private key 32 | // provided in hex format. 33 | func NewSaltedKey(privKey string) (*SaltedKey, error) { 34 | if len(privKey) != PrivKeyHexSize { 35 | return nil, fmt.Errorf("private key size is incorrect %d", len(privKey)) 36 | } 37 | pkb, err := hex.DecodeString(privKey) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | // Check the privKey point is a valid D value 43 | _, err = ethcrypto.ToECDSA(pkb) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return &SaltedKey{ 48 | rootKey: new(big.Int).SetBytes(pkb), 49 | }, nil 50 | } 51 | 52 | // SignECDSA returns the signature payload of message (which will be hashed) 53 | // using the provided Salt. 54 | func (sk *SaltedKey) SignECDSA(salt [SaltSize]byte, 55 | msg []byte, 56 | ) ([]byte, error) { 57 | esk := new(vocdonicrypto.SignKeys) 58 | if err := esk.AddHexKey(fmt.Sprintf("%x", sk.rootKey.Bytes())); err != nil { 59 | return nil, fmt.Errorf("cannot sign ECDSA salted: %w", err) 60 | } 61 | // get the bigNumber from salt 62 | s := new(big.Int).SetBytes(salt[:]) 63 | // add it to the current key, so now we have a new private key (currentPrivKey + n) 64 | esk.Private.D.Add(esk.Private.D, s) 65 | // return the signature 66 | return esk.SignEthereum(msg) 67 | } 68 | 69 | // SignBlind returns the signature payload of a blinded message using the provided Salt. 70 | // The Secretk number needs to be also provided. 71 | func (sk *SaltedKey) SignBlind(salt [SaltSize]byte, msgBlinded []byte, 72 | secretK *big.Int, 73 | ) ([]byte, error) { 74 | if secretK == nil { 75 | return nil, fmt.Errorf("secretK is nil") 76 | } 77 | s := new(big.Int).SetBytes(salt[:]) 78 | privKey := s.Add(s, sk.rootKey) 79 | blindPrivKey := blind.PrivateKey(*privKey) 80 | m := new(big.Int).SetBytes(msgBlinded) 81 | signature, err := blindPrivKey.BlindSign(m, secretK) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return signature.Bytes(), nil 86 | } 87 | 88 | // BlindPubKey returns the root public key for blind signatures 89 | func (sk *SaltedKey) BlindPubKey() *blind.PublicKey { 90 | pk := blind.PrivateKey(*sk.rootKey) 91 | return pk.Public() 92 | } 93 | 94 | // ECDSAPubKey returns the root ecdsa public key for plain signatures 95 | func (sk *SaltedKey) ECDSAPubKey() (*ecdsa.PublicKey, error) { 96 | privK, err := ethcrypto.ToECDSA(sk.rootKey.Bytes()) 97 | if err != nil { 98 | return nil, err 99 | } 100 | return &privK.PublicKey, nil 101 | } 102 | 103 | // SaltBlindPubKey returns the salted blind public key of pubKey applying the salt. 104 | func SaltBlindPubKey(pubKey *blind.PublicKey, 105 | salt [saltedkey.SaltSize]byte, 106 | ) (*blind.PublicKey, error) { 107 | if pubKey == nil { 108 | return nil, fmt.Errorf("public key is nil") 109 | } 110 | x, y := ethcrypto.S256().ScalarBaseMult(salt[:]) 111 | s := blind.Point{ 112 | X: x, 113 | Y: y, 114 | } 115 | return (*blind.PublicKey)(pubKey.Point().Add(&s)), nil 116 | } 117 | 118 | // SaltECDSAPubKey returns the salted plain public key of pubKey applying the salt. 119 | func SaltECDSAPubKey(pubKey *ecdsa.PublicKey, salt [saltedkey.SaltSize]byte) ([]byte, error) { 120 | if pubKey == nil { 121 | return nil, fmt.Errorf("public key is nil") 122 | } 123 | x, y := pubKey.Curve.ScalarBaseMult(salt[:]) 124 | pubKey.X, pubKey.Y = pubKey.Curve.Add(pubKey.X, pubKey.Y, x, y) 125 | return ethcrypto.FromECDSAPub(pubKey), nil 126 | } 127 | -------------------------------------------------------------------------------- /saltedkey/saltedkey_test.go: -------------------------------------------------------------------------------- 1 | package saltedkey 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io" 7 | "math/big" 8 | "testing" 9 | 10 | blind "github.com/arnaucube/go-blindsecp256k1" 11 | qt "github.com/frankban/quicktest" 12 | "go.vocdoni.io/dvote/crypto/ethereum" 13 | ) 14 | 15 | func TestECDSAsaltedKey(t *testing.T) { 16 | privHex := fmt.Sprintf("%x", randomBytes(32)) 17 | sk, err := NewSaltedKey(privHex) 18 | qt.Assert(t, err, qt.IsNil) 19 | 20 | salt := [SaltSize]byte{} 21 | copy(salt[:], randomBytes(20)) 22 | msg := []byte("hello world!") 23 | 24 | signature, err := sk.SignECDSA(salt, msg) 25 | qt.Assert(t, err, qt.IsNil) 26 | 27 | saltAddr, err := ethereum.AddrFromSignature(msg, signature) 28 | qt.Assert(t, err, qt.IsNil) 29 | 30 | signingKeys := ethereum.NewSignKeys() 31 | signingKeys.AddAuthKey(saltAddr) 32 | 33 | ok, _, err := signingKeys.VerifySender(msg, signature) 34 | qt.Assert(t, err, qt.IsNil) 35 | qt.Assert(t, ok, qt.IsTrue) 36 | } 37 | 38 | func TestBlindsaltedKey(t *testing.T) { 39 | privHex := fmt.Sprintf("%x", randomBytes(32)) 40 | sk, err := NewSaltedKey(privHex) 41 | qt.Assert(t, err, qt.IsNil) 42 | 43 | salt := [SaltSize]byte{} 44 | copy(salt[:], randomBytes(20)) 45 | msgHash := ethereum.HashRaw([]byte("hello world!")) 46 | 47 | // Server: generate a new secretK and R (R is required for blinding and K for signing) 48 | k, signerR, err := blind.NewRequestParameters() 49 | qt.Assert(t, err, qt.IsNil) 50 | 51 | // Client: blinds the message with R (from server). Keeps userSecretData for unblinding 52 | msgBlinded, userSecretData, err := blind.Blind(new(big.Int).SetBytes(msgHash), signerR) 53 | qt.Assert(t, err, qt.IsNil) 54 | 55 | // Server: performs the signature with the commont salt using secretK 56 | blindedSignature, err := sk.SignBlind(salt, msgBlinded.Bytes(), k) 57 | qt.Assert(t, err, qt.IsNil) 58 | 59 | // Client: unblind the signature 60 | signature := blind.Unblind(new(big.Int).SetBytes(blindedSignature), userSecretData) 61 | 62 | // Any: verifies the signature (salting previously the pubKey with the common salt) 63 | saltedPubKey, err := SaltBlindPubKey(sk.BlindPubKey(), salt) 64 | qt.Assert(t, err, qt.IsNil) 65 | valid := blind.Verify(new(big.Int).SetBytes(msgHash), signature, saltedPubKey) 66 | qt.Assert(t, valid, qt.IsTrue) 67 | } 68 | 69 | func randomBytes(n int) []byte { 70 | bytes := make([]byte, n) 71 | if _, err := io.ReadFull(rand.Reader, bytes); err != nil { 72 | panic(err) 73 | } 74 | return bytes 75 | } 76 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | HOST=${HOST:-"127.0.0.1:5000/v1/auth/elections/A9893a41fc7046d66d39fdc073ed901af6bec66ecc070a97f9cb2dda02b11265"} 3 | #set -x 4 | 5 | get_R_simplemath() { 6 | [ -z "$1" ] && { 7 | echo "auth and signature type missing" 8 | exit 1 9 | } 10 | local auth0="$(curl -s $HOST/$1/0 -X POST -d '{"authData":["John Smith"]}')" 11 | local authToken="$(echo $auth0 | jq -Mc .authToken)" 12 | local challenge1="$(echo $auth0 | jq -Mc '.response | .[0]' | tr -d \")" 13 | local challenge2="$(echo $auth0 | jq -Mc '.response | .[1]' | tr -d \")" 14 | [ -z "$challenge1" -o -z "$challenge2" ] && exit 15 | local solution=$(($challenge1 + $challenge2)) 16 | [ "$1" == "sharedkey" ] && { 17 | echo "$(curl -s $HOST/$1/1 -X POST -d '{"authToken":'$authToken', "authData":["'$solution'"]}' | jq -Mc .sharedkey)" 18 | } || { 19 | echo "$(curl -s $HOST/$1/1 -X POST -d '{"authToken":'$authToken', "authData":["'$solution'"]}' | jq -Mc .token)" 20 | } 21 | } 22 | 23 | echo "=> ECDSA blind signatre" 24 | R=$(get_R_simplemath blind/auth) 25 | echo "R is $R" 26 | hash="$(echo $RANDOM | sha256sum | awk '{print $1}')" 27 | curl -s $HOST/blind/sign -X POST -d '{"token":'$R', "payload":"'$hash'"}' 28 | 29 | 30 | echo "=> ECDSA signatre" 31 | R=$(get_R_simplemath ecdsa/auth) 32 | echo "R is $R" 33 | hash="$(echo $RANDOM | sha256sum | awk '{print $1}')" 34 | curl -s $HOST/ecdsa/sign -X POST -d '{"token":'$R', "payload":"'$hash'"}' 35 | 36 | echo "=> Shared key" 37 | SK=$(get_R_simplemath sharedkey) 38 | [ "$SK" == "" ] && { echo "Error receiving shared key"; exit 1; } 39 | echo "sharedkey: $SK" 40 | -------------------------------------------------------------------------------- /test/mongodb.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "fmt" 7 | "math/big" 8 | 9 | "github.com/testcontainers/testcontainers-go" 10 | "github.com/testcontainers/testcontainers-go/wait" 11 | ) 12 | 13 | // StartMongoContainer creates and starts an instance of the mongodb container 14 | func StartMongoContainer(ctx context.Context) (testcontainers.Container, error) { 15 | return testcontainers.GenericContainer(ctx, 16 | testcontainers.GenericContainerRequest{ 17 | ContainerRequest: testcontainers.ContainerRequest{ 18 | Image: "mongo:6", 19 | ExposedPorts: []string{"27017/tcp"}, 20 | WaitingFor: wait.ForAll( 21 | wait.ForLog("Waiting for connections"), 22 | wait.ForListeningPort("27017/tcp"), 23 | ), 24 | }, 25 | Started: true, 26 | }) 27 | } 28 | 29 | // 30 | // Copy-pasted verbatim from github.com/strikesecurity/strikememongo 31 | // 32 | 33 | // DBNameLen is the length of a database name generated by RandomDatabase(). 34 | // It's OK to change this, but not concurrently with calls to RandomDatabase. 35 | const DBNameLen = 15 36 | 37 | // DBNameChars is the set of characters used by RandomDatabase(). 38 | // It's OK to change this, but not concurrently with calls to RandomDatabase. 39 | const DBNameChars = "abcdefghijklmnopqrstuvwxyz" 40 | 41 | // RandomDatabaseName returns a random valid mongo database name. You can use to 42 | // to pick a new database name for each test to isolate tests from each other 43 | // without having to tear down the whole server. 44 | // 45 | // This function will panic if it cannot generate a random number. 46 | func RandomDatabaseName() string { 47 | dbChars := make([]byte, DBNameLen) 48 | for i := 0; i < DBNameLen; i++ { 49 | bigN, err := rand.Int(rand.Reader, big.NewInt(int64(len(DBNameChars)))) 50 | if err != nil { 51 | panic(fmt.Errorf("error getting a random int: %s", err)) 52 | } 53 | 54 | dbChars[i] = DBNameChars[int(bigN.Int64())] 55 | } 56 | 57 | return string(dbChars) 58 | } 59 | -------------------------------------------------------------------------------- /types/message.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/google/uuid" 10 | "go.vocdoni.io/dvote/log" 11 | ) 12 | 13 | // Message is the JSON API body message used by the CSP and the client 14 | type Message struct { 15 | Error string `json:"error,omitempty"` 16 | TokenR HexBytes `json:"token,omitempty"` 17 | AuthToken *uuid.UUID `json:"authToken,omitempty"` 18 | Payload HexBytes `json:"payload,omitempty"` 19 | Signature HexBytes `json:"signature,omitempty"` 20 | SharedKey HexBytes `json:"sharedkey,omitempty"` 21 | Title string `json:"title,omitempty"` // reserved for the info handler 22 | SignType []string `json:"signatureType,omitempty"` // reserver for the info handler 23 | AuthType string `json:"authType,omitempty"` // reserved for the info handler 24 | AuthSteps []*AuthField `json:"authSteps,omitempty"` // reserved for the info handler 25 | AuthData []string `json:"authData,omitempty"` // reserved for the auth handler 26 | Response []string `json:"response,omitempty"` // reserved for the handlers 27 | Elections []Election `json:"elections,omitempty"` // reserved for the indexer handler 28 | } 29 | 30 | func (m *Message) Marshal() []byte { 31 | r, err := json.Marshal(m) 32 | if err != nil { 33 | log.Warnf("error marshaling message: %v", err) 34 | } 35 | return r 36 | } 37 | 38 | func (m *Message) Unmarshal(data []byte) error { 39 | return json.Unmarshal(data, m) 40 | } 41 | 42 | // HexBytes is a []byte which encodes as hexadecimal in json, as opposed to the 43 | // base64 default. 44 | type HexBytes []byte 45 | 46 | func (b HexBytes) String() string { 47 | return hex.EncodeToString(b) 48 | } 49 | 50 | func (b *HexBytes) FromString(str string) error { 51 | var err error 52 | (*b), err = hex.DecodeString(str) 53 | return err 54 | } 55 | 56 | func (b HexBytes) MarshalJSON() ([]byte, error) { 57 | enc := make([]byte, hex.EncodedLen(len(b))+2) 58 | enc[0] = '"' 59 | hex.Encode(enc[1:], b) 60 | enc[len(enc)-1] = '"' 61 | return enc, nil 62 | } 63 | 64 | func (b *HexBytes) UnmarshalJSON(data []byte) error { 65 | if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' { 66 | return fmt.Errorf("invalid JSON string: %q", data) 67 | } 68 | data = data[1 : len(data)-1] 69 | 70 | // Strip a leading "0x" prefix, for backwards compatibility. 71 | if len(data) >= 2 && data[0] == '0' && (data[1] == 'x' || data[1] == 'X') { 72 | data = data[2:] 73 | } 74 | 75 | decLen := hex.DecodedLen(len(data)) 76 | if cap(*b) < decLen { 77 | *b = make([]byte, decLen) 78 | } 79 | if _, err := hex.Decode(*b, data); err != nil { 80 | return err 81 | } 82 | return nil 83 | } 84 | 85 | // Election represents a process voting election which might be available for 86 | // CSP signature or not (already used). 87 | type Election struct { 88 | ElectionID HexBytes `json:"electionId"` 89 | RemainingAttempts int `json:"remainingAttempts"` 90 | Consumed bool `json:"consumed"` 91 | ExtraData []string `json:"extra"` 92 | } 93 | 94 | // AuthField is the type used by the Info method for returning the description of the 95 | // authentication steps for the CSP implementation. 96 | type AuthField struct { 97 | Title string `json:"title"` 98 | Type string `json:"type"` 99 | } 100 | 101 | // AuthResponse is the type returned by Auth methods on the AuthHandler interface. 102 | // If success true and AuthToken is nil, authentication process is considered finished, 103 | // and the CSP signature is provided to the user. 104 | type AuthResponse struct { 105 | Success bool // Either the authentication step is success or not 106 | Response []string // Response can be used by the handler to provide arbitrary data to the client 107 | AuthToken *uuid.UUID // Only if there is a next step 108 | } 109 | 110 | func (a *AuthResponse) String() string { 111 | if len(a.Response) == 0 { 112 | return "" 113 | } 114 | var buf strings.Builder 115 | for i, r := range a.Response { 116 | buf.WriteString(r) 117 | if i < len(a.Response)-1 { 118 | buf.WriteString("/") 119 | } 120 | } 121 | return buf.String() 122 | } 123 | -------------------------------------------------------------------------------- /types/signaturetype.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | // SignatureTypeBlind is a secp256k1 blind signature 5 | SignatureTypeBlind = "blind" 6 | // SignatureTypeEthereum is the standard secp256k1 signature used in Ethereum 7 | SignatureTypeEthereum = "ecdsa" 8 | // SignatureTypeSharedKey identifier the shared key (common for all users on the same processId) 9 | SignatureTypeSharedKey = "sharedkey" 10 | ) 11 | 12 | // AllSignatures is a helper list that includes all available CSP signature schemes. 13 | var AllSignatures = []string{SignatureTypeBlind, SignatureTypeEthereum, SignatureTypeSharedKey} 14 | --------------------------------------------------------------------------------