diff --git a/cli/app/app.go b/cli/app/app.go index 85b2586b..5ac8ec36 100644 --- a/cli/app/app.go +++ b/cli/app/app.go @@ -1,11 +1,19 @@ package app import ( + "github.com/taubyte/tau/config" "github.com/urfave/cli/v2" ) func newApp() *cli.App { app := &cli.App{ + Flags: []cli.Flag{ + &cli.PathFlag{ + Name: "root", + Value: config.DefaultRoot, + Usage: "Folder where tau is installed", + }, + }, Commands: []*cli.Command{ startCommand(), configCommand(), diff --git a/cli/app/config_cmd.go b/cli/app/config_cmd.go index c9b2d60f..5b45ddf6 100644 --- a/cli/app/config_cmd.go +++ b/cli/app/config_cmd.go @@ -1,8 +1,6 @@ package app import ( - "github.com/pterm/pterm" - "github.com/taubyte/tau/config" "github.com/urfave/cli/v2" ) @@ -20,10 +18,6 @@ func configCommand() *cli.Command { Name: "shape", Aliases: []string{"s"}, }, - &cli.PathFlag{ - Name: "root", - DefaultText: config.DefaultRoot, - }, &cli.PathFlag{ Name: "path", Aliases: []string{"p"}, @@ -42,10 +36,6 @@ func configCommand() *cli.Command { Name: "shape", Aliases: []string{"s"}, }, - &cli.PathFlag{ - Name: "root", - DefaultText: config.DefaultRoot, - }, &cli.PathFlag{ Name: "path", Aliases: []string{"p"}, @@ -59,6 +49,25 @@ func configCommand() *cli.Command { return displayConfig(pid, cnf) }, }, + { + Name: "export", + Usage: "export a configuration bundle", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "unsafe", + Usage: "export node private key (Only use to restore a node).", + }, + &cli.StringFlag{ + Name: "shape", + Aliases: []string{"s"}, + }, + &cli.BoolFlag{ + Name: "protect", + Aliases: []string{"p"}, + }, + }, + Action: exportConfig, + }, { Name: "generate", Aliases: []string{"gen"}, @@ -67,17 +76,14 @@ func configCommand() *cli.Command { Name: "shape", Aliases: []string{"s"}, }, - &cli.PathFlag{ - Name: "root", - Value: config.DefaultRoot, - }, &cli.StringFlag{ Name: "protocols", Aliases: []string{"proto", "protos"}, + Usage: "Protocols to enable. Use `all` to enable them all.", }, &cli.StringFlag{ Name: "network", - Aliases: []string{"fqdn"}, + Aliases: []string{"n", "fqdn"}, Value: "example.com", }, &cli.IntFlag{ @@ -87,7 +93,8 @@ func configCommand() *cli.Command { }, &cli.StringSliceFlag{ Name: "ip", - Aliases: []string{"address", "addr"}, + Aliases: []string{"announce"}, + Usage: "IP address to announce.", }, &cli.StringSliceFlag{ Name: "bootstrap", @@ -100,14 +107,12 @@ func configCommand() *cli.Command { Name: "dv-keys", Aliases: []string{"dv"}, }, + &cli.PathFlag{ + Name: "use", + Usage: "use a configuration template", + }, }, - Action: func(ctx *cli.Context) error { - id, err := generateSourceConfig(ctx) - if id != "" { - pterm.Info.Println("ID:", id) - } - return err - }, + Action: generateSourceConfig, }, }, } diff --git a/cli/app/config_test.go b/cli/app/config_test.go index cdf8441b..b7a20ea4 100644 --- a/cli/app/config_test.go +++ b/cli/app/config_test.go @@ -15,8 +15,6 @@ import ( ) func TestConfig(t *testing.T) { - app := newApp() - ctx, ctxC := context.WithTimeout(context.Background(), time.Second*15) defer ctxC() @@ -32,16 +30,16 @@ func TestConfig(t *testing.T) { os.Mkdir(root+"/config", 0750) os.Mkdir(root+"/config/keys", 0750) - err = app.RunContext(ctx, []string{os.Args[0], "cnf", "gen", "-s", "test", "--root", root, "--protos", "auth,seer,monkey", "--swarm-key", "--dv-keys"}) + err = newApp().RunContext(ctx, []string{os.Args[0], "--root", root, "cnf", "gen", "-s", "test", "--protos", "auth,seer,monkey", "--swarm-key", "--dv-keys"}) assert.NilError(t, err) - err = app.RunContext(ctx, []string{os.Args[0], "cnf", "ok?", "-s", "test", "--root", root}) + err = newApp().RunContext(ctx, []string{os.Args[0], "--root", root, "cnf", "ok?", "-s", "test"}) assert.NilError(t, err) - err = app.RunContext(ctx, []string{os.Args[0], "cnf", "show", "-s", "test", "--root", root}) + err = newApp().RunContext(ctx, []string{os.Args[0], "--root", root, "cnf", "show", "-s", "test"}) assert.NilError(t, err) config.DefaultRoot = root - err = app.RunContext(ctx, []string{os.Args[0], "cnf", "show", "-s", "test"}) + err = newApp().RunContext(ctx, []string{os.Args[0], "cnf", "show", "-s", "test"}) assert.NilError(t, err) } diff --git a/cli/app/export_config.go b/cli/app/export_config.go new file mode 100644 index 00000000..6f5ff30b --- /dev/null +++ b/cli/app/export_config.go @@ -0,0 +1,137 @@ +package app + +import ( + "encoding/base64" + "fmt" + "io" + "os" + "path" + "path/filepath" + "time" + + "github.com/taubyte/tau/config" + "github.com/urfave/cli/v2" + "gopkg.in/yaml.v2" +) + +func exportConfig(ctx *cli.Context) error { + root := ctx.Path("root") + if root == "" { + root = config.DefaultRoot + } + + if !filepath.IsAbs(root) { + return fmt.Errorf("root folder `%s` is not absolute", root) + } + + shape := ctx.String("shape") + + configRoot := root + "/config" + configPath := ctx.Path("path") + if configPath == "" { + configPath = path.Join(configRoot, shape+".yaml") + } + + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("shape %s does not exist", shape) + } + + host, err := os.Hostname() + if err != nil { + return fmt.Errorf("faile to fetch hostname with %w", err) + } + + var version *string + if v := ctx.String("version"); v != "" { + version = &v + } + + bundle := &config.Bundle{ + Origin: config.BundleOrigin{ + Shape: shape, + Host: host, + Creation: time.Now(), + Version: version, + }, + } + + if err = yaml.Unmarshal(data, &(bundle.Source)); err != nil { + return fmt.Errorf("yaml unmarshal failed with: %w", err) + } + + pkey := bundle.Privatekey + if !ctx.Bool("unsafe") { + pkey = "" + } + + skfilename := path.Join(configRoot, bundle.Swarmkey) + skdata, err := os.ReadFile(skfilename) + if err != nil { + return fmt.Errorf("reading %s failed with: %w %#v", skfilename, err, bundle) + } + + dvsfilename := path.Join(configRoot, bundle.Domains.Key.Private) + dvsdata, err := os.ReadFile(dvsfilename) + if err != nil { + return fmt.Errorf("reading %s failed with: %w", dvsfilename, err) + } + + var dvpdata []byte + if bundle.Domains.Key.Public != "" { + dvpfilename := path.Join(configRoot, bundle.Domains.Key.Public) + dvpdata, err = os.ReadFile(dvpfilename) + if err != nil { + return fmt.Errorf("reading %s failed with: %w", dvsfilename, err) + } + } + + if ctx.Bool("protect") { + bundle.Origin.Protected = true + if passwd, err := promptPassword("Password?"); err != nil { + return fmt.Errorf("faild to read password with %w", err) + } else { + if skdata, err = encrypt(skdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt swarm key with %w", err) + } + if dvsdata, err = encrypt(dvsdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt domain's private key key with %w", err) + } + if dvpdata != nil { + if dvpdata, err = encrypt(dvpdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt domain's public key key with %w", err) + } + } + if pkey != "" { + pkdata, err := encrypt([]byte(pkey), passwd) + if err != nil { + return fmt.Errorf("faild to encrypt private key key with %w", err) + } + pkey = base64.StdEncoding.EncodeToString(pkdata) + } + } + } + + bundle.Privatekey = pkey + bundle.Swarmkey = base64.StdEncoding.EncodeToString(skdata) + bundle.Domains.Key.Private = base64.StdEncoding.EncodeToString(dvsdata) + bundle.Domains.Key.Public = base64.StdEncoding.EncodeToString(dvpdata) + + var out io.Writer = os.Stdout + if ctx.Args().Present() { + filename := ctx.Args().First() + f, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0460) + if err != nil { + return fmt.Errorf("fail to open %s with %w", filename, err) + } + defer f.Close() + out = f + } + + err = yaml.NewEncoder(out).Encode(bundle) + if err != nil { + return fmt.Errorf("fail to marshal configuration with %w", err) + } + + return nil +} diff --git a/cli/app/export_config_test.go b/cli/app/export_config_test.go new file mode 100644 index 00000000..c33aac81 --- /dev/null +++ b/cli/app/export_config_test.go @@ -0,0 +1,124 @@ +package app + +import ( + "context" + "encoding/base64" + "os" + "strings" + "testing" + "time" + + "github.com/taubyte/tau/config" + "gopkg.in/yaml.v2" + "gotest.tools/v3/assert" +) + +func setupTestEnvironmentWithKeys(t *testing.T) (string, func()) { + root, err := os.MkdirTemp("", "tau-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + cleanup := func() { os.RemoveAll(root) } + + // Creating necessary directories + os.MkdirAll(root+"/config/keys", 0750) + + // Writing embedded contents to files + assert.NilError(t, os.WriteFile(root+"/config/test.yaml", testConfig, 0640)) + assert.NilError(t, os.WriteFile(root+"/config/keys/test_swarm.key", testSwarmKey, 0640)) + + // Generating and writing domain verification keys + privKey, pubKey, err := generateDVKeys(nil, nil) + if err != nil { + t.Fatalf("Failed to generate domain verification keys: %v", err) + } + assert.NilError(t, os.WriteFile(root+"/config/keys/test.key", privKey, 0640)) + assert.NilError(t, os.WriteFile(root+"/config/keys/test.pub", pubKey, 0640)) + + return root, cleanup +} + +func TestExportConfigWithEmbeddedFilesAndGeneratedKeys(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + root, cleanup := setupTestEnvironmentWithKeys(t) + defer cleanup() + + app := newApp() // Assuming newApp initializes *cli.App with your commands + + args := []string{"tau", "--root", root, "config", "export", "--shape", "test"} + err := app.RunContext(ctx, args) + assert.NilError(t, err) +} + +func TestExportConfigWithEmbeddedFilesAndGeneratedKeysToFile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + root, cleanup := setupTestEnvironmentWithKeys(t) + defer cleanup() + + outputFile := root + "/configExported.yaml" + app := newApp() // Assuming newApp initializes *cli.App with your commands + + args := []string{"tau", "--root", root, "config", "export", "--shape", "test", outputFile} + err := app.RunContext(ctx, args) + assert.NilError(t, err) + + // Read the output file + exportedData, err := os.ReadFile(outputFile) + assert.NilError(t, err) + + // Example assertion - check if the file contains expected strings + assert.Assert(t, containsString(exportedData, "shape: test"), "Exported config should contain the shape") + assert.Assert(t, containsString(exportedData, "swarmkey"), "Exported config should contain the swarmkey") +} + +// containsString is a helper function to check if the byte slice contains the specified string +func containsString(data []byte, str string) bool { + return strings.Contains(string(data), str) +} + +func TestExportConfigEncryptionOfFields(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + root, cleanup := setupTestEnvironmentWithKeys(t) + defer cleanup() + + testPassword = "secret" + outputFile := root + "/configProtectedExport.yaml" + + // Setup your app and context similarly to your actual app's initialization + app := newApp() // newApp should setup CLI app with the exportConfig command + + // Running the exportConfig command with encryption enabled + args := []string{"tau", "--root", root, "config", "export", "--shape", "test", "--protect", outputFile} + err := app.RunContext(ctx, args) + assert.NilError(t, err) + + // Reading the output file + exportedData, err := os.ReadFile(outputFile) + assert.NilError(t, err) + + // Parsing the YAML to access encrypted fields + var exportedBundle config.Bundle + err = yaml.Unmarshal(exportedData, &exportedBundle) + assert.NilError(t, err) + + swarmKey, err := decryptAndBase64Decode(exportedBundle.Swarmkey, testPassword) + assert.NilError(t, err) + + // Compare decryptedSwarmKey with the original swarm key content used in setupTestEnvironmentWithKeys + assert.DeepEqual(t, testSwarmKey, swarmKey) +} + +// Helper function to base64 decode then decrypt +func decryptAndBase64Decode(encodedEncryptedData string, password string) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(encodedEncryptedData) + if err != nil { + return nil, err + } + return decrypt(data, password) // Use your actual decryption function +} diff --git a/cli/app/gen_config.go b/cli/app/gen_config.go index 549057f2..f909f3f1 100644 --- a/cli/app/gen_config.go +++ b/cli/app/gen_config.go @@ -24,102 +24,233 @@ import ( "gopkg.in/yaml.v3" commonSpecs "github.com/taubyte/go-specs/common" + + "github.com/pterm/pterm" + + jwt "github.com/dgrijalva/jwt-go" ) // TODO: move to config as a methods -func generateSourceConfig(ctx *cli.Context) (string, error) { +func generateSourceConfig(ctx *cli.Context) error { root := ctx.Path("root") if !filepath.IsAbs(root) { - return "", fmt.Errorf("root folder `%s` is not absolute", root) + return fmt.Errorf("root folder `%s` is not absolute", root) + } + + shape := ctx.String("shape") + + var ( + passwd string + err error + + skdata []byte + dvsdata []byte + dvpdata []byte + pkey string + ) + + templatePath := ctx.Path("use") + var bundle *config.Bundle + if templatePath != "" { + if f, err := os.Open(templatePath); err != nil { + return fmt.Errorf("failed to read template %s with %w", templatePath, err) + } else { + ydec := yaml.NewDecoder(f) + bundle = &config.Bundle{} + err = ydec.Decode(bundle) + f.Close() + if err != nil { + return fmt.Errorf("failed to parse template %s with %w", templatePath, err) + } + + pkey = bundle.Privatekey + } + } + + if shape == "" && bundle != nil { + shape = bundle.Origin.Shape } - nodeID, nodeKey, err := generateNodeKeyAndID() + if bundle != nil && bundle.Origin.Protected { + if passwd, err = promptPassword("Password?"); err != nil { + return fmt.Errorf("faild to read password with %w", err) + } else { + + if pkey != "" { + if pkdata, err := base64.StdEncoding.DecodeString(pkey); err != nil { + return fmt.Errorf("faild to read encrypted private key with %w", err) + } else if pkdata, err = decrypt(pkdata, passwd); err != nil { + return fmt.Errorf("faild to decrypt private key with %w", err) + } else { + pkey = string(pkdata) + } + } + + if skdata, err = base64.StdEncoding.DecodeString(bundle.Swarmkey); err != nil { + return fmt.Errorf("faild to read encrypted swarm key with %w", err) + } else if skdata, err = decrypt(skdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt swarm key with %w", err) + } + + if dvsdata, err = base64.StdEncoding.DecodeString(bundle.Domains.Key.Private); err != nil { + return fmt.Errorf("faild to read encrypted domain key with %w", err) + } else if dvsdata, err = decrypt(dvsdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt domain key with %w", err) + } + + if bundle.Domains.Key.Public != "" { + if dvpdata, err = base64.StdEncoding.DecodeString(bundle.Domains.Key.Public); err != nil { + return fmt.Errorf("faild to read encrypted domain public key with %w", err) + } else if dvpdata, err = decrypt(dvpdata, passwd); err != nil { + return fmt.Errorf("faild to encrypt domain public key with %w", err) + } + } + } + } + + nodeID, nodeKey, err := generateNodeKeyAndID(pkey) if err != nil { - return "", err + return err } - mainP2pPort := ctx.Int("p2p-port") + var ports config.Ports + ports.Main = ctx.Int("p2p-port") + ports.Lite = ports.Main + 5 + ports.Ipfs = ports.Main + 10 + + if bundle != nil && ports.Main != 4242 { + ports.Main = bundle.Ports.Main + if bundle.Ports.Lite != 0 { + ports.Lite = bundle.Ports.Lite + } else { + ports.Lite = ports.Main + 5 + } + if bundle.Ports.Ipfs != 0 { + ports.Ipfs = bundle.Ports.Ipfs + } else { + ports.Ipfs = ports.Main + 10 + } + } + var announce []string ips := ctx.StringSlice("ip") - if len(ips) == 0 { - ips = append(ips, "127.0.0.1") + if len(ips) > 0 { + announce = make([]string, len(ips)) + for i, ip := range ips { + announce[i] = fmt.Sprintf("/ip4/%s/tcp/%d", ip, ports.Main) + } + } else if bundle != nil { + announce = bundle.P2PAnnounce + } else { + announce = []string{fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", ports.Main)} } - announce := make([]string, len(ips)) - for i, ip := range ips { - announce[i] = fmt.Sprintf("/ip4/%s/tcp/%d", ip, mainP2pPort) + protocols := getProtocols(ctx.String("protocols")) + if len(protocols) == 0 && bundle != nil { + protocols = bundle.Protocols + } + + p2pListen := []string{fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", ports.Main)} + if bundle != nil && len(bundle.P2PListen) > 0 { + p2pListen = bundle.P2PListen + } + + var location *seer.Location + if bundle != nil { + location = bundle.Location + } else { + location, err = estimateGPSLocation() + if err != nil { + return fmt.Errorf("extimating GPS location failed with %w", err) + } + } + + peers := ctx.StringSlice("bootstrap") + if len(peers) == 0 && bundle != nil { + peers = bundle.Peers + } + + fqdn := ctx.String("network") + genfqdn := fmt.Sprintf("g.%s", ctx.String("network")) + if len(fqdn) == 0 && bundle != nil { + fqdn = bundle.NetworkFqdn + genfqdn = bundle.Domains.Generated } configStruct := &config.Source{ Privatekey: nodeKey, Swarmkey: path.Join("keys", "swarm.key"), - Protocols: getProtocols(ctx.String("protocols")), - P2PListen: []string{fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", mainP2pPort)}, + Protocols: protocols, + P2PListen: p2pListen, P2PAnnounce: announce, - Ports: config.Ports{ - Main: mainP2pPort, - Lite: mainP2pPort + 5, - Ipfs: mainP2pPort + 10, - }, - Location: &seer.Location{ - Latitude: 32.78306, - Longitude: -96.80667, - }, - Peers: ctx.StringSlice("bootstrap"), - NetworkFqdn: ctx.String("network"), + Ports: ports, + Location: location, + Peers: peers, + NetworkFqdn: fqdn, Domains: config.Domains{ Key: config.DVKey{ Private: path.Join("keys", "dv_private.pem"), Public: path.Join("keys", "dv_public.pem"), }, - Generated: fmt.Sprintf("g.%s", ctx.String("network")), + Generated: genfqdn, }, } configRoot := root + "/config" - if ctx.Bool("swarm-key") { - swarmkey, err := generateSwarmKey() + + if err = os.MkdirAll(path.Join(configRoot, "keys"), 0750); err != nil { + return err + } + + if ctx.Bool("swarm-key") || len(skdata) > 0 { + swarmkey, err := generateSwarmKey(skdata) if err != nil { - return "", err + return err } - if err = os.WriteFile(path.Join(configRoot, "keys", "swarm.key"), []byte(swarmkey), 0440); err != nil { - return "", err + if err = os.WriteFile(path.Join(configRoot, "keys", "swarm.key"), []byte(swarmkey), 0640); err != nil { + return fmt.Errorf("failed to write config file with %w", err) } } - if ctx.Bool("dv-keys") { - priv, pub, err := generateDVKeys() + if ctx.Bool("dv-keys") || len(dvsdata) > 0 { + priv, pub, err := generateDVKeys(dvsdata, dvpdata) if err != nil { - return "", err + return err } - if err = os.WriteFile(path.Join(configRoot, "keys", "dv_private.pem"), priv, 0440); err != nil { - return "", err + if err = os.WriteFile(path.Join(configRoot, "keys", "dv_private.pem"), priv, 0640); err != nil { + return err } - if err = os.WriteFile(path.Join(configRoot, "keys", "dv_public.pem"), pub, 0440); err != nil { - return "", err + if err = os.WriteFile(path.Join(configRoot, "keys", "dv_public.pem"), pub, 0640); err != nil { + return err } } - configPath := path.Join(configRoot, ctx.String("shape")+".yaml") + configPath := path.Join(configRoot, shape+".yaml") f, err := os.Create(configPath) if err != nil { - return "", err + return err } defer f.Close() yamlEnc := yaml.NewEncoder(f) if err = yamlEnc.Encode(configStruct); err != nil { - return "", err + return err } - return nodeID, nil + pterm.Info.Println("ID:", nodeID) + + return nil } func getProtocols(s string) []string { + if s == "all" { + return append([]string{}, commonSpecs.Protocols...) + } + protos := make(map[string]bool) for _, p := range commonSpecs.Protocols { protos[p] = false @@ -138,7 +269,11 @@ func getProtocols(s string) []string { return ret } -func generateSwarmKey() (string, error) { +func generateSwarmKey(data []byte) (string, error) { + if len(data) > 0 { + return string(data), nil + } + key := make([]byte, 32) _, err := rand.Read(key) if err != nil { @@ -148,12 +283,28 @@ func generateSwarmKey() (string, error) { return "/key/swarm/psk/1.0.0//base16/" + hex.EncodeToString(key), nil } -func generateNodeKeyAndID() (string, string, error) { - key := keypair.New() +func generateNodeKeyAndID(pkey string) (string, string, error) { + var ( + key crypto.PrivKey + keyData []byte + err error + ) + if pkey == "" { + key = keypair.New() + keyData, err = crypto.MarshalPrivateKey(key) + if err != nil { + return "", "", fmt.Errorf("marshal private key failed with %w", err) + } + } else { + keyData, err = base64.StdEncoding.DecodeString(pkey) + if err != nil { + return "", "", fmt.Errorf("decode private key failed with %w", err) + } - keyData, err := crypto.MarshalPrivateKey(key) - if err != nil { - return "", "", fmt.Errorf("marshal private key failed with %w", err) + key, err = crypto.UnmarshalPrivateKey(keyData) + if err != nil { + return "", "", fmt.Errorf("read private key failed with %w", err) + } } id, err := peer.IDFromPublicKey(key.GetPublic()) @@ -164,23 +315,38 @@ func generateNodeKeyAndID() (string, string, error) { return id.String(), base64.StdEncoding.EncodeToString(keyData), nil } -func generateDVKeys() ([]byte, []byte, error) { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, fmt.Errorf("generate ecdsa key failed with %w", err) - } +func generateDVKeys(private, public []byte) ([]byte, []byte, error) { + var ( + priv *ecdsa.PrivateKey + err error + ) + if len(private) > 0 { + if len(public) > 0 { + return private, public, nil + } - privBytes, err := pemEncodePrivKey(priv) - if err != nil { - return nil, nil, err + priv, err = jwt.ParseECPrivateKeyFromPEM(private) + if err != nil { + return nil, nil, fmt.Errorf("open ecdsa key failed with %w", err) + } + } else { + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate ecdsa key failed with %w", err) + } + + private, err = pemEncodePrivKey(priv) + if err != nil { + return nil, nil, err + } } - pubBytes, err := pemEncodePubKey(priv) + public, err = pemEncodePubKey(priv) if err != nil { return nil, nil, err } - return privBytes, pubBytes, nil + return private, public, nil } func pemEncodePrivKey(priv *ecdsa.PrivateKey) ([]byte, error) { diff --git a/cli/app/geoip.go b/cli/app/geoip.go new file mode 100644 index 00000000..72c7fba2 --- /dev/null +++ b/cli/app/geoip.go @@ -0,0 +1,112 @@ +//go:build !mock + +package app + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "sync" + + "github.com/taubyte/go-interfaces/services/seer" +) + +// Structs to parse responses from the APIs +type ipAPIResponse struct { + Lat float32 `json:"lat"` + Lon float32 `json:"lon"` +} + +type freeGeoIPResponse struct { + Latitude float32 `json:"latitude"` + Longitude float32 `json:"longitude"` +} + +// Function to estimate GPS location +func estimateGPSLocation() (*seer.Location, error) { + var wg sync.WaitGroup + wg.Add(2) + + var mu sync.Mutex + var locations []seer.Location + + // ip-api.com + go func() { + defer wg.Done() + resp, err := http.Get("http://ip-api.com/json/") + if err != nil { + fmt.Println("Error calling ip-api.com:", err) + return + } + defer resp.Body.Close() + + var ipAPIResp ipAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&ipAPIResp); err == nil { + mu.Lock() + locations = append(locations, seer.Location{Latitude: ipAPIResp.Lat, Longitude: ipAPIResp.Lon}) + mu.Unlock() + } + }() + + // freegeoip.io + go func() { + defer wg.Done() + resp, err := http.Get("https://freegeoip.app/json/") + if err != nil { + fmt.Println("Error calling freegeoip.app:", err) + return + } + defer resp.Body.Close() + + var freeGeoIPResp freeGeoIPResponse + if err := json.NewDecoder(resp.Body).Decode(&freeGeoIPResp); err == nil { + mu.Lock() + locations = append(locations, seer.Location{Latitude: freeGeoIPResp.Latitude, Longitude: freeGeoIPResp.Longitude}) + mu.Unlock() + } + }() + + wg.Wait() + + switch len(locations) { + case 1: + return &locations[0], nil + case 2: + avg := averageLocations(locations[0], locations[1]) + return &avg, nil + default: + return nil, fmt.Errorf("failed to estimate GPS location") + } +} + +// Converts geographic coordinates to Cartesian (x, y, z). +func toCartesian(lat, long float32) (x, y, z float64) { + latRad := float64(lat) * math.Pi / 180 + longRad := float64(long) * math.Pi / 180 + + x = math.Cos(latRad) * math.Cos(longRad) + y = math.Cos(latRad) * math.Sin(longRad) + z = math.Sin(latRad) + return +} + +// Converts Cartesian coordinates (x, y, z) back to geographic (latitude, longitude). +func toGeographic(x, y, z float64) (lat, long float32) { + lat = float32(math.Atan2(z, math.Sqrt(x*x+y*y)) * 180 / math.Pi) + long = float32(math.Atan2(y, x) * 180 / math.Pi) + return +} + +// Averages two locations more accurately by converting to Cartesian coordinates, averaging, and converting back. +func averageLocations(loc1, loc2 seer.Location) seer.Location { + x1, y1, z1 := toCartesian(loc1.Latitude, loc1.Longitude) + x2, y2, z2 := toCartesian(loc2.Latitude, loc2.Longitude) + + avgX := (x1 + x2) / 2 + avgY := (y1 + y2) / 2 + avgZ := (z1 + z2) / 2 + + avgLat, avgLong := toGeographic(avgX, avgY, avgZ) + return seer.Location{Latitude: avgLat, Longitude: avgLong} +} diff --git a/cli/app/geoip_mock.go b/cli/app/geoip_mock.go new file mode 100644 index 00000000..0cf268b2 --- /dev/null +++ b/cli/app/geoip_mock.go @@ -0,0 +1,14 @@ +//go:build mock + +package app + +import ( + "github.com/taubyte/go-interfaces/services/seer" +) + +func estimateGPSLocation() (*seer.Location, error) { + return &seer.Location{ + Latitude: 32.78306, + Longitude: -96.80667, + }, nil +} diff --git a/cli/app/protect.go b/cli/app/protect.go new file mode 100644 index 00000000..e1417fa2 --- /dev/null +++ b/cli/app/protect.go @@ -0,0 +1,125 @@ +package app + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/crypto/pbkdf2" + "golang.org/x/term" +) + +var testPassword = "" + +func promptPassword(prompt string) (string, error) { + if testPassword != "" { + return testPassword, nil + } + fmt.Print(prompt) + + passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + + fmt.Println() + + return string(passwordBytes), nil +} + +// generates a cryptographic key from a password using PBKDF2. +func deriveKey(password string, salt []byte, keyLen int) []byte { + return pbkdf2.Key([]byte(password), salt, 4096, keyLen, sha256.New) +} + +func encrypt(data []byte, password string) ([]byte, error) { + salt := make([]byte, 8) + _, err := io.ReadFull(rand.Reader, salt) + if err != nil { + return nil, err + } + key := deriveKey(password, salt, 32) // AES-256 + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + blockSize := block.BlockSize() + data = _PKCS7Pad(data, blockSize) + cipherText := make([]byte, blockSize+len(data)) + iv := cipherText[:blockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(cipherText[blockSize:], data) + + return append(salt, cipherText...), nil +} + +func decrypt(data []byte, password string) ([]byte, error) { + salt := data[:8] + data = data[8:] + + key := deriveKey(password, salt, 32) // AES-256 + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + if len(data) < block.BlockSize() { + return nil, errors.New("cipherData too short") + } + + if len(data)%block.BlockSize() != 0 { + return nil, errors.New("cipherData malformed") + } + + iv := data[:block.BlockSize()] + data = data[block.BlockSize():] + + mode := cipher.NewCBCDecrypter(block, iv) + var bdata []byte = make([]byte, len(data)) + mode.CryptBlocks(bdata, data) + bdata, err = _PKCS7Unpad(bdata, block.BlockSize()) + if err != nil { + return nil, err + } + + return bdata, nil +} + +func _PKCS7Pad(message []byte, blockSize int) []byte { + padding := blockSize - len(message)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(message, padText...) +} + +func _PKCS7Unpad(message []byte, blockSize int) ([]byte, error) { + length := len(message) + if length == 0 || length%blockSize != 0 { + return nil, errors.New("invalid padding size") + } + + padLength := int(message[length-1]) + if padLength > blockSize || padLength == 0 { + return nil, errors.New("invalid padding") + } + + for _, val := range message[length-padLength:] { + if int(val) != padLength { + return nil, errors.New("invalid padding") + } + } + + return message[:(length - padLength)], nil +} diff --git a/cli/app/protect_test.go b/cli/app/protect_test.go new file mode 100644 index 00000000..dbd7f7ec --- /dev/null +++ b/cli/app/protect_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "bytes" + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + originalText := "Hello, World!" + password := "strongpassword" + + // Encrypt the original text + encryptedData, err := encrypt([]byte(originalText), password) + if err != nil { + t.Fatalf("Failed to encrypt: %s", err) + } + + // Decrypt the encrypted data + decryptedData, err := decrypt(encryptedData, password) + if err != nil { + t.Fatalf("Failed to decrypt: %s", err) + } + + // Check if decrypted data matches the original text + if !bytes.Equal(decryptedData, []byte(originalText)) { + t.Errorf("Decrypted data does not match original. got: %s, want: %s", decryptedData, originalText) + } +} + +func TestDecryptWithWrongPassword(t *testing.T) { + originalText := "Sensitive data here" + password := "correctpassword" + wrongPassword := "wrongpassword" + + encryptedData, err := encrypt([]byte(originalText), password) + if err != nil { + t.Fatalf("Failed to encrypt: %s", err) + } + + _, err = decrypt(encryptedData, password) + if err != nil { + t.Errorf("decryption failed with %s", err) + } + + _, err = decrypt(encryptedData, wrongPassword) + if err == nil { + t.Errorf("Expected an error when decrypting with wrong password, but decryption succeeded") + } +} + +func TestDecryptModifiedCiphertext(t *testing.T) { + originalText := "Another piece of sensitive data" + password := "anotherstrongpassword" + + encryptedData, err := encrypt([]byte(originalText), password) + if err != nil { + t.Fatalf("Failed to encrypt: %s", err) + } + + // Modify the ciphertext to simulate corruption or tampering + encryptedData[len(encryptedData)-1] ^= 0xff + + _, err = decrypt(encryptedData, password) + if err == nil { + t.Errorf("Expected an error when decrypting modified ciphertext, but decryption succeeded") + } +} diff --git a/config/tau.go b/config/tau.go index 8af0f3be..d699077c 100644 --- a/config/tau.go +++ b/config/tau.go @@ -1,6 +1,8 @@ package config import ( + "time" + seerIface "github.com/taubyte/go-interfaces/services/seer" ) @@ -19,23 +21,36 @@ func (p Ports) ToMap() map[string]int { } type Source struct { - Privatekey string - Swarmkey string - Protocols []string `yaml:",omitempty"` + Privatekey string `yaml:"privatekey"` + Swarmkey string `yaml:"swarmkey"` + Protocols []string `yaml:"protocols,omitempty"` P2PListen []string `yaml:"p2p-listen"` P2PAnnounce []string `yaml:"p2p-announce"` Ports Ports `yaml:"ports"` Location *seerIface.Location `yaml:"location,omitempty"` - Peers []string `yaml:",omitempty"` + Peers []string `yaml:"peers,omitempty"` NetworkFqdn string `yaml:"network-fqdn"` Domains Domains `yaml:"domains"` Plugins } +type BundleOrigin struct { + Shape string `yaml:"shape"` + Host string `yaml:"host"` + Creation time.Time `yaml:"time"` + Version *string `yaml:"version,omitempty"` + Protected bool `yaml:"protected,omitempty"` +} + +type Bundle struct { + Origin BundleOrigin `yaml:"origin"` + Source +} + type Plugin string type Plugins struct { - Plugins []Plugin `yaml:",omitempty"` + Plugins []Plugin `yaml:"plugins,omitempty"` } type Domains struct {