diff --git a/README.md b/README.md index f042bdf..06a9ec1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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). -- Scaleway Key Manager backend for key lifecycle, recipient resolution, and runtime DEK wrap/unwrap using the official Scaleway SDK. +- Scaleway Key Manager backend for key lifecycle, recipient resolution, and runtime DEK wrap/unwrap using the official Scaleway SDK, with native ML-KEM wrapping (ML-KEM-1024 default). - GCP/AWS/Azure provider packages present as explicit capability-aware stubs (no fake crypto behavior). ## Installation @@ -28,7 +28,7 @@ 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/scwkm`: Scaleway Key Manager runtime recipient (classical cloud wrapping). +- `recipient/scwkm`: Scaleway Key Manager runtime recipient (native ML-KEM wrapping, classical wrapping still supported). - `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. @@ -106,7 +106,7 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", ) ``` -### Scaleway KMS (classical cloud backend) +### Scaleway KMS (native post-quantum backend) ```go km, _ := keymgmtscwkm.NewManager(keymgmtscwkm.Config{ @@ -117,8 +117,8 @@ km, _ := keymgmtscwkm.NewManager(keymgmtscwkm.Config{ desc, _ := km.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ Name: "org-primary", Purpose: keymgmt.PurposeKeyWrapping, - Algorithm: keymgmt.AlgorithmAES256GCM, ProtectionLevel: keymgmt.ProtectionKMS, + // Algorithm omitted: defaults to keymgmt.AlgorithmMLKEM1024 (ML-KEM-1024). }) res, _ := resolverscwkm.New(resolverscwkm.Config{ @@ -126,7 +126,12 @@ res, _ := resolverscwkm.New(resolverscwkm.Config{ ProjectID: "", }) runtimeRecipient, _ := res.ResolveRecipient(context.Background(), desc.Reference) -_ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", document.WithRecipient(runtimeRecipient)) + +// ML-KEM keys held by Scaleway are `cloud-pq-native`, not `local-pq`. +_ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", + document.WithRecipient(runtimeRecipient), + document.WithDefaultProfile(enigma.ProfileCloudBalanced), +) ``` ## Security Properties (Implemented) @@ -140,7 +145,7 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", doc ## Important Limitations - Go memory is not fully controllable; key wiping is best-effort only. -- Scaleway backend is classical cloud wrapping only and does not provide PQ-native guarantees. +- Scaleway PQ-native wrapping keeps the ML-KEM private key inside Key Manager; use `localmlkem` when the key must stay on the host. - 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. @@ -158,8 +163,8 @@ _ = document.EncryptFile(context.Background(), "plain.txt", "plain.txt.enc", doc ## Capability Model - `local-pq`: local ML-KEM recipient. -- `cloud-classical`: cloud-backed classical wrapping path. -- `cloud-pq-native`: cloud-backed native PQ path. +- `cloud-classical`: cloud-backed classical wrapping path (Scaleway AES/RSA keys). +- `cloud-pq-native`: cloud-backed native PQ path (Scaleway ML-KEM keys). The active capability is explicit in recipient descriptors and metadata. diff --git a/docs/architecture.md b/docs/architecture.md index cac6b19..30121dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ Enigma is structured into five layers: 2. Recipient / key wrapping layer - Defines recipient interface. - Wraps and unwraps a random DEK. -- Supports local PQ recipient (ML-KEM), Scaleway KMS classical recipient, and cloud-provider stubs with explicit capabilities. +- Supports local PQ recipient (ML-KEM), Scaleway KMS recipient (native ML-KEM or classical wrapping), and cloud-provider stubs with explicit capabilities. 3. Symmetric encryption layer - Uses one DEK per encrypted object. @@ -62,11 +62,11 @@ Enigma is structured into five layers: ### Scaleway Backend Notes - Backend ID: `scaleway_kms`. -- Security capability: `cloud-classical`. +- Security capability: `cloud-pq-native` for ML-KEM keys, `cloud-classical` for AES/RSA keys. - 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. +- ML-KEM keys are wrapped natively by Key Manager; the private key never leaves Scaleway. ### Rotation versus Rewrap diff --git a/docs/backends/scaleway-kms.md b/docs/backends/scaleway-kms.md index 4404e72..2238edf 100644 --- a/docs/backends/scaleway-kms.md +++ b/docs/backends/scaleway-kms.md @@ -12,6 +12,10 @@ This backend uses the official Scaleway Go SDK: - `github.com/scaleway/scaleway-sdk-go` +ML-KEM support (`usage.key_encapsulation` plus the `WrapKey`/`UnwrapKey` endpoints) landed on +the SDK main branch after `v1.0.0-beta.37` was tagged, so `go.mod` currently pins a +pseudo-version of that branch. Switch back to a released tag once `v1.0.0-beta.38` ships. + ## Security Model - Scaleway Key Manager is used as a root of trust for envelope encryption key custody. @@ -20,25 +24,53 @@ This backend uses the official Scaleway Go SDK: - 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: +The delivered guarantee depends on the key algorithm, and is reported per key rather than +assumed for the whole backend: + +- ML-KEM keys: `SecurityLevel` = `cloud_pq_native`, recipient capability `cloud-pq-native`. +- AES/RSA keys: `SecurityLevel` = `cloud_classical`, recipient capability `cloud-classical`. -- `SecurityLevel`: `cloud_classical` -- `SupportsPQNatively`: `false` -- No post-quantum guarantee is provided by this backend. +Post-quantum wrapping is performed by Scaleway Key Manager itself. Enigma never sees the +ML-KEM private key material. ## 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` +| Enigma algorithm | Scaleway key usage | Runtime endpoints | Security level | +|---|---|---|---| +| `ml-kem-1024` (**default**) | `key_encapsulation/ml_kem_1024` | `WrapKey` / `UnwrapKey` | `cloud_pq_native` | +| `ml-kem-768` | `key_encapsulation/ml_kem_768` | `WrapKey` / `UnwrapKey` | `cloud_pq_native` | +| `aes-256-gcm` | `symmetric_encryption/aes_256_gcm` | `Encrypt` / `Decrypt` | `cloud_classical` | +| `rsa-oaep-3072-sha256` | `asymmetric_encryption/rsa_oaep_3072_sha256` | `Encrypt` / `Decrypt` | `cloud_classical` | + +`CreateKey` defaults to `ml-kem-1024` when `CreateKeyRequest.Algorithm` is empty +(`scwkm.DefaultAlgorithm`). Any other algorithm is rejected with `ErrUnsupportedAlgorithm`. + +`PurposeKeyEncapsulation` is accepted only for ML-KEM algorithms; combining it with a +classical algorithm returns `ErrUnsupportedCapability`. + +Scaleway caps the wrap endpoint at 2 KB of plaintext and the unwrap endpoint at 4 KB of +ciphertext. Enigma DEKs are 32 bytes, so these limits are never reached in practice. -Not supported in this backend: +Use the `localmlkem` backend when the PQ private key must stay local instead of in the KMS. -- `ml-kem-768` -- `ml-kem-1024` +## Wrap Algorithm Identifiers -Use `localmlkem` backend for local PQ workflows. +The identifier stored in each container recipient entry selects the unwrap path, which keeps +existing containers readable after this backend gained ML-KEM support: + +- `scwkm+encrypt-v1` -> classical `Encrypt`/`Decrypt` +- `scwkm+mlkem-768-wrap-v1` -> native `WrapKey`/`UnwrapKey` +- `scwkm+mlkem-1024-wrap-v1` -> native `WrapKey`/`UnwrapKey` + +## Profile Interaction + +The `local-pq` profile (the default in `document` and `field`) requires `local-pq` recipients +and rejects `cloud-pq-native` ones: the profile asserts that the private key never leaves the +host, which a KMS-held ML-KEM key does not satisfy. + +Use `WithDefaultProfile(enigma.ProfileCloudBalanced)` with Scaleway ML-KEM recipients. ## Configuration @@ -68,7 +100,12 @@ 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=` +- `URI`: `enigma-scwkm://key/?alg=®ion=&project_id=&version=` + +The `alg` parameter lets a resolved recipient pick the wrapping path without an extra +Key Manager round trip. References produced before this parameter existed stay valid and +resolve to the classical path. Persist the `KeyReference` returned by the manager rather than +rebuilding it by hand, so the algorithm stays attached to the key. `KeyReference` never stores credentials or private key material. @@ -81,8 +118,8 @@ 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, + // Algorithm omitted: defaults to keymgmt.AlgorithmMLKEM1024. }) // Store desc.Reference in your application database. @@ -99,7 +136,10 @@ 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.EncryptFile(ctx, "plain.txt", "plain.txt.enc", + document.WithRecipient(runtimeRecipient), + document.WithDefaultProfile(enigma.ProfileCloudBalanced), +) _ = document.DecryptFile(ctx, "plain.txt.enc", "plain.dec.txt", document.WithRecipient(runtimeRecipient)) ``` @@ -119,12 +159,14 @@ Scaleway backend reports: - `CanRotateProviderNative = true` - `CanExportPublicKey = true` (backend capability) - `CanResolveRecipient = true` -- `SupportsPQNatively = false` +- `SupportsPQNatively = true` - `SupportsClassicalWrapping = true` - `SupportsRewrapWorkflow = true` ## Current Limitations -- No PQ-native wrapping. +- PQ-native wrapping relies on Scaleway holding the ML-KEM private key; use `localmlkem` when + the key must stay on the host. - Only explicitly mapped algorithms are accepted. +- ML-KEM support requires an SDK build newer than `v1.0.0-beta.37`. - 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 3033dfd..3bb44c7 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -60,8 +60,8 @@ The Scaleway backend is implemented in: - `recipient/scwkm` Properties: -- cloud classical security level (`cloud_classical`) -- no native PQ guarantee +- native PQ security level (`cloud_pq_native`) for ML-KEM keys, `cloud_classical` for AES/RSA keys +- `ml-kem-1024` is the default algorithm when a create request omits one - provider-native key rotation support via Key Manager - runtime recipient resolution from stored `KeyReference` diff --git a/docs/roadmap.md b/docs/roadmap.md index 4562b17..27b9adb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,6 +10,7 @@ - `keymgmt/scwkm` lifecycle implementation - `recipient/scwkm` runtime DEK wrap/unwrap - `resolver/scwkm` key-reference resolution + - native ML-KEM wrapping (`ml-kem-1024` default, `ml-kem-768` supported) - Chunked document encryption and stream APIs - Rewrap path without content re-encryption - Field encryption compact format @@ -24,6 +25,7 @@ - Keep unsupported capabilities explicit; no fake cloud behavior. 2. Scaleway integration hardening +- Repin `scaleway-sdk-go` on a released tag once ML-KEM ships in `v1.0.0-beta.38`. - 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. diff --git a/go.mod b/go.mod index cc5b4b3..e8ab3bd 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/hyperscale-stack/enigma go 1.26.0 require ( - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37.0.20260908130414-d3305e10fca3 github.com/stretchr/testify v1.12.1 golang.org/x/crypto v0.56.0 ) diff --git a/go.sum b/go.sum index 7bab777..569a632 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37 h1:1Q6K8D0BagYYEnCTkT9fn3YHUFb06bS1OvIHWcc3JQM= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37/go.mod h1:Rtb4r3WZ5x4AqmL3t/wiF/DmQi+7GlU/nCRdqFbClV4= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37.0.20260908130414-d3305e10fca3 h1:SOYidC/j3uqsSzfTQ7Y86pXqbl2E3sjGrD8V9mQs1Sg= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37.0.20260908130414-d3305e10fca3/go.mod h1:4Py4dEgJWqoPEn06mi/00bJn/XxY3B+GnA8MbPBV3p8= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= diff --git a/internal/scwkmapi/client.go b/internal/scwkmapi/client.go index c1dd1ba..6679de3 100644 --- a/internal/scwkmapi/client.go +++ b/internal/scwkmapi/client.go @@ -27,6 +27,8 @@ type Client interface { 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) + WrapKey(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) + UnwrapKey(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) } type SDKClient struct { @@ -90,3 +92,11 @@ func (c *SDKClient) Encrypt(ctx context.Context, req *keymanager.EncryptRequest) func (c *SDKClient) Decrypt(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { return c.api.Decrypt(req, scw.WithContext(ctx)) } + +func (c *SDKClient) WrapKey(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + return c.api.WrapKey(req, scw.WithContext(ctx)) +} + +func (c *SDKClient) UnwrapKey(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + return c.api.UnwrapKey(req, scw.WithContext(ctx)) +} diff --git a/keymgmt/scwkm/manager.go b/keymgmt/scwkm/manager.go index c7cd6b3..9c8e5b8 100644 --- a/keymgmt/scwkm/manager.go +++ b/keymgmt/scwkm/manager.go @@ -27,8 +27,14 @@ const ( metadataRotationCount = "rotation_count" metadataKeyName = "key_name" metadataRequestedPurpose = "requested_purpose" + referenceParamAlgorithm = "alg" ) +// DefaultAlgorithm is used by CreateKey when the request does not pin an algorithm. +// Scaleway Key Manager exposes ML-KEM as a native key encapsulation usage, so the +// backend defaults to the strongest available post-quantum parameter set. +const DefaultAlgorithm = keymgmt.AlgorithmMLKEM1024 + type Config = scwkmapi.Config type Manager struct { @@ -42,6 +48,7 @@ type Reference struct { Region scw.Region ProjectID string Version string + Algorithm keymgmt.KeyAlgorithm URI string } @@ -54,16 +61,20 @@ func NewManager(cfg Config) (*Manager, error) { } 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")) + algorithm := req.Algorithm + if algorithm == "" { + algorithm = DefaultAlgorithm } 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 { + switch req.Purpose { + case keymgmt.PurposeKeyWrapping, keymgmt.PurposeRecipientDecrypt: + case keymgmt.PurposeKeyEncapsulation: + if !isKEMAlgorithm(algorithm) { + return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrUnsupportedCapability, fmt.Errorf("purpose %q requires a key encapsulation algorithm, got %q", req.Purpose, algorithm)) + } + default: return nil, enigma.WrapError("keymgmt/scwkm.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("unsupported key purpose %q", req.Purpose)) } if req.Exportable { @@ -73,7 +84,7 @@ func (m *Manager) CreateKey(ctx context.Context, req keymgmt.CreateKeyRequest) ( 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) + usage, _, err := usageForAlgorithm(algorithm) if err != nil { return nil, err } @@ -161,17 +172,24 @@ func capabilitySet() keymgmt.CapabilitySet { CanRotateProviderNative: true, CanExportPublicKey: true, CanResolveRecipient: true, - SupportsPQNatively: false, + SupportsPQNatively: true, SupportsClassicalWrapping: true, SupportsRewrapWorkflow: true, } } func BuildReference(keyID string, region scw.Region, projectID, version string) keymgmt.KeyReference { + return BuildReferenceWithAlgorithm(keyID, region, projectID, version, "") +} + +// BuildReferenceWithAlgorithm pins the key algorithm in the reference URI so runtime +// recipients can select the wrapping path (native ML-KEM wrap versus classical encrypt) +// without an extra Key Manager round trip. An empty algorithm keeps the legacy URI shape. +func BuildReferenceWithAlgorithm(keyID string, region scw.Region, projectID, version string, alg keymgmt.KeyAlgorithm) keymgmt.KeyReference { if version == "" { version = defaultReferenceVersion } - uri := buildURI(keyID, region, projectID, version) + uri := buildURI(keyID, region, projectID, version, alg) return keymgmt.KeyReference{ Backend: BackendName, URI: uri, @@ -202,6 +220,7 @@ func ResolveReference(ref keymgmt.KeyReference, fallbackRegion scw.Region) (Refe } resolved.ProjectID = parsed.ProjectID resolved.Region = parsed.Region + resolved.Algorithm = parsed.Algorithm } if resolved.KeyID == "" { return Reference{}, enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing key id")) @@ -216,7 +235,7 @@ func ResolveReference(ref keymgmt.KeyReference, fallbackRegion scw.Region) (Refe resolved.Version = defaultReferenceVersion } if resolved.URI == "" { - resolved.URI = buildURI(resolved.KeyID, resolved.Region, resolved.ProjectID, resolved.Version) + resolved.URI = buildURI(resolved.KeyID, resolved.Region, resolved.ProjectID, resolved.Version, resolved.Algorithm) } return resolved, nil } @@ -243,6 +262,13 @@ func parseReferenceURI(raw string) (Reference, error) { Version: q.Get("version"), ProjectID: q.Get(metadataProjectID), } + if rawAlg := q.Get(referenceParamAlgorithm); rawAlg != "" { + alg, err := parseAlgorithm(rawAlg) + if err != nil { + return Reference{}, err + } + parsed.Algorithm = alg + } if rawRegion := q.Get(metadataRegion); rawRegion != "" { region, err := scw.ParseRegion(rawRegion) if err != nil { @@ -253,7 +279,7 @@ func parseReferenceURI(raw string) (Reference, error) { return parsed, nil } -func buildURI(keyID string, region scw.Region, projectID, version string) string { +func buildURI(keyID string, region scw.Region, projectID, version string, alg keymgmt.KeyAlgorithm) string { u := &url.URL{Scheme: referenceScheme, Host: referenceHost, Path: "/" + url.PathEscape(keyID)} q := u.Query() q.Set(metadataRegion, string(region)) @@ -263,10 +289,34 @@ func buildURI(keyID string, region scw.Region, projectID, version string) string if version != "" { q.Set("version", version) } + if alg != "" { + q.Set(referenceParamAlgorithm, string(alg)) + } u.RawQuery = q.Encode() return u.String() } +// parseAlgorithm validates an algorithm carried by a reference URI. Only algorithms this +// backend can actually map to a Scaleway key usage are accepted. +func parseAlgorithm(raw string) (keymgmt.KeyAlgorithm, error) { + alg := keymgmt.KeyAlgorithm(raw) + if _, _, err := usageForAlgorithm(alg); err != nil { + return "", enigma.WrapError("keymgmt/scwkm.ResolveReference", enigma.ErrInvalidKeyReference, fmt.Errorf("invalid algorithm %q", raw)) + } + return alg, nil +} + +func isKEMAlgorithm(alg keymgmt.KeyAlgorithm) bool { + switch alg { + case keymgmt.AlgorithmMLKEM768, keymgmt.AlgorithmMLKEM1024: + return true + case keymgmt.AlgorithmAES256GCM, keymgmt.AlgorithmRSAOAEP3072SHA256: + return false + default: + return false + } +} + func descriptorFromKey(k *keymanager.Key, requestedPurpose keymgmt.KeyPurpose, metadata map[string]string) (*keymgmt.KeyDescriptor, error) { alg, class, err := algorithmAndClassFromUsage(k.Usage) if err != nil { @@ -277,7 +327,7 @@ func descriptorFromKey(k *keymanager.Key, requestedPurpose keymgmt.KeyPurpose, m } version := strconv.FormatUint(uint64(k.RotationCount), 10) - ref := BuildReference(k.ID, k.Region, k.ProjectID, version) + ref := BuildReferenceWithAlgorithm(k.ID, k.Region, k.ProjectID, version, alg) merged := map[string]string{ metadataRegion: string(k.Region), @@ -302,13 +352,27 @@ func descriptorFromKey(k *keymanager.Key, requestedPurpose keymgmt.KeyPurpose, m Class: class, Purpose: requestedPurpose, Algorithm: alg, - SecurityLevel: keymgmt.SecurityLevelCloudClassic, + SecurityLevel: securityLevelFromClass(class), Reference: ref, Capabilities: capabilitySet(), Metadata: merged, }, nil } +// securityLevelFromClass reports the guarantee actually delivered by the provisioned key: +// ML-KEM keys are wrapped by Scaleway itself, so they are cloud PQ-native, while RSA/AES +// keys stay classical cloud cryptography. +func securityLevelFromClass(class keymgmt.KeyClass) keymgmt.SecurityLevel { + switch class { + case keymgmt.KeyClassAsymmetricKEM: + return keymgmt.SecurityLevelCloudPQNative + case keymgmt.KeyClassAsymmetricEncryption, keymgmt.KeyClassSymmetricWrapping: + return keymgmt.SecurityLevelCloudClassic + default: + return keymgmt.SecurityLevelCloudClassic + } +} + func purposeFromClass(class keymgmt.KeyClass) keymgmt.KeyPurpose { switch class { case keymgmt.KeyClassAsymmetricKEM: @@ -328,8 +392,12 @@ func usageForAlgorithm(alg keymgmt.KeyAlgorithm) (*keymanager.KeyUsage, keymgmt. 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)) + case keymgmt.AlgorithmMLKEM768: + usage := keymanager.KeyAlgorithmKeyEncapsulationMlKem768 + return &keymanager.KeyUsage{KeyEncapsulation: &usage}, keymgmt.KeyClassAsymmetricKEM, nil + case keymgmt.AlgorithmMLKEM1024: + usage := keymanager.KeyAlgorithmKeyEncapsulationMlKem1024 + return &keymanager.KeyUsage{KeyEncapsulation: &usage}, keymgmt.KeyClassAsymmetricKEM, nil default: return nil, "", enigma.WrapError("keymgmt/scwkm.usageForAlgorithm", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("algorithm %q", alg)) } @@ -363,6 +431,18 @@ func algorithmAndClassFromUsage(usage *keymanager.KeyUsage) (keymgmt.KeyAlgorith return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported asymmetric usage %q", usage.AsymmetricEncryption.String())) } } + if usage.KeyEncapsulation != nil { + switch *usage.KeyEncapsulation { + case keymanager.KeyAlgorithmKeyEncapsulationUnknownKeyEncapsulation: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unknown key encapsulation usage")) + case keymanager.KeyAlgorithmKeyEncapsulationMlKem768: + return keymgmt.AlgorithmMLKEM768, keymgmt.KeyClassAsymmetricKEM, nil + case keymanager.KeyAlgorithmKeyEncapsulationMlKem1024: + return keymgmt.AlgorithmMLKEM1024, keymgmt.KeyClassAsymmetricKEM, nil + default: + return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("unsupported key encapsulation usage %q", usage.KeyEncapsulation.String())) + } + } return "", "", enigma.WrapError("keymgmt/scwkm.algorithmAndClassFromUsage", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("usage is not supported for envelope wrapping")) } diff --git a/keymgmt/scwkm/manager_test.go b/keymgmt/scwkm/manager_test.go index f2f5179..b369e3d 100644 --- a/keymgmt/scwkm/manager_test.go +++ b/keymgmt/scwkm/manager_test.go @@ -21,6 +21,8 @@ type mockAPI struct { 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) + wrapFn func(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) + unwrapFn func(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) } func managerForTest(t *testing.T, api scwkmapi.Client, region, projectID string) *Manager { @@ -72,6 +74,20 @@ func (m *mockAPI) Decrypt(ctx context.Context, req *keymanager.DecryptRequest) ( return m.decFn(ctx, req) } +func (m *mockAPI) WrapKey(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + if m.wrapFn == nil { + return nil, fmt.Errorf("unexpected WrapKey") + } + return m.wrapFn(ctx, req) +} + +func (m *mockAPI) UnwrapKey(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + if m.unwrapFn == nil { + return nil, fmt.Errorf("unexpected UnwrapKey") + } + return m.unwrapFn(ctx, req) +} + func TestBuildAndResolveReferenceRoundTrip(t *testing.T) { ref := BuildReference("key-1", scw.RegionFrPar, "proj-1", "7") assert.Equal(t, BackendName, ref.Backend) @@ -131,7 +147,7 @@ func TestCapabilities(t *testing.T) { assert.True(t, caps.CanRotateProviderNative) assert.True(t, caps.CanExportPublicKey) assert.True(t, caps.CanResolveRecipient) - assert.False(t, caps.SupportsPQNatively) + assert.True(t, caps.SupportsPQNatively) assert.True(t, caps.SupportsClassicalWrapping) assert.True(t, caps.SupportsRewrapWorkflow) } @@ -254,11 +270,11 @@ func TestManagerErrorMappingAndUnsupportedRequests(t *testing.T) { _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ Purpose: keymgmt.PurposeKeyWrapping, - Algorithm: keymgmt.AlgorithmMLKEM768, + Algorithm: keymgmt.KeyAlgorithm("ml-kem-512"), ProtectionLevel: keymgmt.ProtectionKMS, }) assert.Error(t, err) - assert.True(t, errors.Is(err, enigma.ErrKeyAlgorithmMismatch)) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ Purpose: keymgmt.PurposeKeyWrapping, @@ -299,3 +315,117 @@ func TestAlgorithmUsageMapping(t *testing.T) { assert.Error(t, err) assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) } + +func TestCreateKeyDefaultsToMLKEM1024(t *testing.T) { + algKEM := keymanager.KeyAlgorithmKeyEncapsulationMlKem1024 + api := &mockAPI{} + api.createFn = func(_ context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + if assert.NotNil(t, req.Usage) && assert.NotNil(t, req.Usage.KeyEncapsulation) { + assert.Equal(t, algKEM, *req.Usage.KeyEncapsulation) + } + assert.Nil(t, req.Usage.SymmetricEncryption) + assert.Nil(t, req.Usage.AsymmetricEncryption) + return &keymanager.Key{ + ID: "k-kem", + ProjectID: "project-a", + Usage: &keymanager.KeyUsage{KeyEncapsulation: &algKEM}, + RotationCount: 0, + Region: scw.RegionFrPar, + State: keymanager.KeyStateEnabled, + Origin: keymanager.KeyOriginScalewayKms, + }, nil + } + + m := managerForTest(t, api, "fr-par", "project-a") + + desc, err := m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "tenant-pq", + Purpose: keymgmt.PurposeKeyWrapping, + ProtectionLevel: keymgmt.ProtectionKMS, + }) + assert.NoError(t, err) + assert.Equal(t, keymgmt.AlgorithmMLKEM1024, desc.Algorithm) + assert.Equal(t, keymgmt.KeyClassAsymmetricKEM, desc.Class) + assert.Equal(t, keymgmt.SecurityLevelCloudPQNative, desc.SecurityLevel) + assert.True(t, desc.Capabilities.SupportsPQNatively) + assert.Contains(t, desc.Reference.URI, "alg=ml-kem-1024") + + resolved, err := ResolveReference(desc.Reference, "") + assert.NoError(t, err) + assert.Equal(t, keymgmt.AlgorithmMLKEM1024, resolved.Algorithm) +} + +func TestCreateKeyMLKEM768WithEncapsulationPurpose(t *testing.T) { + algKEM := keymanager.KeyAlgorithmKeyEncapsulationMlKem768 + api := &mockAPI{} + api.createFn = func(_ context.Context, req *keymanager.CreateKeyRequest) (*keymanager.Key, error) { + if assert.NotNil(t, req.Usage) && assert.NotNil(t, req.Usage.KeyEncapsulation) { + assert.Equal(t, algKEM, *req.Usage.KeyEncapsulation) + } + return &keymanager.Key{ + ID: "k-kem-768", + ProjectID: "project-a", + Usage: &keymanager.KeyUsage{KeyEncapsulation: &algKEM}, + Region: scw.RegionFrPar, + State: keymanager.KeyStateEnabled, + Origin: keymanager.KeyOriginScalewayKms, + }, nil + } + + m := managerForTest(t, api, "fr-par", "project-a") + + desc, err := m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyEncapsulation, + Algorithm: keymgmt.AlgorithmMLKEM768, + }) + assert.NoError(t, err) + assert.Equal(t, keymgmt.AlgorithmMLKEM768, desc.Algorithm) + assert.Equal(t, keymgmt.PurposeKeyEncapsulation, desc.Purpose) + assert.Equal(t, keymgmt.SecurityLevelCloudPQNative, desc.SecurityLevel) + assert.Contains(t, desc.Reference.URI, "alg=ml-kem-768") +} + +func TestKEMAlgorithmUsageMapping(t *testing.T) { + for _, tc := range []struct { + alg keymgmt.KeyAlgorithm + usage keymanager.KeyAlgorithmKeyEncapsulation + }{ + {keymgmt.AlgorithmMLKEM768, keymanager.KeyAlgorithmKeyEncapsulationMlKem768}, + {keymgmt.AlgorithmMLKEM1024, keymanager.KeyAlgorithmKeyEncapsulationMlKem1024}, + } { + usage, class, err := usageForAlgorithm(tc.alg) + assert.NoError(t, err) + assert.Equal(t, keymgmt.KeyClassAsymmetricKEM, class) + if assert.NotNil(t, usage.KeyEncapsulation) { + assert.Equal(t, tc.usage, *usage.KeyEncapsulation) + } + + alg, outClass, err := algorithmAndClassFromUsage(usage) + assert.NoError(t, err) + assert.Equal(t, tc.alg, alg) + assert.Equal(t, keymgmt.KeyClassAsymmetricKEM, outClass) + } + + unknown := keymanager.KeyAlgorithmKeyEncapsulationUnknownKeyEncapsulation + _, _, err := algorithmAndClassFromUsage(&keymanager.KeyUsage{KeyEncapsulation: &unknown}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedAlgorithm)) +} + +func TestReferenceAlgorithmParsing(t *testing.T) { + ref := BuildReferenceWithAlgorithm("k-1", scw.RegionFrPar, "proj-1", "3", keymgmt.AlgorithmMLKEM1024) + resolved, err := ResolveReference(ref, "") + assert.NoError(t, err) + assert.Equal(t, keymgmt.AlgorithmMLKEM1024, resolved.Algorithm) + + legacy := BuildReference("k-1", scw.RegionFrPar, "proj-1", "3") + resolvedLegacy, err := ResolveReference(legacy, "") + assert.NoError(t, err) + assert.Equal(t, keymgmt.KeyAlgorithm(""), resolvedLegacy.Algorithm) + assert.NotContains(t, legacy.URI, "alg=") + + bad := keymgmt.KeyReference{Backend: BackendName, URI: "enigma-scwkm://key/k-1?region=fr-par&alg=ml-kem-512"} + _, err = ResolveReference(bad, "") + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) +} diff --git a/recipient/scwkm/scwkm.go b/recipient/scwkm/scwkm.go index 699ab27..6479901 100644 --- a/recipient/scwkm/scwkm.go +++ b/recipient/scwkm/scwkm.go @@ -15,7 +15,13 @@ import ( ) const ( + // WrapAlgorithmEncryptV1 wraps the DEK through the classical Key Manager encrypt/decrypt + // endpoints (AES-256-GCM or RSA-OAEP keys). WrapAlgorithmEncryptV1 = "scwkm+encrypt-v1" + // WrapAlgorithmMLKEM768WrapV1 wraps the DEK through the native ML-KEM-768 wrap/unwrap endpoints. + WrapAlgorithmMLKEM768WrapV1 = "scwkm+mlkem-768-wrap-v1" + // WrapAlgorithmMLKEM1024WrapV1 wraps the DEK through the native ML-KEM-1024 wrap/unwrap endpoints. + WrapAlgorithmMLKEM1024WrapV1 = "scwkm+mlkem-1024-wrap-v1" ) type Config = scwkmapi.Config @@ -26,9 +32,19 @@ type Recipient struct { keyID string keyRef string projectID string + algorithm keymgmt.KeyAlgorithm } +// New builds a recipient for a classical Key Manager key (AES-256-GCM or RSA-OAEP). +// Use NewWithAlgorithm or NewFromReference for ML-KEM keys. func New(cfg Config, keyID string) (*Recipient, error) { + return NewWithAlgorithm(cfg, keyID, "") +} + +// NewWithAlgorithm builds a recipient for an explicitly known key algorithm. ML-KEM +// algorithms select the native Key Manager wrap/unwrap path; every other algorithm keeps +// the classical encrypt/decrypt path. +func NewWithAlgorithm(cfg Config, keyID string, alg keymgmt.KeyAlgorithm) (*Recipient, error) { if keyID == "" { return nil, enigma.WrapError("recipient/scwkm.New", enigma.ErrInvalidArgument, fmt.Errorf("missing key id")) } @@ -36,8 +52,8 @@ func New(cfg Config, keyID string) (*Recipient, error) { 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 + keyRef := keymgmtscwkm.BuildReferenceWithAlgorithm(keyID, region, cfg.ProjectID, "", alg).URI + return &Recipient{api: client, region: region, keyID: keyID, keyRef: keyRef, projectID: cfg.ProjectID, algorithm: alg}, nil } func NewFromReference(cfg Config, ref keymgmt.KeyReference) (*Recipient, error) { @@ -67,7 +83,41 @@ func NewFromReference(cfg Config, ref keymgmt.KeyReference) (*Recipient, error) 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 + return &Recipient{api: client, region: region, keyID: resolved.KeyID, keyRef: resolved.URI, projectID: cfg.ProjectID, algorithm: resolved.Algorithm}, nil +} + +// wrapAlgorithm reports the wrap algorithm identifier stored in the container for this key. +func (r *Recipient) wrapAlgorithm() string { + switch r.algorithm { + case keymgmt.AlgorithmMLKEM768: + return WrapAlgorithmMLKEM768WrapV1 + case keymgmt.AlgorithmMLKEM1024: + return WrapAlgorithmMLKEM1024WrapV1 + case keymgmt.AlgorithmAES256GCM, keymgmt.AlgorithmRSAOAEP3072SHA256: + return WrapAlgorithmEncryptV1 + default: + return WrapAlgorithmEncryptV1 + } +} + +func (r *Recipient) capability() recipient.CapabilityLevel { + if r.wrapAlgorithm() == WrapAlgorithmEncryptV1 { + return recipient.CapabilityCloudClassical + } + return recipient.CapabilityCloudPQNative +} + +func (r *Recipient) metadata() map[string]string { + meta := map[string]string{ + "backend": keymgmtscwkm.BackendName, + "region": string(r.region), + "key_id": r.keyID, + "project_id": r.projectID, + } + if r.algorithm != "" { + meta["algorithm"] = string(r.algorithm) + } + return meta } func (r *Recipient) WrapKey(ctx context.Context, dek []byte) (*recipient.WrappedKey, error) { @@ -81,6 +131,41 @@ func (r *Recipient) WrapKey(ctx context.Context, dek []byte) (*recipient.Wrapped return nil, enigma.WrapError("recipient/scwkm.WrapKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key id")) } + wrapAlgorithm := r.wrapAlgorithm() + + ciphertext, err := r.wrapDEK(ctx, wrapAlgorithm, dek) + if err != nil { + return nil, err + } + + return &recipient.WrappedKey{ + RecipientType: recipient.TypeSCWKM, + Capability: r.capability(), + WrapAlgorithm: wrapAlgorithm, + KeyRef: r.keyRef, + Ciphertext: append([]byte(nil), ciphertext...), + Metadata: r.metadata(), + }, nil +} + +// wrapDEK sends the DEK to the endpoint matching the key algorithm: ML-KEM keys go through +// the native wrap endpoint, classical keys keep the encrypt endpoint. +func (r *Recipient) wrapDEK(ctx context.Context, wrapAlgorithm string, dek []byte) ([]byte, error) { + if wrapAlgorithm != WrapAlgorithmEncryptV1 { + resp, err := r.api.WrapKey(ctx, &keymanager.WrapKeyRequest{ + 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 wrap key response")) + } + return resp.Ciphertext, nil + } + resp, err := r.api.Encrypt(ctx, &keymanager.EncryptRequest{ Region: r.region, KeyID: r.keyID, @@ -92,20 +177,7 @@ func (r *Recipient) WrapKey(ctx context.Context, dek []byte) (*recipient.Wrapped 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 + return resp.Ciphertext, nil } func (r *Recipient) UnwrapKey(ctx context.Context, wk *recipient.WrappedKey) ([]byte, error) { @@ -118,7 +190,9 @@ func (r *Recipient) UnwrapKey(ctx context.Context, wk *recipient.WrappedKey) ([] 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 { + switch wk.WrapAlgorithm { + case WrapAlgorithmEncryptV1, WrapAlgorithmMLKEM768WrapV1, WrapAlgorithmMLKEM1024WrapV1: + default: return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("wrap algorithm %q", wk.WrapAlgorithm)) } if len(wk.Ciphertext) == 0 { @@ -128,6 +202,23 @@ func (r *Recipient) UnwrapKey(ctx context.Context, wk *recipient.WrappedKey) ([] return nil, enigma.WrapError("recipient/scwkm.UnwrapKey", enigma.ErrRecipientNotFound, fmt.Errorf("key ref mismatch")) } + // Dispatch on the stored wrap algorithm so containers wrapped before ML-KEM support keep + // resolving through the classical decrypt endpoint. + if wk.WrapAlgorithm != WrapAlgorithmEncryptV1 { + resp, err := r.api.UnwrapKey(ctx, &keymanager.UnwrapKeyRequest{ + 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 unwrap key response")) + } + return append([]byte(nil), resp.Plaintext...), nil + } + resp, err := r.api.Decrypt(ctx, &keymanager.DecryptRequest{ Region: r.region, KeyID: r.keyID, @@ -145,15 +236,10 @@ func (r *Recipient) UnwrapKey(ctx context.Context, wk *recipient.WrappedKey) ([] func (r *Recipient) Descriptor() recipient.Descriptor { return recipient.Descriptor{ Type: recipient.TypeSCWKM, - Capability: recipient.CapabilityCloudClassical, + Capability: r.capability(), KeyRef: r.keyRef, RewrapCompatible: true, - Metadata: map[string]string{ - "backend": keymgmtscwkm.BackendName, - "region": string(r.region), - "key_id": r.keyID, - "project_id": r.projectID, - }, + Metadata: r.metadata(), } } diff --git a/recipient/scwkm/scwkm_test.go b/recipient/scwkm/scwkm_test.go index fc05005..63da04d 100644 --- a/recipient/scwkm/scwkm_test.go +++ b/recipient/scwkm/scwkm_test.go @@ -18,18 +18,25 @@ import ( ) 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) + encryptFn func(ctx context.Context, req *keymanager.EncryptRequest) (*keymanager.EncryptResponse, error) + decryptFn func(ctx context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) + wrapKeyFn func(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) + unwrapKeyFn func(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) } func recipientForTest(t *testing.T, api scwkmapi.Client, region, keyID, keyRef, projectID string) *Recipient { + t.Helper() + return recipientWithAlgorithmForTest(t, api, region, keyID, keyRef, projectID, "") +} + +func recipientWithAlgorithmForTest(t *testing.T, api scwkmapi.Client, region, keyID, keyRef, projectID string, alg keymgmt.KeyAlgorithm) *Recipient { t.Helper() parsedRegion, err := scw.ParseRegion(region) assert.NoError(t, err) if keyRef == "" { - keyRef = keymgmtscwkm.BuildReference(keyID, parsedRegion, projectID, "").URI + keyRef = keymgmtscwkm.BuildReferenceWithAlgorithm(keyID, parsedRegion, projectID, "", alg).URI } - return &Recipient{api: api, region: parsedRegion, keyID: keyID, keyRef: keyRef, projectID: projectID} + return &Recipient{api: api, region: parsedRegion, keyID: keyID, keyRef: keyRef, projectID: projectID, algorithm: alg} } func (m *mockRuntimeAPI) CreateKey(context.Context, *keymanager.CreateKeyRequest) (*keymanager.Key, error) { @@ -62,6 +69,20 @@ func (m *mockRuntimeAPI) Decrypt(ctx context.Context, req *keymanager.DecryptReq return m.decryptFn(ctx, req) } +func (m *mockRuntimeAPI) WrapKey(ctx context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + if m.wrapKeyFn == nil { + return nil, fmt.Errorf("unexpected WrapKey") + } + return m.wrapKeyFn(ctx, req) +} + +func (m *mockRuntimeAPI) UnwrapKey(ctx context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + if m.unwrapKeyFn == nil { + return nil, fmt.Errorf("unexpected UnwrapKey") + } + return m.unwrapKeyFn(ctx, req) +} + func TestNewAndNewFromReferenceValidation(t *testing.T) { _, err := New(Config{}, "") assert.Error(t, err) @@ -168,3 +189,132 @@ func TestSDKErrorMapping(t *testing.T) { assert.Error(t, err) assert.True(t, errors.Is(err, enigma.ErrRecipientNotFound)) } + +func TestWrapUnwrapRoundTripWithMLKEMKey(t *testing.T) { + api := &mockRuntimeAPI{} + api.wrapKeyFn = func(_ context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + assert.Equal(t, scw.RegionFrPar, req.Region) + assert.Equal(t, "kms-kem-1", req.KeyID) + return &keymanager.WrapKeyResponse{KeyID: req.KeyID, Ciphertext: append([]byte("kem:"), req.Plaintext...)}, nil + } + api.unwrapKeyFn = func(_ context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + assert.Equal(t, scw.RegionFrPar, req.Region) + assert.Equal(t, "kms-kem-1", req.KeyID) + if !strings.HasPrefix(string(req.Ciphertext), "kem:") { + return nil, fmt.Errorf("bad ciphertext") + } + pt := []byte(strings.TrimPrefix(string(req.Ciphertext), "kem:")) + return &keymanager.UnwrapKeyResponse{KeyID: req.KeyID, Plaintext: pt}, nil + } + + r := recipientWithAlgorithmForTest(t, api, "fr-par", "kms-kem-1", "", "project-a", keymgmt.AlgorithmMLKEM1024) + + 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.CapabilityCloudPQNative, wk.Capability) + assert.Equal(t, WrapAlgorithmMLKEM1024WrapV1, wk.WrapAlgorithm) + assert.Contains(t, wk.KeyRef, "alg=ml-kem-1024") + assert.Equal(t, "ml-kem-1024", wk.Metadata["algorithm"]) + + 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, recipient.CapabilityCloudPQNative, d.Capability) + assert.True(t, d.RewrapCompatible) + assert.Equal(t, "ml-kem-1024", d.Metadata["algorithm"]) +} + +func TestMLKEM768SelectsItsOwnWrapAlgorithm(t *testing.T) { + api := &mockRuntimeAPI{} + api.wrapKeyFn = func(_ context.Context, req *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + return &keymanager.WrapKeyResponse{KeyID: req.KeyID, Ciphertext: []byte("ct")}, nil + } + + r := recipientWithAlgorithmForTest(t, api, "fr-par", "kms-kem-768", "", "", keymgmt.AlgorithmMLKEM768) + + wk, err := r.WrapKey(context.Background(), []byte("dek")) + assert.NoError(t, err) + assert.Equal(t, WrapAlgorithmMLKEM768WrapV1, wk.WrapAlgorithm) + assert.Equal(t, recipient.CapabilityCloudPQNative, wk.Capability) +} + +func TestUnwrapDispatchesOnStoredWrapAlgorithm(t *testing.T) { + api := &mockRuntimeAPI{} + api.decryptFn = func(_ context.Context, req *keymanager.DecryptRequest) (*keymanager.DecryptResponse, error) { + return &keymanager.DecryptResponse{Plaintext: append([]byte("classical:"), req.Ciphertext...)}, nil + } + api.unwrapKeyFn = func(_ context.Context, req *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + return &keymanager.UnwrapKeyResponse{Plaintext: append([]byte("kem:"), req.Ciphertext...)}, nil + } + + r := recipientWithAlgorithmForTest(t, api, "fr-par", "kms-kem-1", "ref-1", "", keymgmt.AlgorithmMLKEM1024) + + // Containers wrapped before ML-KEM support keep resolving through the classical endpoint. + legacy, err := r.UnwrapKey(context.Background(), &recipient.WrappedKey{ + RecipientType: recipient.TypeSCWKM, + WrapAlgorithm: WrapAlgorithmEncryptV1, + KeyRef: "ref-1", + Ciphertext: []byte("ct"), + }) + assert.NoError(t, err) + assert.Equal(t, "classical:ct", string(legacy)) + + pq, err := r.UnwrapKey(context.Background(), &recipient.WrappedKey{ + RecipientType: recipient.TypeSCWKM, + WrapAlgorithm: WrapAlgorithmMLKEM768WrapV1, + KeyRef: "ref-1", + Ciphertext: []byte("ct"), + }) + assert.NoError(t, err) + assert.Equal(t, "kem:ct", string(pq)) +} + +func TestMLKEMSDKErrorMapping(t *testing.T) { + api := &mockRuntimeAPI{} + api.wrapKeyFn = func(_ context.Context, _ *keymanager.WrapKeyRequest) (*keymanager.WrapKeyResponse, error) { + return nil, &scw.InvalidArgumentsError{Details: []scw.InvalidArgumentsErrorDetail{{ArgumentName: "plaintext", Reason: "too large"}}} + } + api.unwrapKeyFn = func(_ context.Context, _ *keymanager.UnwrapKeyRequest) (*keymanager.UnwrapKeyResponse, error) { + return nil, &scw.ResourceNotFoundError{Resource: "key", ResourceID: "missing"} + } + + r := recipientWithAlgorithmForTest(t, api, "fr-par", "kms-kem-1", "ref-1", "", keymgmt.AlgorithmMLKEM1024) + + _, 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: WrapAlgorithmMLKEM1024WrapV1, + KeyRef: "ref-1", + Ciphertext: []byte("ct"), + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrRecipientNotFound)) +} + +func TestNewFromReferenceCarriesAlgorithm(t *testing.T) { + const projectID = "11111111-2222-3333-4444-555555555555" + + ref := keymgmtscwkm.BuildReferenceWithAlgorithm("kms-kem-1", scw.RegionFrPar, projectID, "1", keymgmt.AlgorithmMLKEM1024) + + r, err := NewFromReference(Config{Region: "fr-par"}, ref) + assert.NoError(t, err) + if assert.NotNil(t, r) { + assert.Equal(t, recipient.CapabilityCloudPQNative, r.Descriptor().Capability) + assert.Equal(t, "ml-kem-1024", r.Descriptor().Metadata["algorithm"]) + } + + legacy := keymgmtscwkm.BuildReference("kms-key-1", scw.RegionFrPar, projectID, "1") + + classical, err := NewFromReference(Config{Region: "fr-par"}, legacy) + assert.NoError(t, err) + if assert.NotNil(t, classical) { + assert.Equal(t, recipient.CapabilityCloudClassical, classical.Descriptor().Capability) + assert.Empty(t, classical.Descriptor().Metadata["algorithm"]) + } +} diff --git a/resolver/scwkm/resolver_test.go b/resolver/scwkm/resolver_test.go index d9fa090..dcc314c 100644 --- a/resolver/scwkm/resolver_test.go +++ b/resolver/scwkm/resolver_test.go @@ -8,7 +8,9 @@ import ( "github.com/hyperscale-stack/enigma" "github.com/hyperscale-stack/enigma/keymgmt" + keymgmtscwkm "github.com/hyperscale-stack/enigma/keymgmt/scwkm" "github.com/hyperscale-stack/enigma/recipient" + "github.com/scaleway/scaleway-sdk-go/scw" "github.com/stretchr/testify/assert" ) @@ -72,3 +74,26 @@ func TestResolveRecipientErrorMapping(t *testing.T) { assert.Error(t, err) assert.True(t, errors.Is(err, enigma.ErrResolveRecipientFailed)) } + +func TestResolveRecipientPreservesReferenceAlgorithm(t *testing.T) { + const projectID = "11111111-2222-3333-4444-555555555555" + + r, err := New(Config{Region: "fr-par", ProjectID: projectID}) + assert.NoError(t, err) + + pqRef := keymgmtscwkm.BuildReferenceWithAlgorithm("k-kem", scw.RegionFrPar, projectID, "1", keymgmt.AlgorithmMLKEM1024) + + pq, err := r.ResolveRecipient(context.Background(), pqRef) + assert.NoError(t, err) + if assert.NotNil(t, pq) { + assert.Equal(t, recipient.CapabilityCloudPQNative, pq.Descriptor().Capability) + } + + classicalRef := keymgmtscwkm.BuildReference("k-classical", scw.RegionFrPar, projectID, "1") + + classical, err := r.ResolveRecipient(context.Background(), classicalRef) + assert.NoError(t, err) + if assert.NotNil(t, classical) { + assert.Equal(t, recipient.CapabilityCloudClassical, classical.Descriptor().Capability) + } +}