From 84014b97bf71c11c6b03fb60ec913d40a941ebcd Mon Sep 17 00:00:00 2001 From: Simon Worthington Date: Tue, 23 Jan 2024 08:58:04 +1100 Subject: [PATCH] Add authentication that supports usernames and passwords This commit adds `ask` mode authentication that allows node operators to configure Bacalhau to ask the user for arbitrary information to be used as a credential. This method can be used to implement basic usernames and passwords, shared secrets, security questions and even 2FA. The associated policy additionally returns a JSON Schema to show what information is required. The CLI uses the schema to ask the user for the right information. In the future, the Web UI will do this as well. --- apps/job-info-consumer/consumer/go.mod | 2 +- apps/job-info-consumer/consumer/go.sum | 2 +- cmd/util/auth/ask.go | 77 +++++++++++++++++++++ cmd/util/auth/auth.go | 9 +-- docs/docs/dev/auth_flow.md | 19 ++++++ docs/docs/running-node/auth.md | 19 ++++++ go.mod | 5 +- go.sum | 14 +++- ops/aws/canary/lambda/go.mod | 3 +- ops/aws/canary/lambda/go.sum | 2 +- pkg/authn/ask/ask_ns_example.rego | 34 ++++++++++ pkg/authn/ask/ask_ns_password.rego | 67 ++++++++++++++++++ pkg/authn/ask/ask_ns_test_password.rego | 71 +++++++++++++++++++ pkg/authn/ask/authenticator.go | 90 +++++++++++++++++++++++++ pkg/authn/ask/authenticator_test.go | 82 ++++++++++++++++++++++ pkg/authn/ask/gen_password/main.go | 43 ++++++++++++ pkg/authn/types.go | 3 + pkg/lib/policy/policy.go | 2 +- pkg/lib/policy/scrypt.go | 60 +++++++++++++++++ pkg/node/factories.go | 21 ++++-- 20 files changed, 607 insertions(+), 18 deletions(-) create mode 100644 cmd/util/auth/ask.go create mode 100644 pkg/authn/ask/ask_ns_example.rego create mode 100644 pkg/authn/ask/ask_ns_password.rego create mode 100644 pkg/authn/ask/ask_ns_test_password.rego create mode 100644 pkg/authn/ask/authenticator.go create mode 100644 pkg/authn/ask/authenticator_test.go create mode 100644 pkg/authn/ask/gen_password/main.go create mode 100644 pkg/lib/policy/scrypt.go diff --git a/apps/job-info-consumer/consumer/go.mod b/apps/job-info-consumer/consumer/go.mod index 0412bfe53d..9ab10658d4 100644 --- a/apps/job-info-consumer/consumer/go.mod +++ b/apps/job-info-consumer/consumer/go.mod @@ -323,7 +323,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect diff --git a/apps/job-info-consumer/consumer/go.sum b/apps/job-info-consumer/consumer/go.sum index 149f0c38f1..6188c5be1c 100644 --- a/apps/job-info-consumer/consumer/go.sum +++ b/apps/job-info-consumer/consumer/go.sum @@ -1183,7 +1183,7 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/cmd/util/auth/ask.go b/cmd/util/auth/ask.go new file mode 100644 index 0000000000..e1f6aee673 --- /dev/null +++ b/cmd/util/auth/ask.go @@ -0,0 +1,77 @@ +package auth + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Returns a responder that responds to authentication requirements of type +// `authn.MethodTypeAsk`. Reads the JSON Schema returned by the `ask` endpoint +// and uses it to ask appropriate questions to the user on their terminal, and +// then returns their response as serialized JSON. +func askResponder(cmd *cobra.Command) responder { + return func(request *json.RawMessage) ([]byte, error) { + compiler := jsonschema.NewCompiler() + compiler.ExtractAnnotations = true + + if err := compiler.AddResource("", bytes.NewReader(*request)); err != nil { + return nil, err + } + + schema, err := compiler.Compile("") + if err != nil { + return nil, err + } + + response := make(map[string]any, len(schema.Properties)) + for _, name := range schema.Required { + subschema := schema.Properties[name] + + if len(subschema.Types) < 1 { + return nil, fmt.Errorf("invalid schema: property %q has no type", name) + } + + typ := subschema.Types[0] + if typ == "object" { + return nil, fmt.Errorf("invalid schema: property %q has non-scalar type", name) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "%s: ", name) + + var input []byte + var err error + + // If the property is marked as write only, assume it is a sensitive + // value and make sure we don't display it in the terminal + if subschema.WriteOnly { + input, err = term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(cmd.ErrOrStderr()) + } else { + reader := bufio.NewScanner(cmd.InOrStdin()) + if reader.Scan() { + input = reader.Bytes() + } + err = reader.Err() + } + + if err != nil { + return nil, err + } + response[name] = string(input) + } + + respBytes, err := json.Marshal(response) + if err != nil { + return nil, err + } + + return respBytes, schema.Validate(response) + } +} diff --git a/cmd/util/auth/auth.go b/cmd/util/auth/auth.go index 25b1158c6c..74b376752e 100644 --- a/cmd/util/auth/auth.go +++ b/cmd/util/auth/auth.go @@ -19,11 +19,12 @@ import ( type responder = func(request *json.RawMessage) (response []byte, err error) -var supportedMethods map[authn.MethodType]responder = map[authn.MethodType]responder{ - authn.MethodTypeChallenge: challenge.Respond, -} - func RunAuthenticationFlow(cmd *cobra.Command) (string, error) { + supportedMethods := map[authn.MethodType]responder{ + authn.MethodTypeChallenge: challenge.Respond, + authn.MethodTypeAsk: askResponder(cmd), + } + client := util.GetAPIClientV2(cmd.Context()) methods, err := client.Auth().Methods(&apimodels.ListAuthnMethodsRequest{}) if err != nil { diff --git a/docs/docs/dev/auth_flow.md b/docs/docs/dev/auth_flow.md index f20f1434e9..53f0c1cbdd 100644 --- a/docs/docs/dev/auth_flow.md +++ b/docs/docs/dev/auth_flow.md @@ -83,6 +83,25 @@ return to the endpoint. } ``` +### `ask` authentication + +This method requires the user to manually input some information. This method +can be used to implement username and password authentication, shared secret +authentication, and even 2FA or security question auth. + +The required information is represented by a JSON Schema in the object itself. +The implementation should parse the JSON Schema and ask the user questions to +populate an object that is valid by it. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bacalhau.org/auth/ask", + "type": "object", + "$ref": "https://json-schema.org/draft/2020-12/schema", +} +``` + ## 2. Run the authn flow and submit the result for an access token The user agent decides which authentication method to use (e.g. by asking the diff --git a/docs/docs/running-node/auth.md b/docs/docs/running-node/auth.md index 4958b5fa9a..10ea719ed5 100644 --- a/docs/docs/running-node/auth.md +++ b/docs/docs/running-node/auth.md @@ -47,3 +47,22 @@ include acceptable client IDs, found by running `bacalhau id`. Once the node is restarted, only keys in the allowed list will be able to access any API. + +## Username and password access + +Users can authenticate using a username and password instead of specifying a +private key for access. Again, this just requires installation of an appropriate +policy. + + curl -sL https://raw.githubusercontent.com/bacalhau-project/bacalhau/main/pkg/authn/ask/ask_ns_password.rego -o ~/.bacalhau/ask_ns_password.rego + bacalhau config set Node.Auth.Methods.Password.Type ask + bacalhau config set Node.Auth.Methods.Password.PolicyPath ~/.bacalhau/ask_ns_password.rego + +Passwords are not stored in plaintext and are salted. The downloaded policy +expects password hashes and salts generated by `scrypt`. To generate a salted +password, the helper script in `pkg/authn/ask/gen_password` can be used: + + cd pkg/authn/ask/gen_password && go run . + +This will ask for a password and generate a salt and hash to authenticate with +it. Add the encoded username, salt and hash into the `ask_ns_password.rego`. diff --git a/go.mod b/go.mod index 891812c0a5..7027a857fb 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/ricochet2200/go-disk-usage/du v0.0.0-20210707232629-ac9918953285 github.com/rs/zerolog v1.31.0 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 @@ -82,7 +83,7 @@ require ( go.uber.org/mock v0.4.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.18.0 golang.org/x/exp v0.0.0-20230321023759-10a507213a29 gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 k8s.io/apimachinery v0.29.0 @@ -373,7 +374,7 @@ require ( golang.org/x/oauth2 v0.13.0 // indirect golang.org/x/sync v0.6.0 golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.16.0 // indirect + golang.org/x/term v0.16.0 golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 golang.org/x/tools v0.16.1 // indirect diff --git a/go.sum b/go.sum index 6e43170e77..4c8c14f551 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,7 @@ github.com/BTBurke/k8sresource v1.2.0 h1:yIwuKJj4cQQVyWF5hGNhXmZNZ3VLLVH1jyGfL0a github.com/BTBurke/k8sresource v1.2.0/go.mod h1:3Sa2yHvNmOvwzP/WU8joqU4ZbBGUzToZPR9MbaDt38g= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Kubuxu/go-os-helper v0.0.1/go.mod h1:N8B+I7vPCT80IcP58r50u4+gEEcsZETFUpAzWW2ep1Y= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -72,6 +73,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= @@ -88,6 +91,7 @@ github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -188,6 +192,7 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -261,6 +266,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20231201014635-141351c24659 h1:RGgHymaENttkVRf0YEzly0Cr2q8xB56WuDEsn8oFXHE= github.com/dylibso/observe-sdk/go v0.0.0-20231201014635-141351c24659/go.mod h1:7h4vx/+0cUjKN2f+ynM4tcC8kIjJqP6W2cLcn7buXl4= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= github.com/elastic/gosigar v0.14.2 h1:Dg80n8cr90OZ7x+bAax/QjoW/XqTI11RmA79ZwIm9/4= github.com/elastic/gosigar v0.14.2/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= @@ -587,6 +594,7 @@ github.com/ipfs/go-graphsync v0.14.4/go.mod h1:yT0AfjFgicOoWdAlUJ96tQ5AkuGI4r1ta github.com/ipfs/go-ipfs-blockstore v1.3.0 h1:m2EXaWgwTzAfsmt5UdJ7Is6l4gJcaM/A12XwJyvYvMM= github.com/ipfs/go-ipfs-blockstore v1.3.0/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= +github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8= github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8= github.com/ipfs/go-ipfs-cmds v0.9.0 h1:K0VcXg1l1k6aY6sHnoxYcyimyJQbcV1ueXuWgThmK9Q= @@ -1054,6 +1062,7 @@ github.com/ricochet2200/go-disk-usage/du v0.0.0-20210707232629-ac9918953285/go.m github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= @@ -1069,6 +1078,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -1341,8 +1352,9 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/ops/aws/canary/lambda/go.mod b/ops/aws/canary/lambda/go.mod index 04056f3ff5..14aca0427b 100644 --- a/ops/aws/canary/lambda/go.mod +++ b/ops/aws/canary/lambda/go.mod @@ -304,14 +304,13 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/oauth2 v0.13.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.1 // indirect diff --git a/ops/aws/canary/lambda/go.sum b/ops/aws/canary/lambda/go.sum index d7671afce6..8c8475f75a 100644 --- a/ops/aws/canary/lambda/go.sum +++ b/ops/aws/canary/lambda/go.sum @@ -1176,7 +1176,7 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/pkg/authn/ask/ask_ns_example.rego b/pkg/authn/ask/ask_ns_example.rego new file mode 100644 index 0000000000..abebbf467b --- /dev/null +++ b/pkg/authn/ask/ask_ns_example.rego @@ -0,0 +1,34 @@ +package bacalhau.authn + +import rego.v1 + +schema := { + "type": "object", + "properties": {"magic": {"type": "string"}}, + "required": ["magic"], +} + +token := t if { + input.magic == "open sesame" + + t := io.jwt.encode_sign( + { + "typ": "JWT", + "alg": "RS256", + }, + { + "iss": input.nodeId, + "sub": "aladdin", + "aud": [input.nodeId], + "iat": now, + "exp": one_month, + "ns": { + # Read-only access to all namespaces + "*": read_only, + # Writable access to own namespace + "genie": full_access, + }, + }, + input.signingKey, + ) +} diff --git a/pkg/authn/ask/ask_ns_password.rego b/pkg/authn/ask/ask_ns_password.rego new file mode 100644 index 0000000000..35e4604f5f --- /dev/null +++ b/pkg/authn/ask/ask_ns_password.rego @@ -0,0 +1,67 @@ +package bacalhau.authn + +import rego.v1 + +# Implements a policy where clients that supply a valid username and password +# are permitted access. Anonymous users are not permitted. +# +# Modify the `userlist` to control what users are permitted access. +# Modify the `ns` key of the token to control what namespaces they can access. + +schema := { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "username": {"type": "string"}, + "password": {"type": "string", "writeOnly": true}, + }, + "required": ["username", "password"], +} + +now := time.now_ns() / 1000 + +one_month := time.add_date(time.now_ns(), 0, 1, 0) / 1000 + +# userlist should be a map of usernames to scrypt-hashed passwords and salts. in +# a simple live setup they can be hard coded here as a map, and then apply +# appropriate file permissions to this policy. +userlist := { + # "username": ["hash", "salt"] +} + +valid_user := input.ask.username if { + input.ask.username in userlist + + hash := userlist[input.ask.username][0] + salt := userlist[input.ask.username][1] + hash == scrypt(input.ask.password, salt) +} + +token := io.jwt.encode_sign( + { + "typ": "JWT", + "alg": "RS256", + }, + { + "iss": input.nodeId, + "sub": valid_user, + "aud": [input.nodeId], + "iat": now, + "exp": one_month, + "ns": { + # Read-only access to all namespaces + "*": read_only, + # Writable access to own namespace + valid_user: full_access, + }, + }, + input.signingKey, +) + +namespace_read := 1 +namespace_write := 2 +namespace_download := 4 +namespace_cancel := 8 + +read_only := bits.and(namespace_read, namespace_download) +full_access := bits.and(bits.and(namespace_write, namespace_cancel), read_only) diff --git a/pkg/authn/ask/ask_ns_test_password.rego b/pkg/authn/ask/ask_ns_test_password.rego new file mode 100644 index 0000000000..c174d3afdb --- /dev/null +++ b/pkg/authn/ask/ask_ns_test_password.rego @@ -0,0 +1,71 @@ +package bacalhau.authn + +import rego.v1 + +# Implements a policy where clients that supply a valid username and password +# are permitted access. Anonymous users are not permitted. +# +# Modify the `userlist` to control what users are permitted access. +# Modify the `ns` key of the token to control what namespaces they can access. + +schema := { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "username": {"type": "string"}, + "password": {"type": "string", "writeOnly": true}, + }, + "required": ["username", "password"], +} + +now := time.now_ns() / 1000 + +one_month := time.add_date(time.now_ns(), 0, 1, 0) / 1000 + +# userlist should be a map of usernames to scrypt-hashed passwords and salts. in +# a simple live setup they can be hard coded here as a map, and then apply +# appropriate file permissions to this policy. +userlist := {"username": [ + # hash corresponding to "password" + "SN1U4DxjUhzyYG6p6nQ4by0IpudU8wdNs7Fpp42Ky9M=", + # a randomly generated salt + "d9ucnhE5kHEqm0YEWqN5qJmrHB+IDqjuPEwLkmZ9BGs=", +]} + +valid_user := user if { + some input.ask.username, _ in userlist + + user := input.ask.username + hash := userlist[input.ask.username][0] + salt := userlist[input.ask.username][1] + hash == scrypt(input.ask.password, salt) +} + +token := io.jwt.encode_sign( + { + "typ": "JWT", + "alg": "RS256", + }, + { + "iss": input.nodeId, + "sub": valid_user, + "aud": [input.nodeId], + "iat": now, + "exp": one_month, + "ns": { + # Read-only access to all namespaces + "*": read_only, + # Writable access to own namespace + valid_user: full_access, + }, + }, + input.signingKey, +) + +namespace_read := 1 +namespace_write := 2 +namespace_download := 4 +namespace_cancel := 8 + +read_only := bits.and(namespace_read, namespace_download) +full_access := bits.and(bits.and(namespace_write, namespace_cancel), read_only) diff --git a/pkg/authn/ask/authenticator.go b/pkg/authn/ask/authenticator.go new file mode 100644 index 0000000000..c81b73bc92 --- /dev/null +++ b/pkg/authn/ask/authenticator.go @@ -0,0 +1,90 @@ +package ask + +import ( + "context" + "crypto/rsa" + "encoding/json" + + "github.com/bacalhau-project/bacalhau/pkg/authn" + "github.com/bacalhau-project/bacalhau/pkg/lib/policy" + "github.com/lestrrat-go/jwx/jwk" + "github.com/pkg/errors" + "github.com/samber/lo" +) + +type policyData struct { + SigningKey jwk.Key `json:"signingKey"` + NodeID string `json:"nodeId"` + Ask map[string]string `json:"ask"` +} + +type requiredSchema = map[string]any + +const schemaRule = "bacalhau.authn.schema" + +type askAuthenticator struct { + authnPolicy *policy.Policy + key jwk.Key + nodeID string + + validate policy.Query[policyData, string] + schema policy.Query[any, requiredSchema] +} + +func NewAuthenticator(p *policy.Policy, key *rsa.PrivateKey, nodeID string) authn.Authenticator { + return askAuthenticator{ + authnPolicy: p, + key: lo.Must(jwk.New(key)), + nodeID: nodeID, + validate: policy.AddQuery[policyData, string](p, authn.PolicyTokenRule), + schema: policy.AddQuery[any, requiredSchema](p, schemaRule), + } +} + +// Authenticate implements authn.Authenticator. +func (authenticator askAuthenticator) Authenticate(ctx context.Context, req []byte) (authn.Authentication, error) { + var userInput map[string]string + err := json.Unmarshal(req, &userInput) + if err != nil { + return authn.Error(errors.Wrap(err, "invalid authentication data")) + } + + input := policyData{ + SigningKey: authenticator.key, + NodeID: authenticator.nodeID, + Ask: userInput, + } + + token, err := authenticator.validate(ctx, input) + if errors.Is(err, policy.ErrNoResult) { + return authn.Failed("credentials rejected"), nil + } else if err != nil { + return authn.Error(err) + } + + return authn.Authentication{Success: true, Token: token}, nil +} + +func (authenticator askAuthenticator) Schema(ctx context.Context) ([]byte, error) { + schema, err := authenticator.schema(ctx, nil) + if err != nil { + return nil, err + } + + return json.Marshal(schema) +} + +// IsInstalled implements authn.Authenticator. +func (authenticator askAuthenticator) IsInstalled(ctx context.Context) (bool, error) { + schema, err := authenticator.Schema(ctx) + return err == nil && schema != nil, err +} + +// Requirement implements authn.Authenticator. +func (authenticator askAuthenticator) Requirement() authn.Requirement { + params := lo.Must(authenticator.Schema(context.TODO())) + return authn.Requirement{ + Type: authn.MethodTypeAsk, + Params: (*json.RawMessage)(¶ms), + } +} diff --git a/pkg/authn/ask/authenticator_test.go b/pkg/authn/ask/authenticator_test.go new file mode 100644 index 0000000000..c753b9ab17 --- /dev/null +++ b/pkg/authn/ask/authenticator_test.go @@ -0,0 +1,82 @@ +//go:build unit || !integration + +package ask + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "embed" + "encoding/json" + "testing" + + "github.com/bacalhau-project/bacalhau/pkg/authn" + "github.com/bacalhau-project/bacalhau/pkg/lib/policy" + "github.com/bacalhau-project/bacalhau/pkg/logger" + "github.com/stretchr/testify/require" +) + +//go:embed *.rego +var policies embed.FS + +func setup(t *testing.T) authn.Authenticator { + logger.ConfigureTestLogging(t) + + authPolicy, err := (policy.FromFS(policies, "ask_ns_test_password.rego")) + require.NoError(t, err) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return NewAuthenticator(authPolicy, rsaKey, "node") +} + +func try(t *testing.T, authenticator authn.Authenticator, r any) authn.Authentication { + req, err := json.Marshal(r) + require.NoError(t, err) + + auth, err := authenticator.Authenticate(context.Background(), req) + require.NoError(t, err) + return auth +} + +func TestRequirement(t *testing.T) { + authenticator := setup(t) + + requirement := authenticator.Requirement() + require.Equal(t, authn.MethodTypeAsk, requirement.Type) + require.NoError(t, json.Unmarshal(*requirement.Params, &requiredSchema{})) +} + +func TestUnknownUser(t *testing.T) { + authenticator := setup(t) + + auth := try(t, authenticator, map[string]string{ + "username": "robert", + "password": "password", + }) + require.False(t, auth.Success, auth.Reason) + require.Empty(t, auth.Token) +} + +func TestIncorrectPassword(t *testing.T) { + authenticator := setup(t) + + auth := try(t, authenticator, map[string]string{ + "username": "username", + "password": "drowssap", + }) + require.False(t, auth.Success, auth.Reason) + require.Empty(t, auth.Token) +} + +func TestGoodResponse(t *testing.T) { + authenticator := setup(t) + + auth := try(t, authenticator, map[string]string{ + "username": "username", + "password": "password", + }) + require.True(t, auth.Success, auth.Reason) + require.NotEmpty(t, auth.Token) +} diff --git a/pkg/authn/ask/gen_password/main.go b/pkg/authn/ask/gen_password/main.go new file mode 100644 index 0000000000..25e0706aa1 --- /dev/null +++ b/pkg/authn/ask/gen_password/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "os" + + "github.com/bacalhau-project/bacalhau/pkg/lib/policy" + "golang.org/x/term" +) + +const saltLength = 32 + +func main() { + fmt.Fprintf(os.Stderr, "Password: ") + + password, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + salt := make([]byte, saltLength) + _, err = rand.Read(salt) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + hash, err := policy.Scrypt(password, salt) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + output := [][]byte{hash, salt} + err = json.NewEncoder(os.Stdout).Encode(output) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} diff --git a/pkg/authn/types.go b/pkg/authn/types.go index b5716bac00..f582adaef8 100644 --- a/pkg/authn/types.go +++ b/pkg/authn/types.go @@ -40,6 +40,9 @@ const ( // An authentication method that provides a challenge string that the user // must sign using their private key. MethodTypeChallenge MethodType = "challenge" + + // An authentication method that asks the user to supply some credentials. + MethodTypeAsk MethodType = "ask" ) // Requirement represents information about how to authenticate using a diff --git a/pkg/lib/policy/policy.go b/pkg/lib/policy/policy.go index 80b95c95e6..285f956ab7 100644 --- a/pkg/lib/policy/policy.go +++ b/pkg/lib/policy/policy.go @@ -67,7 +67,7 @@ type Query[Input, Output any] func(ctx context.Context, input Input) (Output, er // certain input type and returns a function that will execute the query when // given input of that type. func AddQuery[Input, Output any](runner *Policy, rule string) Query[Input, Output] { - opts := append(runner.modules, rego.Query("data."+rule)) + opts := append(runner.modules, rego.Query("data."+rule), scryptFn) query := lo.Must(rego.New(opts...).PrepareForEval(context.Background())) return func(ctx context.Context, t Input) (Output, error) { diff --git a/pkg/lib/policy/scrypt.go b/pkg/lib/policy/scrypt.go new file mode 100644 index 0000000000..15e7e41da7 --- /dev/null +++ b/pkg/lib/policy/scrypt.go @@ -0,0 +1,60 @@ +package policy + +import ( + "encoding/base64" + + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/types" + "golang.org/x/crypto/scrypt" +) + +// See https://pkg.go.dev/golang.org/x/crypto/scrypt +const ( + n = 32768 + r = 8 + p = 1 + keyLen = 32 +) + +func Scrypt(password, salt []byte) ([]byte, error) { + return scrypt.Key(password, salt, n, r, p, keyLen) +} + +// scryptFn exposes the `scrypt` password hashing primitive to Rego. +var scryptFn = rego.Function2( + ®o.Function{ + Name: "scrypt", + Description: "Run the scrypt key derivation function", + Decl: types.NewFunction(types.Args(types.S, types.S), types.S), + Memoize: true, + Nondeterministic: false, + }, + func(bctx rego.BuiltinContext, passwordTerm, saltTerm *ast.Term) (*ast.Term, error) { + var password, salt string + if err := ast.As(passwordTerm.Value, &password); err != nil { + return nil, err + } + if err := ast.As(saltTerm.Value, &salt); err != nil { + return nil, err + } + + saltBytes, err := base64.StdEncoding.DecodeString(salt) + if err != nil { + return nil, err + } + + passwordBytes := []byte(password) + hash, err := Scrypt(passwordBytes, saltBytes) + if err != nil { + return nil, err + } + + value, err := ast.InterfaceToValue(base64.StdEncoding.EncodeToString(hash)) + if err != nil { + return nil, err + } + + return ast.NewTerm(value), nil + }, +) diff --git a/pkg/node/factories.go b/pkg/node/factories.go index f692e5227b..9c424d1d15 100644 --- a/pkg/node/factories.go +++ b/pkg/node/factories.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/bacalhau-project/bacalhau/pkg/authn" + "github.com/bacalhau-project/bacalhau/pkg/authn/ask" "github.com/bacalhau-project/bacalhau/pkg/authn/challenge" "github.com/bacalhau-project/bacalhau/pkg/config" "github.com/bacalhau-project/bacalhau/pkg/executor" @@ -136,26 +137,36 @@ func NewStandardAuthenticatorsFactory() AuthenticatorsFactory { return AuthenticatorsFactoryFunc( func(ctx context.Context, nodeConfig NodeConfig) (authn.Provider, error) { var allErr error - authns := make(map[string]authn.Authenticator, len(nodeConfig.AuthConfig.Methods)) + privKey, allErr := config.GetClientPrivateKey() + if allErr != nil { + return nil, allErr + } + authns := make(map[string]authn.Authenticator, len(nodeConfig.AuthConfig.Methods)) for name, authnConfig := range nodeConfig.AuthConfig.Methods { switch authnConfig.Type { case authn.MethodTypeChallenge: - privKey, err := config.GetClientPrivateKey() + methodPolicy, err := policy.FromPathOrDefault(authnConfig.PolicyPath, challenge.AnonymousModePolicy) if err != nil { allErr = multierr.Append(allErr, err) continue } - methodPolicy, err := policy.FromPathOrDefault(authnConfig.PolicyPath, challenge.AnonymousModePolicy) + authns[name] = challenge.NewAuthenticator( + methodPolicy, + challenge.NewStringMarshaller(nodeConfig.NodeID), + privKey, + nodeConfig.NodeID, + ) + case authn.MethodTypeAsk: + methodPolicy, err := policy.FromPath(authnConfig.PolicyPath) if err != nil { allErr = multierr.Append(allErr, err) continue } - authns[name] = challenge.NewAuthenticator( + authns[name] = ask.NewAuthenticator( methodPolicy, - challenge.NewStringMarshaller(nodeConfig.NodeID), privKey, nodeConfig.NodeID, )