From 92b54836dd2c7b62690980ae4e2b5eb4d2f9d5cc Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Wed, 25 Mar 2026 00:10:16 +0100 Subject: [PATCH] feat: add scaleway support --- README.md | 36 ++- docs/architecture.md | 13 +- docs/backends/scaleway-kms.md | 130 +++++++++++ docs/key-management.md | 16 ++ docs/roadmap.md | 22 +- go.mod | 2 + go.sum | 4 + internal/scwkmapi/client.go | 92 ++++++++ keymgmt/scwkm/manager.go | 382 ++++++++++++++++++++++++++++++++ keymgmt/scwkm/manager_test.go | 301 +++++++++++++++++++++++++ recipient/scwkm/scwkm.go | 156 ++++++++++++- recipient/scwkm/scwkm_test.go | 159 ++++++++++++- recipient/stubs_test.go | 5 - resolver/scwkm/resolver.go | 52 +++++ resolver/scwkm/resolver_test.go | 74 +++++++ 15 files changed, 1411 insertions(+), 33 deletions(-) create mode 100644 docs/backends/scaleway-kms.md create mode 100644 internal/scwkmapi/client.go create mode 100644 keymgmt/scwkm/manager.go create mode 100644 keymgmt/scwkm/manager_test.go create mode 100644 resolver/scwkm/resolver.go create mode 100644 resolver/scwkm/resolver_test.go diff --git a/README.md b/README.md index 6f639fb..f042bdf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Enigma is a pure Go library for high-level document and field encryption using m - Rewrap support without content re-encryption when a valid recipient can unwrap the DEK. - Separate compact field/value encryption API. - Local post-quantum recipient implementation using `crypto/mlkem` (ML-KEM-768 default, ML-KEM-1024 optional). -- Cloud provider packages present as explicit capability-aware stubs (no fake crypto behavior). +- Scaleway Key Manager backend for key lifecycle, recipient resolution, and runtime DEK wrap/unwrap using the official Scaleway SDK. +- GCP/AWS/Azure provider packages present as explicit capability-aware stubs (no fake crypto behavior). ## Installation @@ -27,11 +28,14 @@ go get github.com/hyperscale-stack/enigma - `container`: strict parser/serializer for the binary envelope format. - `recipient`: recipient abstractions and capability model. - `recipient/localmlkem`: fully implemented local PQ recipient. -- `recipient/{gcpkms,awskms,azurekv,scwkm}`: explicit cloud stubs for v1. +- `recipient/scwkm`: Scaleway Key Manager runtime recipient (classical cloud wrapping). +- `recipient/{gcpkms,awskms,azurekv}`: explicit cloud stubs for v1. - `keymgmt`: key lifecycle interfaces and domain types. - `keymgmt/localmlkem`: local ML-KEM key manager with filesystem-backed metadata persistence. +- `keymgmt/scwkm`: Scaleway Key Manager lifecycle backend. - `resolver`: recipient resolver interfaces and backend registry. - `resolver/localmlkem`: resolves local key references into runtime recipients. +- `resolver/scwkm`: resolves Scaleway key references into runtime recipients. - `mem`: best-effort memory hygiene helpers. ## Quick Start @@ -102,6 +106,29 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", ) ``` +### Scaleway KMS (classical cloud backend) + +```go +km, _ := keymgmtscwkm.NewManager(keymgmtscwkm.Config{ + Region: "fr-par", + ProjectID: "", +}) + +desc, _ := km.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "org-primary", + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, +}) + +res, _ := resolverscwkm.New(resolverscwkm.Config{ + Region: "fr-par", + ProjectID: "", +}) +runtimeRecipient, _ := res.ResolveRecipient(context.Background(), desc.Reference) +_ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", document.WithRecipient(runtimeRecipient)) +``` + ## Security Properties (Implemented) - Confidentiality and authenticity of encrypted content when recipients and primitives are used correctly. @@ -113,7 +140,8 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", ## Important Limitations - Go memory is not fully controllable; key wiping is best-effort only. -- v1 cloud providers are stubs and return `ErrNotImplemented` for wrapping/unwrapping. +- Scaleway backend is classical cloud wrapping only and does not provide PQ-native guarantees. +- GCP/AWS/Azure backend packages are still stubs and return `ErrNotImplemented` for wrapping/unwrapping. - Key lifecycle mapping (for example one key per tenant or organization) is application-owned. - Recipient metadata (type/key references/capability labels) is inspectable by design and not encrypted. - No signatures in v1 (footer/signature area is an extension point only). @@ -135,6 +163,8 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", The active capability is explicit in recipient descriptors and metadata. +For Scaleway-specific details, see [`docs/backends/scaleway-kms.md`](docs/backends/scaleway-kms.md). + ## Development ```bash diff --git a/docs/architecture.md b/docs/architecture.md index 171d6a1..cac6b19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,14 +7,16 @@ Enigma is structured into five layers: 1. Key lifecycle and resolution layer - `keymgmt`: key lifecycle interfaces and domain types. - `keymgmt/localmlkem`: local ML-KEM key manager implementation. +- `keymgmt/scwkm`: Scaleway Key Manager lifecycle backend. - `resolver`: recipient resolution interfaces and registry. - `resolver/localmlkem`: resolves stored local key references into runtime recipients. +- `resolver/scwkm`: resolves stored Scaleway key references into runtime recipients. - Separates key provisioning from runtime wrapping semantics. 2. Recipient / key wrapping layer - Defines recipient interface. - Wraps and unwraps a random DEK. -- Supports local PQ recipient (ML-KEM) and cloud-provider stubs with explicit capabilities. +- Supports local PQ recipient (ML-KEM), Scaleway KMS classical recipient, and cloud-provider stubs with explicit capabilities. 3. Symmetric encryption layer - Uses one DEK per encrypted object. @@ -57,6 +59,15 @@ Enigma is structured into five layers: - `KeyReference` is stable, serializable metadata that never includes private key material. - Application key ownership mapping (for example per tenant or per organization) is handled by the application, not by Enigma. +### Scaleway Backend Notes + +- Backend ID: `scaleway_kms`. +- Security capability: `cloud-classical`. +- Uses Scaleway Key Manager as root of trust for DEK wrapping and unwrapping. +- Enigma still performs local content encryption (`XChaCha20-Poly1305` or `AES-256-GCM`). +- Wrapped DEKs and encrypted payloads are stored and managed by the application. +- No PQ-native guarantee for this backend. + ### Rotation versus Rewrap - Rotation creates or selects successor keys at lifecycle level. diff --git a/docs/backends/scaleway-kms.md b/docs/backends/scaleway-kms.md new file mode 100644 index 0000000..4404e72 --- /dev/null +++ b/docs/backends/scaleway-kms.md @@ -0,0 +1,130 @@ +# Scaleway Key Manager Backend + +## Scope + +Enigma provides a production-grade Scaleway backend in three separate layers: + +- `keymgmt/scwkm`: key lifecycle (`CreateKey`, `GetKey`, `RotateKey`, `DeleteKey`, `Capabilities`). +- `recipient/scwkm`: runtime DEK wrap/unwrap (`WrapKey`, `UnwrapKey`). +- `resolver/scwkm`: resolve a stored `KeyReference` to a runtime recipient. + +This backend uses the official Scaleway Go SDK: + +- `github.com/scaleway/scaleway-sdk-go` + +## Security Model + +- Scaleway Key Manager is used as a root of trust for envelope encryption key custody. +- Enigma still encrypts document/field plaintext locally with AEAD. +- Enigma wraps and unwraps DEKs through Scaleway Key Manager operations. +- Wrapped DEKs are stored by the application in Enigma containers/value blobs. +- DEKs are not stored by Scaleway Key Manager for the application lifecycle. + +This backend is classical cloud cryptography: + +- `SecurityLevel`: `cloud_classical` +- `SupportsPQNatively`: `false` +- No post-quantum guarantee is provided by this backend. + +## Supported Algorithms + +Current lifecycle/runtime mapping: + +- `aes-256-gcm` -> Scaleway key usage `symmetric_encryption/aes_256_gcm` +- `rsa-oaep-3072-sha256` -> Scaleway key usage `asymmetric_encryption/rsa_oaep_3072_sha256` + +Not supported in this backend: + +- `ml-kem-768` +- `ml-kem-1024` + +Use `localmlkem` backend for local PQ workflows. + +## Configuration + +Shared config shape: + +```go +type Config struct { + Region string + AccessKey string + SecretKey string + APIURL string + ProjectID string +} +``` + +Notes: + +- `Region` is required for deterministic key reference resolution. +- If `AccessKey`/`SecretKey` are omitted, SDK environment/profile resolution is used. +- `APIURL` is optional (useful for controlled environments/tests). +- `ProjectID` is used for key creation context. + +## KeyReference Format + +Scaleway references are serialized as generic Enigma `KeyReference` values: + +- `Backend`: `scaleway_kms` +- `ID`: Scaleway key ID +- `Version`: key rotation count string +- `URI`: `enigma-scwkm://key/?region=&project_id=&version=` + +`KeyReference` never stores credentials or private key material. + +## Usage Pattern + +### 1) Create key and persist reference + +```go +km, _ := keymgmtscwkm.NewManager(keymgmtscwkm.Config{Region: "fr-par", ProjectID: ""}) +desc, _ := km.CreateKey(ctx, keymgmt.CreateKeyRequest{ + Name: "org-a-primary", + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, +}) + +// Store desc.Reference in your application database. +_ = desc.Reference +``` + +### 2) Resolve recipient at runtime + +```go +res, _ := resolverscwkm.New(resolverscwkm.Config{Region: "fr-par", ProjectID: ""}) +runtimeRecipient, _ := res.ResolveRecipient(ctx, storedRef) +``` + +### 3) Encrypt/decrypt with existing document/field APIs + +```go +_ = document.EncryptFile(ctx, "plain.txt", "plain.txt.enc", document.WithRecipient(runtimeRecipient)) +_ = document.DecryptFile(ctx, "plain.txt.enc", "plain.dec.txt", document.WithRecipient(runtimeRecipient)) +``` + +## Rotation vs Rewrap + +- `KeyManager.RotateKey` rotates backend key material/provider version. +- `document.Rewrap` updates recipient entries in existing encrypted containers. + +These are distinct operations and must be orchestrated explicitly by the application. + +## Capability Set + +Scaleway backend reports: + +- `CanCreateKeys = true` +- `CanDeleteKeys = true` +- `CanRotateProviderNative = true` +- `CanExportPublicKey = true` (backend capability) +- `CanResolveRecipient = true` +- `SupportsPQNatively = false` +- `SupportsClassicalWrapping = true` +- `SupportsRewrapWorkflow = true` + +## Current Limitations + +- No PQ-native wrapping. +- Only explicitly mapped algorithms are accepted. +- Live cloud integration tests are optional and not required for standard CI runs. diff --git a/docs/key-management.md b/docs/key-management.md index 4ff2322..3033dfd 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -52,6 +52,22 @@ The local backend is implemented in: - protect the configured root path with strict OS permissions - if host compromise is in scope, local software key storage may be insufficient +## Scaleway Key Manager Backend + +The Scaleway backend is implemented in: +- `keymgmt/scwkm` +- `resolver/scwkm` +- `recipient/scwkm` + +Properties: +- cloud classical security level (`cloud_classical`) +- no native PQ guarantee +- provider-native key rotation support via Key Manager +- runtime recipient resolution from stored `KeyReference` + +Reference documentation: +- `docs/backends/scaleway-kms.md` + ## Rotation and Rewrap Rotation and rewrap are intentionally distinct operations: diff --git a/docs/roadmap.md b/docs/roadmap.md index 03dea49..4562b17 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,10 @@ - Local ML-KEM recipient implementation - Key lifecycle abstraction (`keymgmt`) and recipient resolution abstraction (`resolver`) - Local ML-KEM key manager and resolver implementation +- Scaleway Key Manager backend: + - `keymgmt/scwkm` lifecycle implementation + - `recipient/scwkm` runtime DEK wrap/unwrap + - `resolver/scwkm` key-reference resolution - Chunked document encryption and stream APIs - Rewrap path without content re-encryption - Field encryption compact format @@ -14,23 +18,27 @@ ## Planned Next -1. Provider lifecycle backends -- Add `KeyManager` and `RecipientResolver` implementations for cloud backends. +1. Remaining provider lifecycle backends +- Add `KeyManager` and `RecipientResolver` implementations for GCP/AWS/Azure. - Keep capability reporting explicit for native rotation versus successor workflows. -- Add live integration test matrix behind opt-in configuration. +- Keep unsupported capabilities explicit; no fake cloud behavior. -2. Stronger policy controls +2. Scaleway integration hardening +- Add opt-in live integration tests (credential-gated) for create/get/rotate/delete and wrap/unwrap flows. +- Add operational guidance for key policies and production rollout checks. + +3. Stronger policy controls - Optional stricter profile/policy validation helpers. - Explicit compliance constraints for algorithm and recipient mixes. -3. Signature extension +4. Signature extension - Add optional signed footer extension for authenticity provenance. -4. Advanced rewrap tooling +5. Advanced rewrap tooling - CLI improvements for inspection/rewrap automation. - Batch workflows that combine successor-key rotation and explicit rewrap execution. -5. Hardening and observability +6. Hardening and observability - Additional fuzzing corpora. - Performance profiling on large object streams. - More fault-injection tests. diff --git a/go.mod b/go.mod index a1fe08e..3890cff 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/hyperscale-stack/enigma go 1.25.0 require ( + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.49.0 ) @@ -11,5 +12,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 25e7419..e01f12f 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -10,5 +12,7 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/scwkmapi/client.go b/internal/scwkmapi/client.go new file mode 100644 index 0000000..c1dd1ba --- /dev/null +++ b/internal/scwkmapi/client.go @@ -0,0 +1,92 @@ +package scwkmapi + +import ( + "context" + "fmt" + + keymanager "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +const ( + BackendName = "scaleway_kms" +) + +type Config struct { + Region string + AccessKey string //nolint:gosec // credential identifier used for SDK authentication wiring + SecretKey string //nolint:gosec // credential value passed to SDK, never persisted by this package + APIURL string + ProjectID string +} + +type Client interface { + CreateKey(ctx context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) + GetKey(ctx context.Context, req *keymanager.GetKeyRequest) (*keymanager.Key, error) + RotateKey(ctx context.Context, req *keymanager.RotateKeyRequest) (*keymanager.Key, error) + DeleteKey(ctx context.Context, req *keymanager.DeleteKeyRequest) error + Encrypt(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) + Decrypt(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) +} + +type SDKClient struct { + api *keymanager.API +} + +func New(cfg Config) (*SDKClient, scw.Region, error) { + if cfg.Region == "" { + return nil, "", fmt.Errorf("missing region") + } + region, err := scw.ParseRegion(cfg.Region) + if err != nil { + return nil, "", fmt.Errorf("invalid region %q: %w", cfg.Region, err) + } + if (cfg.AccessKey == "") != (cfg.SecretKey == "") { + return nil, "", fmt.Errorf("both access key and secret key must be set together") + } + + opts := []scw.ClientOption{ + scw.WithDefaultRegion(region), + } + if cfg.ProjectID != "" { + opts = append(opts, scw.WithDefaultProjectID(cfg.ProjectID)) + } + if cfg.APIURL != "" { + opts = append(opts, scw.WithAPIURL(cfg.APIURL)) + } + if cfg.AccessKey != "" { + opts = append(opts, scw.WithAuth(cfg.AccessKey, cfg.SecretKey)) + } else { + opts = append(opts, scw.WithEnv()) + } + + client, err := scw.NewClient(opts...) + if err != nil { + return nil, "", err + } + return &SDKClient{api: keymanager.NewAPI(client)}, region, nil +} + +func (c *SDKClient) CreateKey(ctx context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + return c.api.CreateKey(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) GetKey(ctx context.Context, req *keymanager.GetKeyRequest) (*keymanager.Key, error) { + return c.api.GetKey(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) RotateKey(ctx context.Context, req *keymanager.RotateKeyRequest) (*keymanager.Key, error) { + return c.api.RotateKey(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) DeleteKey(ctx context.Context, req *keymanager.DeleteKeyRequest) error { + return c.api.DeleteKey(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) Encrypt(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) { + return c.api.Encrypt(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) Decrypt(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + return c.api.Decrypt(req, scw.WithContext(ctx)) +} diff --git a/keymgmt/scwkm/manager.go b/keymgmt/scwkm/manager.go new file mode 100644 index 0000000..c7cd6b3 --- /dev/null +++ b/keymgmt/scwkm/manager.go @@ -0,0 +1,382 @@ +package scwkm + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/internal/scwkmapi" + "github.com/hyperscale-stack/enigma/keymgmt" + keymanager "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +const ( + BackendName = scwkmapi.BackendName + referenceScheme = "enigma-scwkm" + referenceHost = "key" + defaultReferenceVersion = "0" + metadataRegion = "region" + metadataProjectID = "project_id" + metadataKeyState = "key_state" + metadataKeyOrigin = "key_origin" + metadataRotationCount = "rotation_count" + metadataKeyName = "key_name" + metadataRequestedPurpose = "requested_purpose" +) + +type Config = scwkmapi.Config + +type Manager struct { + api scwkmapi.Client + defaultRegion scw.Region + defaultProject string +} + +type Reference struct { + KeyID string + Region scw.Region + ProjectID string + Version string + URI string +} + +func NewManager(cfg Config) (*Manager, error) { + client, region, err := scwkmapi.New(cfg) + if err != nil { + return nil, enigma.WrapError("keymgmt/scwkm.NewManager", enigma.ErrInvalidArgument, err) + } + return &Manager{api: client, defaultRegion: region, defaultProject: cfg.ProjectID}, nil +} + +func (m *Manager) CreateKey(ctx context.Context, req keymgmt.CreateKeyRequest) (*keymgmt.KeyDescriptor, error) { + if req.Algorithm == "" { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key algorithm")) + } + if req.Purpose == "" { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key purpose")) + } + if req.Purpose == keymgmt.PurposeKeyEncapsulation { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrUnsupportedCapability, fmt.Errorf("purpose %q requires KEM capabilities not provided by this backend", req.Purpose)) + } + if req.Purpose != keymgmt.PurposeKeyWrapping && req.Purpose != keymgmt.PurposeRecipientDecrypt { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("unsupported key purpose %q", req.Purpose)) + } + if req.Exportable { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrUnsupportedCapability, fmt.Errorf("exportable key material is not supported")) + } + if req.ProtectionLevel != "" && req.ProtectionLevel != keymgmt.ProtectionKMS { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrUnsupportedCapability, fmt.Errorf("protection level %q is not supported by scaleway kms backend", req.ProtectionLevel)) + } + + usage, _, err := usageForAlgorithm(req.Algorithm) + if err != nil { + return nil, err + } + + createReq := &keymanager.CreateKeyRequest{ + Region: m.defaultRegion, + ProjectID: m.defaultProject, + Usage: usage, + } + if req.Name != "" { + name := req.Name + createReq.Name = &name + } + + k, err := m.api.CreateKey(ctx, createReq) + if err != nil { + return nil, mapSDKError("keymgmt/scwkm.CreateKey", enigma.ErrCreateKeyFailed, err) + } + if k == nil { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrCreateKeyFailed, fmt.Errorf("empty key response")) + } + return descriptorFromKey(k, req.Purpose, keymgmt.CloneMap(req.Metadata)) +} + +func (m *Manager) GetKey(ctx context.Context, ref keymgmt.KeyReference) (*keymgmt.KeyDescriptor, error) { + resolved, err := ResolveReference(ref, m.defaultRegion) + if err != nil { + return nil, err + } + k, err := m.api.GetKey(ctx, &keymanager.GetKeyRequest{Region: resolved.Region, KeyID: resolved.KeyID}) + if err != nil { + return nil, mapSDKError("keymgmt/scwkm.GetKey", enigma.ErrKeyNotFound, err) + } + if k == nil { + return nil, enigma.WrapError("keymgmt/scwkm.GetKey", enigma.ErrKeyNotFound, fmt.Errorf("empty key response")) + } + return descriptorFromKey(k, keymgmt.PurposeKeyWrapping, nil) +} + +func (m *Manager) RotateKey(ctx context.Context, ref keymgmt.KeyReference, req keymgmt.RotateKeyRequest) (*keymgmt.KeyDescriptor, error) { + resolved, err := ResolveReference(ref, m.defaultRegion) + if err != nil { + return nil, enigma.WrapError("keymgmt/scwkm.RotateKey", enigma.ErrRotateKeyFailed, err) + } + k, err := m.api.RotateKey(ctx, &keymanager.RotateKeyRequest{Region: resolved.Region, KeyID: resolved.KeyID}) + if err != nil { + return nil, mapSDKError("keymgmt/scwkm.RotateKey", enigma.ErrRotateKeyFailed, err) + } + if k == nil { + return nil, enigma.WrapError("keymgmt/scwkm.RotateKey", enigma.ErrRotateKeyFailed, fmt.Errorf("empty key response")) + } + metadata := map[string]string(nil) + if req.SuccessorName != "" { + metadata = map[string]string{"requested_successor_name": req.SuccessorName} + } + for mk, mv := range req.Metadata { + if metadata == nil { + metadata = make(map[string]string, len(req.Metadata)) + } + metadata[mk] = mv + } + return descriptorFromKey(k, keymgmt.PurposeKeyWrapping, metadata) +} + +func (m *Manager) DeleteKey(ctx context.Context, ref keymgmt.KeyReference) error { + resolved, err := ResolveReference(ref, m.defaultRegion) + if err != nil { + return err + } + if err := m.api.DeleteKey(ctx, &keymanager.DeleteKeyRequest{Region: resolved.Region, KeyID: resolved.KeyID}); err != nil { + return mapSDKError("keymgmt/scwkm.DeleteKey", enigma.ErrDeleteKeyFailed, err) + } + return nil +} + +func (m *Manager) Capabilities(ctx context.Context) keymgmt.CapabilitySet { + _ = ctx + return capabilitySet() +} + +func capabilitySet() keymgmt.CapabilitySet { + return keymgmt.CapabilitySet{ + CanCreateKeys: true, + CanDeleteKeys: true, + CanRotateProviderNative: true, + CanExportPublicKey: true, + CanResolveRecipient: true, + SupportsPQNatively: false, + SupportsClassicalWrapping: true, + SupportsRewrapWorkflow: true, + } +} + +func BuildReference(keyID string, region scw.Region, projectID, version string) keymgmt.KeyReference { + if version == "" { + version = defaultReferenceVersion + } + uri := buildURI(keyID, region, projectID, version) + return keymgmt.KeyReference{ + Backend: BackendName, + URI: uri, + ID: keyID, + Version: version, + } +} + +func ResolveReference(ref keymgmt.KeyReference, fallbackRegion scw.Region) (Reference, error) { + if ref.Backend == "" { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing backend")) + } + if ref.Backend != BackendName { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("backend %q", ref.Backend)) + } + + resolved := Reference{KeyID: ref.ID, Version: ref.Version, URI: ref.URI} + if resolved.URI != "" { + parsed, err := parseReferenceURI(resolved.URI) + if err != nil { + return Reference{}, err + } + if resolved.KeyID == "" { + resolved.KeyID = parsed.KeyID + } + if resolved.Version == "" { + resolved.Version = parsed.Version + } + resolved.ProjectID = parsed.ProjectID + resolved.Region = parsed.Region + } + if resolved.KeyID == "" { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing key id")) + } + if resolved.Region == "" { + if fallbackRegion == "" { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing region")) + } + resolved.Region = fallbackRegion + } + if resolved.Version == "" { + resolved.Version = defaultReferenceVersion + } + if resolved.URI == "" { + resolved.URI = buildURI(resolved.KeyID, resolved.Region, resolved.ProjectID, resolved.Version) + } + return resolved, nil +} + +func parseReferenceURI(raw string) (Reference, error) { + u, err := url.Parse(raw) + if err != nil { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, err) + } + if u.Scheme != referenceScheme { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("unexpected scheme %q", u.Scheme)) + } + if u.Host != "" && u.Host != referenceHost { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("unexpected host %q", u.Host)) + } + keyID := strings.TrimPrefix(u.EscapedPath(), "/") + keyID, err = url.PathUnescape(keyID) + if err != nil { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, err) + } + q := u.Query() + parsed := Reference{ + KeyID: keyID, + Version: q.Get("version"), + ProjectID: q.Get(metadataProjectID), + } + if rawRegion := q.Get(metadataRegion); rawRegion != "" { + region, err := scw.ParseRegion(rawRegion) + if err != nil { + return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("invalid region %q: %w", rawRegion, err)) + } + parsed.Region = region + } + return parsed, nil +} + +func buildURI(keyID string, region scw.Region, projectID, version string) string { + u := &url.URL{Scheme: referenceScheme, Host: referenceHost, Path: "/" + url.PathEscape(keyID)} + q := u.Query() + q.Set(metadataRegion, string(region)) + if projectID != "" { + q.Set(metadataProjectID, projectID) + } + if version != "" { + q.Set("version", version) + } + u.RawQuery = q.Encode() + return u.String() +} + +func descriptorFromKey(k *keymanager.Key, requestedPurpose keymgmt.KeyPurpose, metadata map[string]string) (*keymgmt.KeyDescriptor, error) { + alg, class, err := algorithmAndClassFromUsage(k.Usage) + if err != nil { + return nil, err + } + if requestedPurpose == "" { + requestedPurpose = purposeFromClass(class) + } + + version := strconv.FormatUint(uint64(k.RotationCount), 10) + ref := BuildReference(k.ID, k.Region, k.ProjectID, version) + + merged := map[string]string{ + metadataRegion: string(k.Region), + metadataProjectID: k.ProjectID, + metadataKeyState: string(k.State), + metadataKeyOrigin: string(k.Origin), + metadataRotationCount: version, + } + if k.Name != "" { + merged[metadataKeyName] = k.Name + } + if requestedPurpose != "" { + merged[metadataRequestedPurpose] = string(requestedPurpose) + } + for mk, mv := range metadata { + merged[mk] = mv + } + + return &keymgmt.KeyDescriptor{ + ID: k.ID, + Backend: BackendName, + Class: class, + Purpose: requestedPurpose, + Algorithm: alg, + SecurityLevel: keymgmt.SecurityLevelCloudClassic, + Reference: ref, + Capabilities: capabilitySet(), + Metadata: merged, + }, nil +} + +func purposeFromClass(class keymgmt.KeyClass) keymgmt.KeyPurpose { + switch class { + case keymgmt.KeyClassAsymmetricKEM: + return keymgmt.PurposeKeyEncapsulation + case keymgmt.KeyClassAsymmetricEncryption, keymgmt.KeyClassSymmetricWrapping: + return keymgmt.PurposeKeyWrapping + default: + return keymgmt.PurposeKeyWrapping + } +} + +func usageForAlgorithm(alg keymgmt.KeyAlgorithm) (*keymanager.KeyUsage, keymgmt.KeyClass, error) { + switch alg { + case keymgmt.AlgorithmAES256GCM: + usage := keymanager.KeyAlgorithmSymmetricEncryptionAes256Gcm + return &keymanager.KeyUsage{SymmetricEncryption: &usage}, keymgmt.KeyClassSymmetricWrapping, nil + case keymgmt.AlgorithmRSAOAEP3072SHA256: + usage := keymanager.KeyAlgorithmAsymmetricEncryptionRsaOaep3072Sha256 + return &keymanager.KeyUsage{AsymmetricEncryption: &usage}, keymgmt.KeyClassAsymmetricEncryption, nil + case keymgmt.AlgorithmMLKEM768, keymgmt.AlgorithmMLKEM1024: + return nil, "", enigma.WrapError("keymgmt/scwkm.usageForAlgorithm", enigma.ErrKeyAlgorithmMismatch, fmt.Errorf("algorithm %q requires PQ support not provided by scaleway kms", alg)) + default: + return nil, "", enigma.WrapError("keymgmt/scwkm.usageForAlgorithm", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("algorithm %q", alg)) + } +} + +func algorithmAndClassFromUsage(usage *keymanager.KeyUsage) (keymgmt.KeyAlgorithm, keymgmt.KeyClass, error) { + if usage == nil { + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("missing key usage")) + } + if usage.SymmetricEncryption != nil { + switch *usage.SymmetricEncryption { + case keymanager.KeyAlgorithmSymmetricEncryptionUnknownSymmetricEncryption: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unknown symmetric usage")) + case keymanager.KeyAlgorithmSymmetricEncryptionAes256Gcm: + return keymgmt.AlgorithmAES256GCM, keymgmt.KeyClassSymmetricWrapping, nil + default: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported symmetric usage %q", usage.SymmetricEncryption.String())) + } + } + if usage.AsymmetricEncryption != nil { + switch *usage.AsymmetricEncryption { + case keymanager.KeyAlgorithmAsymmetricEncryptionUnknownAsymmetricEncryption: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unknown asymmetric usage")) + case keymanager.KeyAlgorithmAsymmetricEncryptionRsaOaep2048Sha256: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported asymmetric usage %q", usage.AsymmetricEncryption.String())) + case keymanager.KeyAlgorithmAsymmetricEncryptionRsaOaep3072Sha256: + return keymgmt.AlgorithmRSAOAEP3072SHA256, keymgmt.KeyClassAsymmetricEncryption, nil + case keymanager.KeyAlgorithmAsymmetricEncryptionRsaOaep4096Sha256: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported asymmetric usage %q", usage.AsymmetricEncryption.String())) + default: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported asymmetric usage %q", usage.AsymmetricEncryption.String())) + } + } + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("usage is not supported for envelope wrapping")) +} + +func mapSDKError(op string, kind error, err error) error { + if err == nil { + return nil + } + var notFound *scw.ResourceNotFoundError + if errors.As(err, ¬Found) { + return enigma.WrapError(op, enigma.ErrKeyNotFound, err) + } + var invalid *scw.InvalidArgumentsError + if errors.As(err, &invalid) { + return enigma.WrapError(op, enigma.ErrInvalidArgument, err) + } + return enigma.WrapError(op, kind, err) +} diff --git a/keymgmt/scwkm/manager_test.go b/keymgmt/scwkm/manager_test.go new file mode 100644 index 0000000..f2f5179 --- /dev/null +++ b/keymgmt/scwkm/manager_test.go @@ -0,0 +1,301 @@ +package scwkm + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/internal/scwkmapi" + "github.com/hyperscale-stack/enigma/keymgmt" + keymanager "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/assert" +) + +type mockAPI struct { + createFn func(ctx context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) + getFn func(ctx context.Context, req *keymanager.GetKeyRequest) (*keymanager.Key, error) + rotateFn func(ctx context.Context, req *keymanager.RotateKeyRequest) (*keymanager.Key, error) + delFn func(ctx context.Context, req *keymanager.DeleteKeyRequest) error + encFn func(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) + decFn func(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) +} + +func managerForTest(t *testing.T, api scwkmapi.Client, region, projectID string) *Manager { + t.Helper() + parsedRegion, err := scw.ParseRegion(region) + assert.NoError(t, err) + return &Manager{api: api, defaultRegion: parsedRegion, defaultProject: projectID} +} + +func (m *mockAPI) CreateKey(ctx context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + if m.createFn == nil { + return nil, fmt.Errorf("unexpected CreateKey") + } + return m.createFn(ctx, req) +} + +func (m *mockAPI) GetKey(ctx context.Context, req *keymanager.GetKeyRequest) (*keymanager.Key, error) { + if m.getFn == nil { + return nil, fmt.Errorf("unexpected GetKey") + } + return m.getFn(ctx, req) +} + +func (m *mockAPI) RotateKey(ctx context.Context, req *keymanager.RotateKeyRequest) (*keymanager.Key, error) { + if m.rotateFn == nil { + return nil, fmt.Errorf("unexpected RotateKey") + } + return m.rotateFn(ctx, req) +} + +func (m *mockAPI) DeleteKey(ctx context.Context, req *keymanager.DeleteKeyRequest) error { + if m.delFn == nil { + return fmt.Errorf("unexpected DeleteKey") + } + return m.delFn(ctx, req) +} + +func (m *mockAPI) Encrypt(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) { + if m.encFn == nil { + return nil, fmt.Errorf("unexpected Encrypt") + } + return m.encFn(ctx, req) +} + +func (m *mockAPI) Decrypt(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + if m.decFn == nil { + return nil, fmt.Errorf("unexpected Decrypt") + } + return m.decFn(ctx, req) +} + +func TestBuildAndResolveReferenceRoundTrip(t *testing.T) { + ref := BuildReference("key-1", scw.RegionFrPar, "proj-1", "7") + assert.Equal(t, BackendName, ref.Backend) + assert.NotEmpty(t, ref.URI) + + resolved, err := ResolveReference(ref, "") + assert.NoError(t, err) + assert.Equal(t, "key-1", resolved.KeyID) + assert.Equal(t, scw.RegionFrPar, resolved.Region) + assert.Equal(t, "proj-1", resolved.ProjectID) + assert.Equal(t, "7", resolved.Version) +} + +func TestResolveReferenceValidation(t *testing.T) { + _, err := ResolveReference(keymgmt.KeyReference{}, scw.RegionFrPar) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + _, err = ResolveReference(keymgmt.KeyReference{Backend: "other", ID: "x"}, scw.RegionFrPar) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + _, err = ResolveReference(keymgmt.KeyReference{Backend: BackendName, URI: "not-a-uri"}, scw.RegionFrPar) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + badScheme := keymgmt.KeyReference{Backend: BackendName, URI: "http://key/k1?region=fr-par"} + _, err = ResolveReference(badScheme, scw.RegionFrPar) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + missingRegion := keymgmt.KeyReference{Backend: BackendName, ID: "k1"} + _, err = ResolveReference(missingRegion, "") + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) +} + +func TestNewManagerValidation(t *testing.T) { + _, err := NewManager(Config{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = NewManager(Config{Region: "invalid-region"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = NewManager(Config{Region: "fr-par", AccessKey: "SCWXXXXXXXXXXXXXXXXX"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) +} + +func TestCapabilities(t *testing.T) { + m := managerForTest(t, &mockAPI{}, "fr-par", "") + caps := m.Capabilities(context.Background()) + assert.True(t, caps.CanCreateKeys) + assert.True(t, caps.CanDeleteKeys) + assert.True(t, caps.CanRotateProviderNative) + assert.True(t, caps.CanExportPublicKey) + assert.True(t, caps.CanResolveRecipient) + assert.False(t, caps.SupportsPQNatively) + assert.True(t, caps.SupportsClassicalWrapping) + assert.True(t, caps.SupportsRewrapWorkflow) +} + +func TestCreateGetRotateDeleteSuccess(t *testing.T) { + algSym := keymanager.KeyAlgorithmSymmetricEncryptionAes256Gcm + api := &mockAPI{} + api.createFn = func(_ context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + assert.Equal(t, scw.RegionFrPar, req.Region) + assert.Equal(t, "project-a", req.ProjectID) + assert.NotNil(t, req.Usage) + assert.NotNil(t, req.Usage.SymmetricEncryption) + assert.Equal(t, algSym, *req.Usage.SymmetricEncryption) + if assert.NotNil(t, req.Name) { + assert.Equal(t, "tenant-a", *req.Name) + } + return &keymanager.Key{ + ID: "k-1", + ProjectID: "project-a", + Name: "tenant-a", + Usage: &keymanager.KeyUsage{SymmetricEncryption: &algSym}, + RotationCount: 0, + Region: scw.RegionFrPar, + State: keymanager.KeyStateEnabled, + Origin: keymanager.KeyOriginScalewayKms, + }, nil + } + api.getFn = func(_ context.Context, req *keymanager.GetKeyRequest) (*keymanager.Key, error) { + assert.Equal(t, "k-1", req.KeyID) + assert.Equal(t, scw.RegionFrPar, req.Region) + return &keymanager.Key{ + ID: "k-1", + ProjectID: "project-a", + Usage: &keymanager.KeyUsage{SymmetricEncryption: &algSym}, + RotationCount: 1, + Region: scw.RegionFrPar, + State: keymanager.KeyStateEnabled, + Origin: keymanager.KeyOriginScalewayKms, + }, nil + } + api.rotateFn = func(_ context.Context, req *keymanager.RotateKeyRequest) (*keymanager.Key, error) { + assert.Equal(t, "k-1", req.KeyID) + assert.Equal(t, scw.RegionFrPar, req.Region) + return &keymanager.Key{ + ID: "k-1", + ProjectID: "project-a", + Usage: &keymanager.KeyUsage{SymmetricEncryption: &algSym}, + RotationCount: 2, + Region: scw.RegionFrPar, + State: keymanager.KeyStateEnabled, + Origin: keymanager.KeyOriginScalewayKms, + }, nil + } + api.delFn = func(_ context.Context, req *keymanager.DeleteKeyRequest) error { + assert.Equal(t, "k-1", req.KeyID) + assert.Equal(t, scw.RegionFrPar, req.Region) + return nil + } + + m := managerForTest(t, api, "fr-par", "project-a") + + desc, err := m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "tenant-a", + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, + Metadata: map[string]string{"tenant": "a"}, + }) + assert.NoError(t, err) + assert.Equal(t, "k-1", desc.ID) + assert.Equal(t, BackendName, desc.Backend) + assert.Equal(t, keymgmt.KeyClassSymmetricWrapping, desc.Class) + assert.Equal(t, keymgmt.AlgorithmAES256GCM, desc.Algorithm) + assert.Equal(t, keymgmt.SecurityLevelCloudClassic, desc.SecurityLevel) + assert.Equal(t, "a", desc.Metadata["tenant"]) + assert.Contains(t, desc.Reference.URI, "region=fr-par") + + loaded, err := m.GetKey(context.Background(), desc.Reference) + assert.NoError(t, err) + assert.Equal(t, "1", loaded.Reference.Version) + + rotated, err := m.RotateKey(context.Background(), desc.Reference, keymgmt.RotateKeyRequest{SuccessorName: "ignored-native"}) + assert.NoError(t, err) + assert.Equal(t, "2", rotated.Reference.Version) + + assert.NoError(t, m.DeleteKey(context.Background(), desc.Reference)) +} + +func TestManagerErrorMappingAndUnsupportedRequests(t *testing.T) { + api := &mockAPI{} + api.createFn = func(_ context.Context, _ *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + return nil, &scw.InvalidArgumentsError{Details: []scw.InvalidArgumentsErrorDetail{{ArgumentName: "project_id", Reason: "required"}}} + } + api.getFn = func(_ context.Context, _ *keymanager.GetKeyRequest) (*keymanager.Key, error) { + return nil, &scw.ResourceNotFoundError{Resource: "key", ResourceID: "missing"} + } + + m := managerForTest(t, api, "fr-par", "") + var err error + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = m.GetKey(context.Background(), BuildReference("missing", scw.RegionFrPar, "", "")) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyNotFound)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyEncapsulation, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedCapability)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmMLKEM768, + ProtectionLevel: keymgmt.ProtectionKMS, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyAlgorithmMismatch)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionHSM, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedCapability)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyWrapping, + Algorithm: keymgmt.AlgorithmAES256GCM, + ProtectionLevel: keymgmt.ProtectionKMS, + Exportable: true, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedCapability)) +} + +func TestAlgorithmUsageMapping(t *testing.T) { + usage, class, err := usageForAlgorithm(keymgmt.AlgorithmRSAOAEP3072SHA256) + assert.NoError(t, err) + if assert.NotNil(t, usage.AsymmetricEncryption) { + assert.Equal(t, keymanager.KeyAlgorithmAsymmetricEncryptionRsaOaep3072Sha256, *usage.AsymmetricEncryption) + } + assert.Equal(t, keymgmt.KeyClassAsymmetricEncryption, class) + + alg, outClass, err := algorithmAndClassFromUsage(&keymanager.KeyUsage{AsymmetricEncryption: usage.AsymmetricEncryption}) + assert.NoError(t, err) + assert.Equal(t, keymgmt.AlgorithmRSAOAEP3072SHA256, alg) + assert.Equal(t, keymgmt.KeyClassAsymmetricEncryption, outClass) + + _, _, err = usageForAlgorithm(keymgmt.KeyAlgorithm("unknown")) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) + + _, _, err = algorithmAndClassFromUsage(nil) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) +} diff --git a/recipient/scwkm/scwkm.go b/recipient/scwkm/scwkm.go index ca048dd..699ab27 100644 --- a/recipient/scwkm/scwkm.go +++ b/recipient/scwkm/scwkm.go @@ -2,30 +2,144 @@ package scwkm import ( "context" + "errors" "fmt" "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/internal/scwkmapi" + "github.com/hyperscale-stack/enigma/keymgmt" + keymgmtscwkm "github.com/hyperscale-stack/enigma/keymgmt/scwkm" "github.com/hyperscale-stack/enigma/recipient" + keymanager "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" ) +const ( + WrapAlgorithmEncryptV1 = "scwkm+encrypt-v1" +) + +type Config = scwkmapi.Config + type Recipient struct { - keyRef string + api scwkmapi.Client + region scw.Region + keyID string + keyRef string + projectID string } -func New(keyRef string) *Recipient { - return &Recipient{keyRef: keyRef} +func New(cfg Config, keyID string) (*Recipient, error) { + if keyID == "" { + return nil, enigma.WrapError("recipient/scwkm.New", enigma.ErrInvalidArgument, fmt.Errorf("missing key id")) + } + client, region, err := scwkmapi.New(cfg) + if err != nil { + return nil, enigma.WrapError("recipient/scwkm.New", enigma.ErrInvalidArgument, err) + } + keyRef := keymgmtscwkm.BuildReference(keyID, region, cfg.ProjectID, "").URI + return &Recipient{api: client, region: region, keyID: keyID, keyRef: keyRef, projectID: cfg.ProjectID}, nil +} + +func NewFromReference(cfg Config, ref keymgmt.KeyReference) (*Recipient, error) { + var fallback scw.Region + if cfg.Region != "" { + parsed, err := scw.ParseRegion(cfg.Region) + if err != nil { + return nil, enigma.WrapError("recipient/scwkm.NewFromReference", enigma.ErrInvalidArgument, err) + } + fallback = parsed + } + resolved, err := keymgmtscwkm.ResolveReference(ref, fallback) + if err != nil { + return nil, err + } + if cfg.Region != "" { + configuredRegion, _ := scw.ParseRegion(cfg.Region) + if configuredRegion != resolved.Region { + return nil, enigma.WrapError("recipient/scwkm.NewFromReference", enigma.ErrInvalidKeyReference, fmt.Errorf("reference region %q does not match configured region %q", resolved.Region, configuredRegion)) + } + } + cfg.Region = string(resolved.Region) + if cfg.ProjectID == "" { + cfg.ProjectID = resolved.ProjectID + } + client, region, err := scwkmapi.New(cfg) + if err != nil { + return nil, enigma.WrapError("recipient/scwkm.NewFromReference", enigma.ErrInvalidArgument, err) + } + return &Recipient{api: client, region: region, keyID: resolved.KeyID, keyRef: resolved.URI, projectID: cfg.ProjectID}, nil } func (r *Recipient) WrapKey(ctx context.Context, dek []byte) (*recipient.WrappedKey, error) { - _ = ctx - _ = dek - return nil, enigma.WrapError("scwkm.WrapKey", enigma.ErrNotImplemented, fmt.Errorf("cloud integration is deferred in v1")) + if len(dek) == 0 { + return nil, enigma.WrapError("recipient/scwkm.WrapKey", enigma.ErrInvalidArgument, fmt.Errorf("empty dek")) + } + if r.api == nil { + return nil, enigma.WrapError("recipient/scwkm.WrapKey", enigma.ErrInvalidArgument, fmt.Errorf("nil api client")) + } + if r.keyID == "" { + return nil, enigma.WrapError("recipient/scwkm.WrapKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key id")) + } + + resp, err := r.api.Encrypt(ctx, &keymanager.EncryptRequest{ + Region: r.region, + KeyID: r.keyID, + Plaintext: append([]byte(nil), dek...), + }) + if err != nil { + return nil, mapSDKRecipientError("recipient/scwkm.WrapKey", enigma.ErrWrapFailed, err) + } + if resp == nil { + return nil, enigma.WrapError("recipient/scwkm.WrapKey", enigma.ErrWrapFailed, fmt.Errorf("empty encrypt response")) + } + + return &recipient.WrappedKey{ + RecipientType: recipient.TypeSCWKM, + Capability: recipient.CapabilityCloudClassical, + WrapAlgorithm: WrapAlgorithmEncryptV1, + KeyRef: r.keyRef, + Ciphertext: append([]byte(nil), resp.Ciphertext...), + Metadata: map[string]string{ + "backend": keymgmtscwkm.BackendName, + "region": string(r.region), + "key_id": r.keyID, + "project_id": r.projectID, + }, + }, nil } func (r *Recipient) UnwrapKey(ctx context.Context, wk *recipient.WrappedKey) ([]byte, error) { - _ = ctx - _ = wk - return nil, enigma.WrapError("scwkm.UnwrapKey", enigma.ErrNotImplemented, fmt.Errorf("cloud integration is deferred in v1")) + if wk == nil { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrInvalidArgument, fmt.Errorf("nil wrapped key")) + } + if r.api == nil { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrInvalidArgument, fmt.Errorf("nil api client")) + } + if wk.RecipientType != recipient.TypeSCWKM { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrRecipientNotFound, fmt.Errorf("recipient type %q", wk.RecipientType)) + } + if wk.WrapAlgorithm != WrapAlgorithmEncryptV1 { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("wrap algorithm %q", wk.WrapAlgorithm)) + } + if len(wk.Ciphertext) == 0 { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrInvalidArgument, fmt.Errorf("empty wrapped ciphertext")) + } + if r.keyRef != "" && wk.KeyRef != "" && r.keyRef != wk.KeyRef { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrRecipientNotFound, fmt.Errorf("key ref mismatch")) + } + + resp, err := r.api.Decrypt(ctx, &keymanager.DecryptRequest{ + Region: r.region, + KeyID: r.keyID, + Ciphertext: append([]byte(nil), wk.Ciphertext...), + }) + if err != nil { + return nil, mapSDKRecipientError("recipient/scwkm.UnwrapKey", enigma.ErrUnwrapFailed, err) + } + if resp == nil { + return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrUnwrapFailed, fmt.Errorf("empty decrypt response")) + } + return append([]byte(nil), resp.Plaintext...), nil } func (r *Recipient) Descriptor() recipient.Descriptor { @@ -34,5 +148,29 @@ func (r *Recipient) Descriptor() recipient.Descriptor { Capability: recipient.CapabilityCloudClassical, KeyRef: r.keyRef, RewrapCompatible: true, + Metadata: map[string]string{ + "backend": keymgmtscwkm.BackendName, + "region": string(r.region), + "key_id": r.keyID, + "project_id": r.projectID, + }, + } +} + +func mapSDKRecipientError(op string, kind error, err error) error { + if err == nil { + return nil + } + var notFound *scw.ResourceNotFoundError + if errors.As(err, ¬Found) { + if errors.Is(kind, enigma.ErrUnwrapFailed) { + return enigma.WrapError(op, enigma.ErrRecipientNotFound, err) + } + return enigma.WrapError(op, enigma.ErrWrapFailed, err) + } + var invalid *scw.InvalidArgumentsError + if errors.As(err, &invalid) { + return enigma.WrapError(op, enigma.ErrInvalidArgument, err) } + return enigma.WrapError(op, kind, err) } diff --git a/recipient/scwkm/scwkm_test.go b/recipient/scwkm/scwkm_test.go index 85d81df..fc05005 100644 --- a/recipient/scwkm/scwkm_test.go +++ b/recipient/scwkm/scwkm_test.go @@ -3,25 +3,168 @@ package scwkm import ( "context" "errors" + "fmt" + "strings" "testing" "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/internal/scwkmapi" + "github.com/hyperscale-stack/enigma/keymgmt" + keymgmtscwkm "github.com/hyperscale-stack/enigma/keymgmt/scwkm" + "github.com/hyperscale-stack/enigma/recipient" + keymanager "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" "github.com/stretchr/testify/assert" ) -func TestStubBehaviorAndDescriptor(t *testing.T) { - r := New("fr-par/kms/key") +type mockRuntimeAPI struct { + encryptFn func(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) + decryptFn func(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) +} + +func recipientForTest(t *testing.T, api scwkmapi.Client, region, keyID, keyRef, projectID string) *Recipient { + t.Helper() + parsedRegion, err := scw.ParseRegion(region) + assert.NoError(t, err) + if keyRef == "" { + keyRef = keymgmtscwkm.BuildReference(keyID, parsedRegion, projectID, "").URI + } + return &Recipient{api: api, region: parsedRegion, keyID: keyID, keyRef: keyRef, projectID: projectID} +} + +func (m *mockRuntimeAPI) CreateKey(context.Context, *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + return nil, fmt.Errorf("unexpected CreateKey") +} + +func (m *mockRuntimeAPI) GetKey(context.Context, *keymanager.GetKeyRequest) (*keymanager.Key, error) { + return nil, fmt.Errorf("unexpected GetKey") +} + +func (m *mockRuntimeAPI) RotateKey(context.Context, *keymanager.RotateKeyRequest) (*keymanager.Key, error) { + return nil, fmt.Errorf("unexpected RotateKey") +} + +func (m *mockRuntimeAPI) DeleteKey(context.Context, *keymanager.DeleteKeyRequest) error { + return fmt.Errorf("unexpected DeleteKey") +} + +func (m *mockRuntimeAPI) Encrypt(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) { + if m.encryptFn == nil { + return nil, fmt.Errorf("unexpected Encrypt") + } + return m.encryptFn(ctx, req) +} + +func (m *mockRuntimeAPI) Decrypt(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + if m.decryptFn == nil { + return nil, fmt.Errorf("unexpected Decrypt") + } + return m.decryptFn(ctx, req) +} + +func TestNewAndNewFromReferenceValidation(t *testing.T) { + _, err := New(Config{}, "") + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = New(Config{Region: "invalid"}, "key-1") + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = NewFromReference(Config{Region: "fr-par"}, keymgmt.KeyReference{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + ref := keymgmtscwkm.BuildReference("key-1", scw.RegionNlAms, "", "") + _, err = NewFromReference(Config{Region: "fr-par"}, ref) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) +} + +func TestWrapUnwrapRoundTripWithMockAPI(t *testing.T) { + api := &mockRuntimeAPI{} + api.encryptFn = func(_ context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) { + assert.Equal(t, scw.RegionFrPar, req.Region) + assert.Equal(t, "kms-key-1", req.KeyID) + return &keymanager.EncryptResponse{Ciphertext: append([]byte("ct:"), req.Plaintext...)}, nil + } + api.decryptFn = func(_ context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + assert.Equal(t, scw.RegionFrPar, req.Region) + assert.Equal(t, "kms-key-1", req.KeyID) + if !strings.HasPrefix(string(req.Ciphertext), "ct:") { + return nil, fmt.Errorf("bad ciphertext") + } + pt := []byte(strings.TrimPrefix(string(req.Ciphertext), "ct:")) + return &keymanager.DecryptResponse{Plaintext: pt}, nil + } + + r := recipientForTest(t, api, "fr-par", "kms-key-1", "", "project-a") + + wk, err := r.WrapKey(context.Background(), []byte("dek-32bytes-value-1234567890")) + assert.NoError(t, err) + assert.Equal(t, recipient.TypeSCWKM, wk.RecipientType) + assert.Equal(t, recipient.CapabilityCloudClassical, wk.Capability) + assert.Equal(t, WrapAlgorithmEncryptV1, wk.WrapAlgorithm) + assert.Contains(t, wk.KeyRef, "region=fr-par") + assert.Equal(t, "kms-key-1", wk.Metadata["key_id"]) + + dek, err := r.UnwrapKey(context.Background(), wk) + assert.NoError(t, err) + assert.Equal(t, "dek-32bytes-value-1234567890", string(dek)) + d := r.Descriptor() - assert.Equal(t, "scaleway-km", string(d.Type)) - assert.Equal(t, "cloud-classical", string(d.Capability)) - assert.Equal(t, "fr-par/kms/key", d.KeyRef) + assert.Equal(t, recipient.TypeSCWKM, d.Type) + assert.Equal(t, recipient.CapabilityCloudClassical, d.Capability) assert.True(t, d.RewrapCompatible) + assert.Equal(t, "kms-key-1", d.Metadata["key_id"]) +} + +func TestWrapAndUnwrapErrorPaths(t *testing.T) { + r := recipientForTest(t, &mockRuntimeAPI{}, "fr-par", "kms-key-1", "ref-1", "") + var err error - _, err := r.WrapKey(context.Background(), []byte("dek")) + _, err = r.WrapKey(context.Background(), nil) assert.Error(t, err) - assert.True(t, errors.Is(err, enigma.ErrNotImplemented)) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) _, err = r.UnwrapKey(context.Background(), nil) assert.Error(t, err) - assert.True(t, errors.Is(err, enigma.ErrNotImplemented)) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = r.UnwrapKey(context.Background(), &recipient.WrappedKey{RecipientType: recipient.TypeAWSKMS, WrapAlgorithm: WrapAlgorithmEncryptV1, Ciphertext: []byte("x")}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrRecipientNotFound)) + + _, err = r.UnwrapKey(context.Background(), &recipient.WrappedKey{RecipientType: recipient.TypeSCWKM, WrapAlgorithm: "unknown", Ciphertext: []byte("x")}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) + + _, err = r.UnwrapKey(context.Background(), &recipient.WrappedKey{RecipientType: recipient.TypeSCWKM, WrapAlgorithm: WrapAlgorithmEncryptV1, KeyRef: "different", Ciphertext: []byte("x")}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrRecipientNotFound)) + + _, err = r.UnwrapKey(context.Background(), &recipient.WrappedKey{RecipientType: recipient.TypeSCWKM, WrapAlgorithm: WrapAlgorithmEncryptV1, KeyRef: "ref-1"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) +} + +func TestSDKErrorMapping(t *testing.T) { + api := &mockRuntimeAPI{} + api.encryptFn = func(_ context.Context, _ *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) { + return nil, &scw.InvalidArgumentsError{Details: []scw.InvalidArgumentsErrorDetail{{ArgumentName: "plaintext", Reason: "required"}}} + } + api.decryptFn = func(_ context.Context, _ *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + return nil, &scw.ResourceNotFoundError{Resource: "key", ResourceID: "missing"} + } + + r := recipientForTest(t, api, "fr-par", "kms-key-1", "ref-1", "") + var err error + + _, err = r.WrapKey(context.Background(), []byte("dek")) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = r.UnwrapKey(context.Background(), &recipient.WrappedKey{RecipientType: recipient.TypeSCWKM, WrapAlgorithm: WrapAlgorithmEncryptV1, KeyRef: "ref-1", Ciphertext: []byte("ct")}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrRecipientNotFound)) } diff --git a/recipient/stubs_test.go b/recipient/stubs_test.go index 35e3ddf..e609c57 100644 --- a/recipient/stubs_test.go +++ b/recipient/stubs_test.go @@ -9,14 +9,12 @@ import ( "github.com/hyperscale-stack/enigma/recipient/awskms" "github.com/hyperscale-stack/enigma/recipient/azurekv" "github.com/hyperscale-stack/enigma/recipient/gcpkms" - "github.com/hyperscale-stack/enigma/recipient/scwkm" ) func TestCloudRecipientsAreExplicitStubs(t *testing.T) { gcp := gcpkms.New("projects/p/locations/l/keyRings/r/cryptoKeys/k", gcpkms.ModeClassical) aws := awskms.New("arn:aws:kms:region:acct:key/1") az := azurekv.New("https://vault.vault.azure.net/keys/key") - scw := scwkm.New("fr-par/kms/key") if _, err := gcp.WrapKey(context.Background(), []byte("dek")); !errors.Is(err, enigma.ErrNotImplemented) { t.Fatalf("expected ErrNotImplemented for gcp wrap") @@ -27,7 +25,4 @@ func TestCloudRecipientsAreExplicitStubs(t *testing.T) { if _, err := az.WrapKey(context.Background(), []byte("dek")); !errors.Is(err, enigma.ErrNotImplemented) { t.Fatalf("expected ErrNotImplemented for azure wrap") } - if _, err := scw.WrapKey(context.Background(), []byte("dek")); !errors.Is(err, enigma.ErrNotImplemented) { - t.Fatalf("expected ErrNotImplemented for scw wrap") - } } diff --git a/resolver/scwkm/resolver.go b/resolver/scwkm/resolver.go new file mode 100644 index 0000000..1178065 --- /dev/null +++ b/resolver/scwkm/resolver.go @@ -0,0 +1,52 @@ +package scwkm + +import ( + "context" + "errors" + "fmt" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/hyperscale-stack/enigma/recipient" + recipientscwkm "github.com/hyperscale-stack/enigma/recipient/scwkm" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +type Config = recipientscwkm.Config + +type recipientFactory func(cfg Config, ref keymgmt.KeyReference) (recipient.Recipient, error) + +type Resolver struct { + cfg Config + factory recipientFactory +} + +func New(cfg Config) (*Resolver, error) { + if cfg.Region == "" { + return nil, enigma.WrapError("resolver/scwkm.New", enigma.ErrInvalidArgument, fmt.Errorf("missing region")) + } + if _, err := scw.ParseRegion(cfg.Region); err != nil { + return nil, enigma.WrapError("resolver/scwkm.New", enigma.ErrInvalidArgument, err) + } + if (cfg.AccessKey == "") != (cfg.SecretKey == "") { + return nil, enigma.WrapError("resolver/scwkm.New", enigma.ErrInvalidArgument, fmt.Errorf("both access key and secret key must be set together")) + } + return &Resolver{ + cfg: cfg, + factory: func(cfg Config, ref keymgmt.KeyReference) (recipient.Recipient, error) { + return recipientscwkm.NewFromReference(cfg, ref) + }, + }, nil +} + +func (r *Resolver) ResolveRecipient(ctx context.Context, ref keymgmt.KeyReference) (recipient.Recipient, error) { + _ = ctx + rcp, err := r.factory(r.cfg, ref) + if err == nil { + return rcp, nil + } + if errors.Is(err, enigma.ErrInvalidArgument) || errors.Is(err, enigma.ErrInvalidKeyReference) || errors.Is(err, enigma.ErrKeyNotFound) { + return nil, err + } + return nil, enigma.WrapError("resolver/scwkm.ResolveRecipient", enigma.ErrResolveRecipientFailed, err) +} diff --git a/resolver/scwkm/resolver_test.go b/resolver/scwkm/resolver_test.go new file mode 100644 index 0000000..d9fa090 --- /dev/null +++ b/resolver/scwkm/resolver_test.go @@ -0,0 +1,74 @@ +package scwkm + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/hyperscale-stack/enigma/recipient" + "github.com/stretchr/testify/assert" +) + +type stubRecipient struct{} + +func (stubRecipient) WrapKey(context.Context, []byte) (*recipient.WrappedKey, error) { + return nil, nil +} + +func (stubRecipient) UnwrapKey(context.Context, *recipient.WrappedKey) ([]byte, error) { + return nil, nil +} + +func (stubRecipient) Descriptor() recipient.Descriptor { + return recipient.Descriptor{Type: recipient.TypeSCWKM, Capability: recipient.CapabilityCloudClassical} +} + +func TestNewValidation(t *testing.T) { + _, err := New(Config{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = New(Config{Region: "invalid"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = New(Config{Region: "fr-par", AccessKey: "SCWXXXXXXXXXXXXXXXXX"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) +} + +func TestResolveRecipientBehavior(t *testing.T) { + r, err := New(Config{Region: "fr-par"}) + assert.NoError(t, err) + + r.factory = func(_ Config, _ keymgmt.KeyReference) (recipient.Recipient, error) { + return stubRecipient{}, nil + } + + got, err := r.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: "scaleway_kms", ID: "k1"}) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, recipient.TypeSCWKM, got.Descriptor().Type) +} + +func TestResolveRecipientErrorMapping(t *testing.T) { + r, err := New(Config{Region: "fr-par"}) + assert.NoError(t, err) + + r.factory = func(_ Config, _ keymgmt.KeyReference) (recipient.Recipient, error) { + return nil, enigma.WrapError("factory", enigma.ErrInvalidKeyReference, fmt.Errorf("bad reference")) + } + _, err = r.ResolveRecipient(context.Background(), keymgmt.KeyReference{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + r.factory = func(_ Config, _ keymgmt.KeyReference) (recipient.Recipient, error) { + return nil, fmt.Errorf("unexpected failure") + } + _, err = r.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: "scaleway_kms", ID: "k1"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrResolveRecipientFailed)) +}