diff --git a/.github/scripts/verify-checksums.sh b/.github/scripts/verify-checksums.sh index 8aaedac3..271144b2 100755 --- a/.github/scripts/verify-checksums.sh +++ b/.github/scripts/verify-checksums.sh @@ -2,32 +2,60 @@ # Check if the required arguments are provided if [ $# -ne 2 ]; then - echo "Usage: $0 " + echo "Usage: $0 " exit 1 fi -echo "Verifying checksums..." -# Location of the checksum file -checksumFile=$1/$2 -outputDir=$1 +# Assign arguments to variables +output_dir="$1" +checksum_file="$2" +checksum_path="${output_dir}/${checksum_file}" # Full path to the checksum file +lock_file="${checksum_path}.lock" # Append .lock to the full path of the checksum file + +# Ensure the checksum file exists +if [ ! -f "$checksum_path" ]; then + echo "ERROR: Checksum file $checksum_path does not exist." + exit 1 +fi -echo "Looking for checksum file: $checksumFile" -test -f "$checksumFile" || { echo "ERROR: Checksum file not found!"; exit 1; } +# Wait for the lock file to be available for reading +exec 200<"$lock_file" # Open lock file descriptor for reading +flock -s 200 # Acquire shared lock (will wait if exclusive lock is held) + +echo "Verifying checksums..." +echo "Looking for checksum file: $checksum_path" # Iterate over each line in the checksum file while read -r line; do - # Extract the expected checksum and filename from each line - read -ra ADDR <<< "$line" # Read the line into an array - expectedChecksum="${ADDR[0]}" - fileName="${ADDR[2]}" - - # Calculate the actual checksum of the file - actualChecksum=$(shasum -a 256 "$outputDir/$fileName" | awk '{print $1}') - - # Compare the expected checksum with the actual checksum - if [ "$expectedChecksum" == "$actualChecksum" ]; then - echo "SUCCESS: Checksum for $fileName is valid." - else - echo "ERROR: Checksum for $fileName does not match." - fi -done < "$checksumFile" \ No newline at end of file + # Extract checksum and filename from the line + expected_checksum=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + + # Construct the full path to the file + file_path="$output_dir/$filename" + + # Check if the file exists + if [ ! -f "$file_path" ]; then + echo "ERROR: File $filename not found in $output_dir" + continue + fi + + # Calculate the actual checksum of the file + actual_checksum=$(shasum -a 256 "$file_path" | awk '{print $1}') + + # Compare the expected and actual checksums + if [ "$expected_checksum" != "$actual_checksum" ]; then + echo "ERROR: Checksum for $filename does not match." + else + echo "Checksum for $filename is correct." + fi +done < "$checksum_path" + +# Release the lock and close the lock file descriptor +flock -u 200 +exec 200>&- + +# Clean up the lock file +rm -f "$lock_file" + +echo "Checksum verification completed." diff --git a/.github/scripts/zip-builds.sh b/.github/scripts/zip-builds.sh index 0b0e0e91..4720fc5c 100755 --- a/.github/scripts/zip-builds.sh +++ b/.github/scripts/zip-builds.sh @@ -16,23 +16,41 @@ mkdir -p "$output_dir" # Create a checksums file checksums_file="$output_dir/${build_semver}_checksums.txt" -touch $checksums_file +touch "$checksums_file" + +# Define a lock file for parallel-safe writing to checksums +checksums_lockfile="${checksums_file}.lock" # Iterate over each binary file for binary_file in "$binary_dir"/*; do - compressed="" - if [[ $binary_file == *.exe ]]; then - # If the file is a Windows binary, zip it - filename=$(basename "$binary_file") - compressed="${filename%.exe}.zip" - zip -j "$output_dir/$compressed" "$binary_file" - else - # For other binaries, tar and gzip them - filename=$(basename "$binary_file") - compressed="${filename}.tar.gz" - tar -czf "$output_dir/$compressed" "$binary_file" - fi - - # Append checksums to the file - echo "$(cat "$output_dir/$compressed" | shasum -a 256) $compressed" >> $checksums_file -done \ No newline at end of file + ( + compressed="" + if [[ $binary_file == *.exe ]]; then + # If the file is a Windows binary, zip it + filename=$(basename "$binary_file") + compressed="${filename%.exe}.zip" + zip -j "$output_dir/$compressed" "$binary_file" + else + # For other binaries, tar and gzip them + filename=$(basename "$binary_file") + compressed="${filename}.tar.gz" + tar -czf "$output_dir/$compressed" "$binary_file" + fi + + # Compute checksum and append it to the checksums file using a lock + checksum="$(shasum -a 256 "$output_dir/$compressed" | awk '{print $1}')" + ( + flock -x 200 + echo "$checksum $compressed" >> "$checksums_file" + ) 200>"$checksums_lockfile" + + ) & +done + +# Echo message indicating background tasks are running +echo "All zip and tar processes started. Waiting for them to finish..." + +# Wait for all background processes to complete +wait + +echo "All compression and checksum operations completed." diff --git a/Makefile b/Makefile index d7a511a7..9eb694ae 100644 --- a/Makefile +++ b/Makefile @@ -58,10 +58,10 @@ build-%: go build $(GO_BUILD_FLAGS) \ -o $(GO_BUILD_PREFIX)-$(word 1,$(subst -, ,$*))-$(word 2,$(subst -, ,$*))$(word 3,$(subst -, ,$*)) -zip-builds: +zip-builds: $(addprefix build-,$(PLATFORMS)) ./.github/scripts/zip-builds.sh $(BINARY_NAME)-$(CURR_VERSION) $(TARGET_DIR) $(OUTPUT_DIR) -verify-checksums: +verify-checksums: zip-builds ./.github/scripts/verify-checksums.sh $(OUTPUT_DIR) $(BINARY_NAME)-$(CURR_VERSION)_checksums.txt # Target for running the project (adjust as necessary for your project) @@ -93,3 +93,4 @@ test-bats: build-test .PHONY: clean clean: rm -rf $(TARGET_DIR) + rm -rf $(OUTPUT_DIR) diff --git a/cmd/auth-login.go b/cmd/auth-login.go index 21ca8467..664fe96e 100644 --- a/cmd/auth-login.go +++ b/cmd/auth-login.go @@ -13,11 +13,16 @@ func auth_codeLogin(cmd *cobra.Command, args []string) { _, cp := InitProfile(c, false) c.Print("Initiating login...") + + // Use profile values as defaults, with command-line overrides + tlsNoVerify := c.FlagHelper.GetOptionalBoolWithDefault("tls-no-verify", cp.GetTLSNoVerify()) + clientId := c.FlagHelper.GetOptionalStringWithDefault("client-id", cp.GetAuthCredentials().ClientId) + tok, publicClientID, err := auth.LoginWithPKCE( cmd.Context(), cp.GetEndpoint(), - c.FlagHelper.GetOptionalString("client-id"), - c.FlagHelper.GetOptionalBool("tls-no-verify"), + clientId, + tlsNoVerify, ) if err != nil { c.Println("failed") diff --git a/cmd/root.go b/cmd/root.go index 3ddeff90..9757242c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -279,5 +279,11 @@ func init() { rootCmd.GetDocFlag("with-access-token").Default, rootCmd.GetDocFlag("with-access-token").Description, ) + + RootCmd.PersistentFlags().String( + rootCmd.GetDocFlag("profile-driver").Name, + rootCmd.GetDocFlag("profile-driver").Default, + rootCmd.GetDocFlag("profile-driver").Description, + ) RootCmd.AddGroup(&cobra.Group{ID: TDF}) } diff --git a/docs/man/_index.md b/docs/man/_index.md index b8705f14..1fd5bcee 100644 --- a/docs/man/_index.md +++ b/docs/man/_index.md @@ -39,4 +39,11 @@ command: - name: debug description: enable debug output default: false + - name: profile-driver + description: storage driver for managing profiles + enum: + - keyring + - in-memory + - file + default: file --- diff --git a/e2e/profile.bats b/e2e/profile.bats index f0ed9cb8..5a536a65 100755 --- a/e2e/profile.bats +++ b/e2e/profile.bats @@ -65,10 +65,7 @@ teardown() { @test "profile create" { run_otdfctl profile create test http://localhost:8080 assert_output --regexp "Creating profile .* ok" - - run_otdfctl profile create test localhost:8080 - assert_output --regexp "Failed .* invalid scheme" - + # TODO figure out how to test the case where the profile already exists } diff --git a/go.mod b/go.mod index 5607251b..2362f9b9 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,9 @@ require ( github.com/go-jose/go-jose/v3 v3.0.3 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.6.0 - github.com/opentdf/platform/lib/flattening v0.1.1 - github.com/opentdf/platform/protocol/go v0.2.20 - github.com/opentdf/platform/sdk v0.3.19 + github.com/opentdf/platform/lib/flattening v0.1.2 + github.com/opentdf/platform/protocol/go v0.2.22 + github.com/opentdf/platform/sdk v0.3.20 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 @@ -86,7 +86,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect @@ -109,9 +108,9 @@ require ( go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.25.0 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/net v0.27.0 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/go.sum b/go.sum index 0e380399..4ddb2d18 100644 --- a/go.sum +++ b/go.sum @@ -215,22 +215,16 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/opentdf/platform/lib/fixtures v0.2.7 h1:2LxWmLBBISONVJnVDH8yMsV72VHQyirua0DwDBBoq+g= -github.com/opentdf/platform/lib/fixtures v0.2.7/go.mod h1:8yCSe+oUzW9jbM573r9qgE68rjwDMNzktObiGVsO/W8= -github.com/opentdf/platform/lib/flattening v0.1.1 h1:la1f6PcRsc+yLH8+9UEr0ux6IRKu+6+oMaMVt05+8HU= -github.com/opentdf/platform/lib/flattening v0.1.1/go.mod h1:eyG7pe5UZlV+GI5/CymQD3xTAJxNhnP9M4QnBzaad1M= +github.com/opentdf/platform/lib/fixtures v0.2.8 h1:lGYrMnbORtU62lxsJi8qPsxjFuNIkc4Dop8rVkH6pD0= +github.com/opentdf/platform/lib/fixtures v0.2.8/go.mod h1:8yCSe+oUzW9jbM573r9qgE68rjwDMNzktObiGVsO/W8= +github.com/opentdf/platform/lib/flattening v0.1.2 h1:7/fUlBY08PR6UItfVU2CVF5rcCxf5oZZ4MGLABj4NAU= +github.com/opentdf/platform/lib/flattening v0.1.2/go.mod h1:Gs/T+6FGZKk9OAdz2Jf1R8CTGeNRYrq1lZGDeYT3hrY= github.com/opentdf/platform/lib/ocrypto v0.1.6 h1:rd4ctCZOE/c3qDJORtkSK9tw6dEXb+jbJXRRk4LcxII= github.com/opentdf/platform/lib/ocrypto v0.1.6/go.mod h1:ne+l8Q922OdzA0xesK3XJmfECBnn5vLSGYU3/3OhiHM= -github.com/opentdf/platform/protocol/go v0.2.18 h1:s+TVZkOPGCzy7WyObtJWJNaFeOGDUTuSmAsq3omvugY= -github.com/opentdf/platform/protocol/go v0.2.18/go.mod h1:WqDcnFQJb0v8ivRQPidbehcL8ils5ZSZYXkuv0nyvsI= -github.com/opentdf/platform/protocol/go v0.2.20 h1:FPU1ZcXvPm/QeE2nqgbD/HMTOCICQSD0DoncQbAZ1ws= -github.com/opentdf/platform/protocol/go v0.2.20/go.mod h1:TWIuf387VeR3q0TL4nAMKQTWEqqID+8Yjao76EX9Dto= -github.com/opentdf/platform/sdk v0.3.17 h1:Uo/kTMneB18i0gZNfTRtvw34bGLFUc8BEnA/BMK0VVs= -github.com/opentdf/platform/sdk v0.3.17/go.mod h1:c2+nrsRLvLf2OOryXnNy0iGZN/TScc21Pul7uqKVXIs= -github.com/opentdf/platform/sdk v0.3.18 h1:IY6fNrOfQD9lF/hZp9ewZsH0PMuLe17HlSE1A5kyIWc= -github.com/opentdf/platform/sdk v0.3.18/go.mod h1:u+XZhVRsMq5blukCFCHcjk6HLCp4Y5mmIQu7GhtKQ3E= -github.com/opentdf/platform/sdk v0.3.19 h1:4Ign6HPrxOH6ZllLO/cI6joSuqz8CqPlpxpTKunpMQs= -github.com/opentdf/platform/sdk v0.3.19/go.mod h1:u+XZhVRsMq5blukCFCHcjk6HLCp4Y5mmIQu7GhtKQ3E= +github.com/opentdf/platform/protocol/go v0.2.22 h1:C/jjtwu5yTon8g0ewuN29QE7VXSQHyb2dx9W0U6Oqok= +github.com/opentdf/platform/protocol/go v0.2.22/go.mod h1:skpOCVuWSjUHazLKOkh3nSB057OB4sHICe7MpmJY9KU= +github.com/opentdf/platform/sdk v0.3.20 h1:zyBAZLhQaIv4X2twyPbmbdBd9Vc1vsTwxr1BIuESJWg= +github.com/opentdf/platform/sdk v0.3.20/go.mod h1:O4tyqjK9sJwp+6jUeiJjECe9TQfqaD1kTr6wgsRxkWc= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -338,8 +332,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -351,8 +345,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 84fda51f..bfd78b58 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -214,7 +214,7 @@ const ( // Facilitates an auth code PKCE flow to obtain OIDC tokens. // Spawns a local server to handle the callback and opens a browser window in each respective OS. -func Login(ctx context.Context, platformEndpoint, tokenURL, authURL, publicClientID string) (*oauth2.Token, error) { +func Login(ctx context.Context, platformEndpoint, tokenURL, authURL, publicClientID string, tlsNoVerify bool) (*oauth2.Token, error) { // Generate random hash and encryption keys for cookie handling hashKey := make([]byte, keyLength) encryptKey := make([]byte, keyLength) @@ -239,9 +239,16 @@ func Login(ctx context.Context, platformEndpoint, tokenURL, authURL, publicClien }, } - cookiehandler := httphelper.NewCookieHandler(hashKey, encryptKey) + var cookieOpts []httphelper.CookieHandlerOpt + if tlsNoVerify { + cookieOpts = append(cookieOpts, httphelper.WithUnsecure()) + } + + cookiehandler := httphelper.NewCookieHandler(hashKey, encryptKey, cookieOpts...) relyingParty, err := oidcrp.NewRelyingPartyOAuth(conf, + // respect tlsNoVerify + oidcrp.WithHTTPClient(utils.NewHttpClient(tlsNoVerify)), // allow cookie handling for PKCE oidcrp.WithCookieHandler(cookiehandler), // use PKCE @@ -271,7 +278,7 @@ func LoginWithPKCE(ctx context.Context, host, publicClientID string, tlsNoVerify return nil, "", fmt.Errorf("failed to get platform configuration: %w", err) } - tok, err := Login(ctx, host, pc.tokenEndpoint, pc.authzEndpoint, pc.publicClientID) + tok, err := Login(ctx, host, pc.tokenEndpoint, pc.authzEndpoint, pc.publicClientID, tlsNoVerify) if err != nil { return nil, "", fmt.Errorf("failed to login: %w", err) } diff --git a/pkg/cli/flagValues.go b/pkg/cli/flagValues.go index 3d5c0b9a..aed53b2d 100644 --- a/pkg/cli/flagValues.go +++ b/pkg/cli/flagValues.go @@ -52,11 +52,18 @@ func (f flagHelper) GetOptionalID(idFlag string) string { } func (f flagHelper) GetOptionalString(flag string) string { - p := f.cmd.Flag(flag) - if p == nil { - return "" + return f.GetOptionalStringWithDefault(flag, "") +} + +// GetOptionalStringWithDefault retrieves a string flag, or returns the default value if the flag is not set +func (f flagHelper) GetOptionalStringWithDefault(flagName string, defaultValue string) string { + if f.cmd.Flags().Changed(flagName) { + value, err := f.cmd.Flags().GetString(flagName) + if err == nil { + return value + } } - return p.Value.String() + return defaultValue } func (f flagHelper) GetStringSlice(flag string, v []string, opts FlagsStringSliceOptions) []string { @@ -82,8 +89,18 @@ func (f flagHelper) GetRequiredInt32(flag string) int32 { } func (f flagHelper) GetOptionalBool(flag string) bool { - v, _ := f.cmd.Flags().GetBool(flag) - return v + return f.GetOptionalBoolWithDefault(flag, false) +} + +// GetOptionalBoolWithDefault retrieves a boolean flag, or returns the default value if the flag is not set +func (f flagHelper) GetOptionalBoolWithDefault(flagName string, defaultValue bool) bool { + if f.cmd.Flags().Changed(flagName) { + value, err := f.cmd.Flags().GetBool(flagName) + if err == nil { + return value + } + } + return defaultValue } func (f flagHelper) GetRequiredBool(flag string) bool { diff --git a/pkg/profiles/profile.go b/pkg/profiles/profile.go index d5ff08b4..856ec955 100644 --- a/pkg/profiles/profile.go +++ b/pkg/profiles/profile.go @@ -4,11 +4,13 @@ import ( "errors" ) -// TODO: -// - add a version -// - add a migration path - +// Define constants for the different storage drivers and store keys const ( + PROFILE_DRIVER_KEYRING ProfileDriver = "keyring" + PROFILE_DRIVER_IN_MEMORY ProfileDriver = "in-memory" + PROFILE_DRIVER_FILE ProfileDriver = "file" + PROFILE_DRIVER_DEFAULT = PROFILE_DRIVER_FILE + STORE_KEY_PROFILE = "profile" STORE_KEY_GLOBAL = "global" ) @@ -24,17 +26,7 @@ type Profile struct { currentProfileStore *ProfileStore } -type CurrentProfileStore struct { - StoreInterface - ProfileConfig -} - -const ( - PROFILE_DRIVER_KEYRING ProfileDriver = "keyring" - PROFILE_DRIVER_IN_MEMORY ProfileDriver = "in-memory" - PROFILE_DRIVER_DEFAULT = PROFILE_DRIVER_KEYRING -) - +// Variadic functions to set different storage drivers type ( profileConfigVariadicFunc func(profileConfig) profileConfig ProfileDriver string @@ -54,26 +46,31 @@ func WithKeyringStore() profileConfigVariadicFunc { } } +func WithFileStore() profileConfigVariadicFunc { + return func(c profileConfig) profileConfig { + c.driver = PROFILE_DRIVER_FILE + return c + } +} + +// newStoreFactory returns a storage interface based on the configured driver func newStoreFactory(driver ProfileDriver) NewStoreInterface { switch driver { case PROFILE_DRIVER_KEYRING: return NewKeyringStore case PROFILE_DRIVER_IN_MEMORY: return NewMemoryStore + case PROFILE_DRIVER_FILE: + return NewFileStore default: return nil } } -// create a new profile and load global config +// New creates a new Profile with the specified configuration options func New(opts ...profileConfigVariadicFunc) (*Profile, error) { var err error - newStoreFactory("hello") - if testProfile != nil { - return testProfile, nil - } - config := profileConfig{ driver: PROFILE_DRIVER_DEFAULT, } @@ -81,7 +78,7 @@ func New(opts ...profileConfigVariadicFunc) (*Profile, error) { config = opt(config) } - // check if the store driver is valid + // Validate and initialize the store newStore := newStoreFactory(config.driver) if newStore == nil { return nil, errors.New("invalid store driver") @@ -91,7 +88,7 @@ func New(opts ...profileConfigVariadicFunc) (*Profile, error) { config: config, } - // load global config + // Load global configuration p.globalStore, err = LoadGlobalConfig(newStore) if err != nil { return nil, err @@ -100,14 +97,16 @@ func New(opts ...profileConfigVariadicFunc) (*Profile, error) { return p, nil } +// GetGlobalConfig returns the global configuration func (p *Profile) GetGlobalConfig() *GlobalStore { return p.globalStore } -func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bool, setDefault bool) error { +// AddProfile adds a new profile to the current configuration +func (p *Profile) AddProfile(profileName, endpoint string, tlsNoVerify, setDefault bool) error { var err error - // check if profile already exists + // Check if the profile already exists if p.globalStore.ProfileExists(profileName) { return errors.New("profile already exists") } @@ -121,7 +120,7 @@ func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bo return err } - // add profile to global config + // Add profile to global configuration if err := p.globalStore.AddProfile(profileName); err != nil { return err } @@ -133,80 +132,76 @@ func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bo return nil } +// GetCurrentProfile retrieves the current profile store func (p *Profile) GetCurrentProfile() (*ProfileStore, error) { if p.currentProfileStore == nil { return nil, errors.New("no current profile set") } - return p.currentProfileStore, nil } +// GetProfile retrieves a specified profile func (p *Profile) GetProfile(profileName string) (*ProfileStore, error) { if !p.globalStore.ProfileExists(profileName) { return nil, errors.New("profile does not exist") } - return LoadProfileStore(newStoreFactory(p.config.driver), profileName) } +// ListProfiles returns a list of profile names func (p *Profile) ListProfiles() []string { return p.globalStore.ListProfiles() } +// UseProfile sets the current profile to the specified profile name func (p *Profile) UseProfile(profileName string) (*ProfileStore, error) { var err error - // check if current profile is already set - if p.currentProfileStore != nil { - if p.currentProfileStore.config.Name == profileName { - return p.currentProfileStore, nil - } + // If current profile is already set to this, return it + if p.currentProfileStore != nil && p.currentProfileStore.config.Name == profileName { + return p.currentProfileStore, nil } - // set current profile + // Set current profile p.currentProfileStore, err = p.GetProfile(profileName) return p.currentProfileStore, err } +// UseDefaultProfile sets the current profile to the default profile func (p *Profile) UseDefaultProfile() (*ProfileStore, error) { defaultProfile := p.globalStore.GetDefaultProfile() if defaultProfile == "" { return nil, errors.New("no default profile set") } - return p.UseProfile(defaultProfile) } +// SetDefaultProfile sets a specified profile as the default profile func (p *Profile) SetDefaultProfile(profileName string) error { if !p.globalStore.ProfileExists(profileName) { return errors.New("profile does not exist") } - return p.globalStore.SetDefaultProfile(profileName) } +// DeleteProfile removes a profile from storage func (p *Profile) DeleteProfile(profileName string) error { - // check if profile exists + // Check if the profile exists if !p.globalStore.ProfileExists(profileName) { return errors.New("profile does not exist") } - // get profile + // Retrieve the profile profile, err := LoadProfileStore(newStoreFactory(p.config.driver), profileName) if err != nil { return err } - // remove profile from global config (will error if profile is default) + // Remove profile from global configuration if err := p.globalStore.RemoveProfile(profileName); err != nil { return err } - // delete profile config - err = profile.Delete() - if err != nil { - return err - } - - return nil + // Delete profile configuration + return profile.Delete() } diff --git a/pkg/profiles/profileConfig.go b/pkg/profiles/profileConfig.go index f4946a7a..999356cc 100644 --- a/pkg/profiles/profileConfig.go +++ b/pkg/profiles/profileConfig.go @@ -1,27 +1,40 @@ package profiles import ( + "fmt" + "time" + "github.com/opentdf/otdfctl/pkg/config" "github.com/opentdf/otdfctl/pkg/utils" ) -type ProfileStore struct { - store StoreInterface +// URN-based namespace template without UUID, using only profile name for uniqueness +var URNNamespaceTemplate = fmt.Sprintf("urn:opentdf:%s:profile:v1", config.AppName) // e.g., urn:opentdf:otdfctl:profile:v1: +// ProfileStore manages profile configurations and handles storage +type ProfileStore struct { + store StoreInterface config ProfileConfig } +// ProfileConfig defines the structure of a profile with flexible attributes and timestamps type ProfileConfig struct { - Name string `json:"profile"` - Endpoint string `json:"endpoint"` - TlsNoVerify bool `json:"tlsNoVerify"` - AuthCredentials AuthCredentials `json:"authCredentials"` + Name string `json:"profile"` // Profile name (unique identifier) + Endpoint string `json:"endpoint"` // Profile endpoint + TlsNoVerify bool `json:"tlsNoVerify"` // TLS verification setting + AuthCredentials AuthCredentials `json:"authCredentials"` // Authentication credentials + Attributes map[string]interface{} `json:"attributes"` // Flexible map of additional attributes + CreatedAt time.Time `json:"createdAt"` // Timestamp for profile creation + UpdatedAt time.Time `json:"updatedAt"` // Timestamp for last profile update + Version string `json:"version"` // profile version } +// NewProfileStore creates a new profile store with flexible attributes and timestamps func NewProfileStore(newStore NewStoreInterface, profileName string, endpoint string, tlsNoVerify bool) (*ProfileStore, error) { if err := validateProfileName(profileName); err != nil { return nil, err } + u, err := utils.NormalizeEndpoint(endpoint) if err != nil { return nil, err @@ -33,11 +46,16 @@ func NewProfileStore(newStore NewStoreInterface, profileName string, endpoint st Name: profileName, Endpoint: u.String(), TlsNoVerify: tlsNoVerify, + Attributes: make(map[string]interface{}), // Empty map for flexible attributes + CreatedAt: time.Now().UTC(), // Set creation time + UpdatedAt: time.Now().UTC(), // Set initial update time + Version: URNNamespaceTemplate, // Set profile version to URN-based namespace template }, } return p, nil } +// LoadProfileStore loads an existing profile using its profile name func LoadProfileStore(newStore NewStoreInterface, profileName string) (*ProfileStore, error) { if err := validateProfileName(profileName); err != nil { return nil, err @@ -49,49 +67,67 @@ func LoadProfileStore(newStore NewStoreInterface, profileName string) (*ProfileS return p, p.Get() } +// Get loads the profile configuration into p.config func (p *ProfileStore) Get() error { return p.store.Get(&p.config) } +// Save saves the current profile configuration to storage and updates UpdatedAt timestamp func (p *ProfileStore) Save() error { + p.config.UpdatedAt = time.Now().UTC() return p.store.Set(p.config) } +// Delete removes the profile from storage func (p *ProfileStore) Delete() error { return p.store.Delete() } -// Profile Name +// Generate a unique namespace for a profile using only the profile name +func (p *ProfileConfig) GetNamespace() string { + return URNNamespaceTemplate +} + +// GetProfileName retrieves the profile name func (p *ProfileStore) GetProfileName() string { return p.config.Name } -// Endpoint +// SetAttribute allows adding or updating an attribute in the profile's Attributes map +func (p *ProfileStore) SetAttribute(key string, value interface{}) error { + p.config.Attributes[key] = value + return p.Save() +} + +// GetAttribute retrieves an attribute by key from the profile's Attributes map +func (p *ProfileStore) GetAttribute(key string) (interface{}, bool) { + value, exists := p.config.Attributes[key] + return value, exists +} + +// GetEndpoint retrieves the Endpoint value from ProfileConfig func (p *ProfileStore) GetEndpoint() string { return p.config.Endpoint } +// SetEndpoint updates the Endpoint in ProfileConfig after normalizing it, then saves the profile func (p *ProfileStore) SetEndpoint(endpoint string) error { u, err := utils.NormalizeEndpoint(endpoint) if err != nil { return err } p.config.Endpoint = u.String() - return p.Save() + return p.Save() // Save the updated profile configuration } -// TLS No Verify +// GetTLSNoVerify retrieves the TlsNoVerify setting from ProfileConfig func (p *ProfileStore) GetTLSNoVerify() bool { return p.config.TlsNoVerify } -func (p *ProfileStore) SetTLSNoVerify(tlsNoVerify bool) error { - p.config.TlsNoVerify = tlsNoVerify - return p.Save() -} - // utility functions -func getStoreKey(n string) string { - return STORE_KEY_PROFILE + "-" + n +// getStoreKey generates a unique key for storing the profile using the profile name +func getStoreKey(name string) string { + return fmt.Sprintf("%s-%s", STORE_KEY_PROFILE, name) } diff --git a/pkg/profiles/storeFile.go b/pkg/profiles/storeFile.go new file mode 100644 index 00000000..5f5f8a53 --- /dev/null +++ b/pkg/profiles/storeFile.go @@ -0,0 +1,258 @@ +package profiles + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/zalando/go-keyring" +) + +type FileStore struct { + namespace string + key string + filePath string +} + +// Metadata structure for unencrypted metadata about the encrypted file +type FileMetadata struct { + ProfileName string `json:"profile_name"` + CreatedAt string `json:"created_at"` + EncryptionAlg string `json:"encryption_alg"` + Version string `json:"version"` +} +const ( + aes256KeyLength = 32 + ownerPermissionsRW = 0o600 + ownerPermissionsRWX = 0o700 +) + +// Generates a safe, hashed filename from namespace and key +func hashNamespaceAndKey(namespace string, key string) string { + hash := sha256.Sum256([]byte(namespace + ":" + key)) + return hex.EncodeToString(hash[:]) +} + +// NewFileStore is the constructor function for FileStore, setting the file path based on executable directory or environment variable and hashed filename +var NewFileStore NewStoreInterface = func(namespace string, key string) StoreInterface { + // Check for OTDFCTL_PROFILE_PATH environment variable + baseDir := os.Getenv("OTDFCTL_PROFILE_PATH") + if baseDir == "" { + // If environment variable is not set, use the "profiles" directory relative to the executable + execPath, err := os.Executable() + if err != nil { + panic("unable to determine the executable path for profile storage") + } + execDir := filepath.Dir(execPath) + baseDir = filepath.Join(execDir, "profiles") + } + + // Ensure the base directory exists with owner-only access including execute + if err := os.MkdirAll(baseDir, ownerPermissionsRWX); err != nil { + panic(fmt.Sprintf("failed to create profiles directory %s: please check directory permissions", baseDir)) + } + + // Check for read/write permissions by creating and removing a temp file + testFilePath := filepath.Join(baseDir, ".tmp_profile_rw_test") + testFile, err := os.Create(testFilePath) + if err != nil { + panic(fmt.Sprintf("unable to write to profiles directory %s: please ensure write permissions are granted", baseDir)) + } + testFile.Close() + if err := os.Remove(testFilePath); err != nil { + panic(fmt.Sprintf("unable to delete temp file in profiles directory %s: please ensure delete permissions are granted", baseDir)) + } + + // Generate the filename hashed for uniqueness + // Note: other stores use the config.AppName, but want to rely on something more resilient like the namespace + fileName := hashNamespaceAndKey(URNNamespaceTemplate, key) + filePath := filepath.Join(baseDir, fileName+".enc") + + return &FileStore{ + namespace: namespace, + key: key, + filePath: filePath, + } +} + +// Exists checks if the encrypted file exists +func (f *FileStore) Exists() bool { + _, err := os.Stat(f.filePath) + return err == nil +} + +// Get retrieves and decrypts data from the file +func (f *FileStore) Get(value interface{}) error { + key, err := f.getEncryptionKey() + if err != nil { + return err + } + + encryptedData, err := os.ReadFile(f.filePath) + if err != nil { + return err + } + + data, err := decryptData(key, encryptedData) + if err != nil { + return err + } + + return json.NewDecoder(bytes.NewReader(data)).Decode(value) +} + +// Set encrypts and saves data to the file, also saving metadata +func (f *FileStore) Set(value interface{}) error { + key, err := f.getEncryptionKey() + if err != nil { + return err + } + + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(value); err != nil { + return err + } + + encryptedData, err := encryptData(key, b.Bytes()) + if err != nil { + return err + } + + // Write the encrypted profile file with proper permissions + if err := os.WriteFile(f.filePath, encryptedData, ownerPermissionsRW); err != nil { + return fmt.Errorf("failed to write encrypted profile to %s: %w", f.filePath, err) + } + + // Save metadata as well + profileName := f.key // or extract from value if it's part of a ProfileConfig struct + return f.SaveMetadata(profileName) +} + +// Delete removes the encrypted file and metadata file from disk +func (f *FileStore) Delete() error { + if err := os.Remove(f.filePath); err != nil { + return err + } + + // Remove the extension from filePath and add .nfo for the metadata file + metadataFilePath := strings.TrimSuffix(f.filePath, filepath.Ext(f.filePath)) + ".nfo" + return os.Remove(metadataFilePath) +} + +// getEncryptionKey retrieves the encryption key from the keyring or generates it if absent +func (f *FileStore) getEncryptionKey() ([]byte, error) { + + // Try retrieving the key as a string from the keyring + keyStr, err := keyring.Get(URNNamespaceTemplate, f.key) + if errors.Is(err, keyring.ErrNotFound) { + // Generate a new key if not found + key := make([]byte, aes256KeyLength) + if _, err := rand.Read(key); err != nil { + return nil, err + } + + // Convert key to string for storage in the keyring + if err := keyring.Set(URNNamespaceTemplate, f.key, string(key)); err != nil { + return nil, err + } + + return key, nil + } else if err != nil { + return nil, err + } + + // Convert the stored string key back to []byte for use + return []byte(keyStr), nil +} + +// encryptData encrypts data using AES-GCM +func encryptData(key, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + // Encrypt the data with a separate destination buffer + ciphertext := aesGCM.Seal(nil, nonce, data, nil) + + // Prepend the nonce to the ciphertext + result := make([]byte, len(nonce)+len(ciphertext)) + copy(result, nonce) + copy(result[len(nonce):], ciphertext) + return result, nil +} + +// decryptData decrypts data using AES-GCM +func decryptData(key, encryptedData []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := aesGCM.NonceSize() + if len(encryptedData) < nonceSize { + return nil, errors.New("invalid encrypted data") + } + + nonce, ciphertext := encryptedData[:nonceSize], encryptedData[nonceSize:] + return aesGCM.Open(nil, nonce, ciphertext, nil) +} + +// SaveMetadata writes unencrypted metadata to a .nfo file +func (f *FileStore) SaveMetadata(profileName string) error { + metadata := FileMetadata{ + ProfileName: profileName, + CreatedAt: time.Now().Format(time.RFC3339), + EncryptionAlg: "AES-256-GCM", + Version: URNNamespaceTemplate, + } + + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + + metadataFilePath := strings.TrimSuffix(f.filePath, filepath.Ext(f.filePath)) + ".nfo" + return os.WriteFile(metadataFilePath, data, ownerPermissionsRW) +} + +// LoadMetadata loads and parses metadata from a .nfo file +func (f *FileStore) LoadMetadata() (*FileMetadata, error) { + metadataFilePath := strings.TrimSuffix(f.filePath, filepath.Ext(f.filePath)) + ".nfo" + data, err := os.ReadFile(metadataFilePath) + if err != nil { + return nil, err + } + + var metadata FileMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, err + } + + return &metadata, nil +} diff --git a/pkg/utils/validators.go b/pkg/utils/validators.go index 8569aaf9..c0b87c89 100644 --- a/pkg/utils/validators.go +++ b/pkg/utils/validators.go @@ -6,28 +6,39 @@ import ( "strings" ) +// NormalizeEndpoint validates, defaults, and standardizes an endpoint URL by setting default scheme/port and removing trailing slashes. func NormalizeEndpoint(endpoint string) (*url.URL, error) { if endpoint == "" { return nil, errors.New("endpoint is required") } + + // Add default scheme if missing + if !strings.Contains(endpoint, "://") { + endpoint = "https://" + endpoint + } + + // Parse the URL u, err := url.Parse(endpoint) if err != nil { return nil, err } + + // Validate scheme and set default ports for http/https switch u.Scheme { case "http": if u.Port() == "" { - u.Host += ":80" + u.Host = u.Hostname() + ":80" } case "https": if u.Port() == "" { - u.Host += ":443" + u.Host = u.Hostname() + ":443" } default: - return nil, errors.New("invalid scheme") - } - for strings.HasSuffix(u.Path, "/") { - u.Path = strings.TrimSuffix(u.Path, "/") + return nil, errors.New("invalid scheme: only http and https are supported") } + + // Trim trailing slashes from path + u.Path = strings.TrimRight(u.Path, "/") + return u, nil }