diff --git a/otdfctl/cmd/policy/kasKeys.go b/otdfctl/cmd/policy/kasKeys.go index bdf692d68a..4e12b49787 100644 --- a/otdfctl/cmd/policy/kasKeys.go +++ b/otdfctl/cmd/policy/kasKeys.go @@ -406,6 +406,62 @@ func policyUpdateKasKey(cmd *cobra.Command, args []string) { common.HandleSuccess(cmd, kasKey.GetKey().GetId(), t, kasKey) } +func unsafeUpdateKasKeyMode(modeArg string) (policy.KeyMode, error) { + if modeArg == "" { + return policy.KeyMode_KEY_MODE_UNSPECIFIED, nil + } + + mode, err := modeToEnum(modeArg) + if err != nil { + return policy.KeyMode_KEY_MODE_UNSPECIFIED, err + } + + switch mode { //nolint:exhaustive // only remote and public_key are supported for unsafe update. + case policy.KeyMode_KEY_MODE_REMOTE, policy.KeyMode_KEY_MODE_PUBLIC_KEY_ONLY: + return mode, nil + default: + return policy.KeyMode_KEY_MODE_UNSPECIFIED, fmt.Errorf("mode must be %q or %q", keyModeRemote, keyModePublicKeyOnly) + } +} + +func policyUnsafeUpdateKasKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + + id := c.Flags.GetRequiredID("id") + modeArg := c.Flags.GetOptionalString("mode") + providerConfigID := c.Flags.GetOptionalID("provider-config-id") + force := c.Flags.GetOptionalBool("force") + + mode, err := unsafeUpdateKasKeyMode(modeArg) + if err != nil { + cli.ExitWithError("Invalid key mode", err) + } + + h := common.NewHandler(c) + defer h.Close() + + existingKasKey, err := h.GetKasKey(c.Context(), id, nil) + if err != nil { + cli.ExitWithError("Failed to get kas key", err) + } + + existingKey := existingKasKey.GetKey() + confirmID := fmt.Sprintf("Id: %s\n\tKAS URI: %s\n\tKID: %s", existingKey.GetId(), existingKasKey.GetKasUri(), existingKey.GetKeyId()) + cli.ConfirmAction(cli.ActionUpdateUnsafe, "key", confirmID, force) + + kasKey, err := h.UnsafeUpdateKasKey(c.Context(), id, mode, providerConfigID) + if err != nil { + cli.ExitWithError("Failed to update kas key", err) + } + + rows := getTableRows(kasKey) + if mdRows := getMetadataRows(kasKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, kasKey.GetKey().GetId(), t, kasKey) +} + func policyListKasKeys(cmd *cobra.Command, args []string) { c := cli.New(cmd, args) h := common.NewHandler(c) @@ -1137,7 +1193,30 @@ func initKASKeysCommands() { unsafeDeleteDoc.GetDocFlag("kas-uri").Description, ) - unsafeCmd.AddSubcommands(unsafeDeleteDoc) + unsafeUpdateDoc := man.Docs.GetCommand( + "policy/kas-registry/key/unsafe/update", + man.WithRun(policyUnsafeUpdateKasKey), + ) + unsafeUpdateDoc.Flags().StringP( + unsafeUpdateDoc.GetDocFlag("id").Name, + unsafeUpdateDoc.GetDocFlag("id").Shorthand, + unsafeUpdateDoc.GetDocFlag("id").Default, + unsafeUpdateDoc.GetDocFlag("id").Description, + ) + unsafeUpdateDoc.Flags().StringP( + unsafeUpdateDoc.GetDocFlag("mode").Name, + unsafeUpdateDoc.GetDocFlag("mode").Shorthand, + unsafeUpdateDoc.GetDocFlag("mode").Default, + unsafeUpdateDoc.GetDocFlag("mode").Description, + ) + unsafeUpdateDoc.Flags().StringP( + unsafeUpdateDoc.GetDocFlag("provider-config-id").Name, + unsafeUpdateDoc.GetDocFlag("provider-config-id").Shorthand, + unsafeUpdateDoc.GetDocFlag("provider-config-id").Default, + unsafeUpdateDoc.GetDocFlag("provider-config-id").Description, + ) + + unsafeCmd.AddSubcommands(unsafeDeleteDoc, unsafeUpdateDoc) policyKasRegistryKeysCmd.AddSubcommands(createDoc, getDoc, updateDoc, listDoc, rotateDoc, importDoc, mappingsDoc, unsafeCmd) KasRegistryCmd.AddCommand(&policyKasRegistryKeysCmd.Command) } diff --git a/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md b/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md index 24934697f5..51670f37d2 100644 --- a/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md +++ b/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md @@ -24,5 +24,5 @@ Make sure you know what you are doing. ## Example ```shell -otdfctl policy kas-keys unsafe delete --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --kas-uri https://kas.example.com --key-id "key-1" +otdfctl policy kas-registry key unsafe delete --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --kas-uri https://kas.example.com --key-id "key-1" ``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/unsafe/update.md b/otdfctl/docs/man/policy/kas-registry/key/unsafe/update.md new file mode 100644 index 0000000000..3a909cb95a --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/unsafe/update.md @@ -0,0 +1,45 @@ +--- +title: Unsafely update a key +command: + name: update + flags: + - name: id + shorthand: i + description: System-given ID of the key to update. + required: true + - name: mode + shorthand: m + description: Target key mode. Only "remote" and "public_key" are supported. + - name: provider-config-id + shorthand: p + description: Configuration ID for the key provider. Required when changing to "remote" mode, or when updating only the provider configuration for an existing remote key. +--- + +# Unsafe Update Warning + +Updating a key in place is a dangerous support operation. It can retroactively change decryptability for existing TDFs. + +This command is limited to switching a key between `remote` and `public_key` modes, or updating the provider configuration +for an existing `remote` key. The key ID, KAS URI, and public key are preserved. + +Make sure you know what you are doing. + +## Examples + +Change a key of mode `public_key` key to one of mode `remote`: + +```shell +otdfctl policy kas-registry key unsafe update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --mode remote --provider-config-id 298c9446-ef71-49eb-a6ef-960149095a76 +``` + +Change a `remote` key to `public_key` mode: + +```shell +otdfctl policy kas-registry key unsafe update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --mode public_key +``` + +Update only the provider configuration for an existing remote key: + +```shell +otdfctl policy kas-registry key unsafe update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --provider-config-id 298c9446-ef71-49eb-a6ef-960149095a76 +``` diff --git a/otdfctl/e2e/kas-keys.bats b/otdfctl/e2e/kas-keys.bats index 8b813a4268..899c5e6ab7 100644 --- a/otdfctl/e2e/kas-keys.bats +++ b/otdfctl/e2e/kas-keys.bats @@ -22,11 +22,9 @@ setup_file() { assert_success export KAS_REGISTRY_ID=$(echo "$output" | jq -r '.id') - if [ "$RUN_EXPERIMENTAL_TESTS" == "true" ]; then - run_otdfctl_provider_create --name "test-provider-config-kas-keys" --config '{}' --json - assert_success - export PC_ID=$(echo "$output" | jq -r '.id') - fi + run_otdfctl_provider_create --name "test-provider-config-kas-keys" --manager "fake-manager" --config '{}' --json + assert_success + export PC_ID=$(echo "$output" | jq -r '.id') export WRAPPING_KEY=$(openssl rand -hex 32) # Generate valid public keys and base64 encode (single-line) export PEM_B64_RSA=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') @@ -698,6 +696,167 @@ format_kas_name_as_uri() { assert_equal "$(echo "$output" | jq -r .message)" "Flag '--id' is required" } +@test "kas-keys: unsafe update key mode remote to public_key" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "${PC_ID}" --wrapping-key-id "wrapping-key-remote" --json + assert_success + local original_key_json="$output" + local key_system_id=$(echo "$output" | jq -r .key.id) + local original_stable_key_json=$(echo "$original_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)') + local original_update_time=$(echo "$original_key_json" | jq -r .key.metadata.updated_at) + assert_equal "$(echo "$original_key_json" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$original_key_json" | jq -r .key.provider_config.id)" "${PC_ID}" + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode public_key --json --force + assert_success + local updated_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.provider_config)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$updated_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" + + run_otdfctl_key get --key "${key_system_id}" --json + assert_success + local persisted_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.provider_config)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$persisted_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" +} + +@test "kas-keys: unsafe update public key to remote" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local original_key_json="$output" + local key_system_id=$(echo "$output" | jq -r .key.id) + local original_stable_key_json=$(echo "$original_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)') + local original_update_time=$(echo "$original_key_json" | jq -r .key.metadata.updated_at) + assert_equal "$(echo "$original_key_json" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$original_key_json" | jq -r .key.provider_config)" "null" + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode remote --provider-config-id "${PC_ID}" --json --force + assert_success + local updated_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.provider_config.id)" "${PC_ID}" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$updated_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" + + run_otdfctl_key get --key "${key_system_id}" --json + assert_success + local persisted_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.provider_config.id)" "${PC_ID}" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$persisted_key_json" | jq -S -c 'del(.key.key_mode, .key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" +} + +@test "kas-keys: unsafe update remote key provider config only" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_provider_create --name "test-provider-config-kas-keys-update-${KEY_ID_UNSAFE_UPDATE}" --manager "fake-manager" --config '{}' --json + assert_success + local original_provider_config_id=$(echo "$output" | jq -r '.id') + + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "${original_provider_config_id}" --wrapping-key-id "wrapping-key-remote" --json + assert_success + local original_key_json="$output" + local key_system_id=$(echo "$output" | jq -r .key.id) + local original_stable_key_json=$(echo "$original_key_json" | jq -S -c 'del(.key.provider_config, .key.metadata.updated_at)') + local original_update_time=$(echo "$original_key_json" | jq -r .key.metadata.updated_at) + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.provider_config.id)" "${original_provider_config_id}" + + run_otdfctl_key unsafe update --id "${key_system_id}" --provider-config-id "${PC_ID}" --json --force + assert_success + local updated_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.provider_config.id)" "${PC_ID}" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$updated_key_json" | jq -S -c 'del(.key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" + + run_otdfctl_key get --key "${key_system_id}" --json + assert_success + local persisted_key_json="$output" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.provider_config.id)" "${PC_ID}" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "${original_update_time}" + assert_equal "$(echo "$persisted_key_json" | jq -S -c 'del(.key.provider_config, .key.metadata.updated_at)')" "${original_stable_key_json}" + + delete_provider_config "$original_provider_config_id" +} + +@test "kas-keys: unsafe update key failure - (invalid provider config, not UUID)" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local key_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode remote --provider-config-id "not-a-uuid" --force + assert_failure + assert_output --partial "Optional flag '--provider-config-id' received value 'not-a-uuid' and must be a valid UUID if used" +} + +@test "kas-keys: unsafe update key failure - (missing id)" { + run_otdfctl_key unsafe update --mode public_key --force + assert_failure + assert_output --partial "Flag '--id' is required" +} + +@test "kas-keys: unsafe update key failure - (missing provider configuration)" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "${PC_ID}" --wrapping-key-id "wrapping-key-remote" --json + assert_success + local key_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key unsafe update --id "${key_system_id}" --force + assert_failure + assert_output --partial "Failed to update kas key" + assert_output --partial "provider_config_id is required for requested key mode" +} + +@test "kas-keys: unsafe update key failure - (remote mode missing provider config)" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local key_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode remote --force + assert_failure + assert_output --partial "Failed to update kas key" + assert_output --partial "provider_config_id is required for requested key mode" +} + +@test "kas-keys: unsafe update key failure - (public_key mode with provider config)" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "${PC_ID}" --wrapping-key-id "wrapping-key-remote" --json + assert_success + local key_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode public_key --provider-config-id "${PC_ID}" --force + assert_failure + assert_output --partial "Failed to update kas key" + assert_output --partial "provider_config_id must be empty for requested key mode" +} + +@test "kas-keys: unsafe update key failure - (existing key mode unsupported)" { + KEY_ID_UNSAFE_UPDATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UNSAFE_UPDATE}" --algorithm "rsa:2048" --mode local --wrapping-key-id "wrapping-key-local" --wrapping-key "${WRAPPING_KEY}" --json + assert_success + local key_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key unsafe update --id "${key_system_id}" --mode public_key --force + assert_failure + assert_output --partial "Failed to update kas key" + assert_output --partial "existing key mode cannot be updated" +} + +@test "kas-keys: unsafe update key failure - (unsupported mode)" { + run_otdfctl_key unsafe update --id "ded32e6d-9fec-4a4c-a391-13158c52e5f2" --mode local --force + assert_failure + assert_output --partial "mode must be \"remote\" or \"public_key\"" +} + # LIST Tests @test "kas-keys: list keys (default limit and offset)" { # Create a few keys to ensure there\'s something to list and to check structure diff --git a/otdfctl/go.mod b/otdfctl/go.mod index a20e919732..17d407acd5 100644 --- a/otdfctl/go.mod +++ b/otdfctl/go.mod @@ -19,7 +19,7 @@ require ( github.com/opentdf/platform/lib/identifier v0.4.0 github.com/opentdf/platform/lib/ocrypto v0.14.0 github.com/opentdf/platform/protocol/go v0.39.0 - github.com/opentdf/platform/sdk v0.25.0 + github.com/opentdf/platform/sdk v0.27.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/zitadel/oidc/v3 v3.45.1 diff --git a/otdfctl/go.sum b/otdfctl/go.sum index 1917f313ee..6e8b49d8ce 100644 --- a/otdfctl/go.sum +++ b/otdfctl/go.sum @@ -184,8 +184,8 @@ github.com/opentdf/platform/lib/ocrypto v0.14.0 h1:qAIOFpz72/QDhYC0oLRXgA5uEnyv1 github.com/opentdf/platform/lib/ocrypto v0.14.0/go.mod h1:TLaMvVE1aTqrgW8AZz0ZuxX/ZpI7l5IQOHyX99fdKl0= github.com/opentdf/platform/protocol/go v0.39.0 h1:02a+1yzDp6INhCSRvWpDYe2lvE8DLiIg0Cv89s2T8CE= github.com/opentdf/platform/protocol/go v0.39.0/go.mod h1:6A0vQJ5D4ZTLReWAp8Y/7jTFzlYCmL/IfMJWDHZS6M0= -github.com/opentdf/platform/sdk v0.25.0 h1:SuHHgdFzaklDCx8zP3p9d06tDgeCbCuNhU/cUKjFd6w= -github.com/opentdf/platform/sdk v0.25.0/go.mod h1:kl4a4PoV3YoumaL5LTJzdB+SZINyCo4A9IdnLZN9aM4= +github.com/opentdf/platform/sdk v0.27.0 h1:ZwKzy9sUGHXuQkU9LNovvVP0CdOIGzKX5rRAenLGnkU= +github.com/opentdf/platform/sdk v0.27.0/go.mod h1:terqpXZ8ZuHt5rKFyLcGUPe1V+XkAtlzbdz6//G+gkk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/otdfctl/pkg/handlers/kas-keys.go b/otdfctl/pkg/handlers/kas-keys.go index 0fc38b341e..6fa1f9d8dc 100644 --- a/otdfctl/pkg/handlers/kas-keys.go +++ b/otdfctl/pkg/handlers/kas-keys.go @@ -85,6 +85,19 @@ func (h Handler) UpdateKasKey(ctx context.Context, id string, metadata *common.M return resp.GetKasKey(), nil } +func (h Handler) UnsafeUpdateKasKey(ctx context.Context, id string, mode policy.KeyMode, providerConfigID string) (*policy.KasKey, error) { + resp, err := h.sdk.Unsafe.UnsafeUpdateKey(ctx, &unsafe.UnsafeUpdateKeyRequest{ + Id: id, + TargetKeyMode: mode, + ProviderConfigId: providerConfigID, + }) + if err != nil { + return nil, err + } + + return resp.GetKey(), nil +} + func (h Handler) ListKasKeys( ctx context.Context, limit, offset int32,