diff --git a/README.md b/README.md index 1bdf8c8..6f639fb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ go get github.com/hyperscale-stack/enigma - `recipient`: recipient abstractions and capability model. - `recipient/localmlkem`: fully implemented local PQ recipient. - `recipient/{gcpkms,awskms,azurekv,scwkm}`: explicit cloud stubs for v1. +- `keymgmt`: key lifecycle interfaces and domain types. +- `keymgmt/localmlkem`: local ML-KEM key manager with filesystem-backed metadata persistence. +- `resolver`: recipient resolver interfaces and backend registry. +- `resolver/localmlkem`: resolves local key references into runtime recipients. - `mem`: best-effort memory hygiene helpers. ## Quick Start @@ -79,6 +83,25 @@ plaintext, _ := field.DecryptValue(context.Background(), ciphertext, ) ``` +### Key lifecycle and recipient resolution + +```go +km, _ := keymgmtlocalmlkem.NewManager("/var/lib/enigma-keys") +desc, _ := km.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "tenant-a-primary", + Purpose: keymgmt.PurposeRecipientDecrypt, + Algorithm: keymgmt.AlgorithmMLKEM768, + ProtectionLevel: keymgmt.ProtectionSoftware, +}) + +res, _ := resolverlocalmlkem.New("/var/lib/enigma-keys") +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. @@ -91,11 +114,19 @@ plaintext, _ := field.DecryptValue(context.Background(), ciphertext, - Go memory is not fully controllable; key wiping is best-effort only. - v1 cloud providers are 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). - No deterministic/searchable field encryption in v1. - No identity platform, policy engine, or remote API service. +## Lifecycle versus Runtime + +- `KeyManager` provisions, inspects, rotates, and deletes keys. +- `Recipient` only wraps and unwraps DEKs at runtime. +- `RecipientResolver` resolves a stored `KeyReference` back to a runtime `Recipient`. +- Key rotation and document rewrap are distinct operations. Rotation creates successor keys; rewrap updates recipient entries in existing encrypted containers. + ## Capability Model - `local-pq`: local ML-KEM recipient. @@ -112,6 +143,7 @@ go test ./... See: - [`docs/architecture.md`](docs/architecture.md) +- [`docs/key-management.md`](docs/key-management.md) - [`docs/container-format.md`](docs/container-format.md) - [`docs/threat-model.md`](docs/threat-model.md) - [`SECURITY.md`](SECURITY.md) diff --git a/docs/architecture.md b/docs/architecture.md index e81b9e4..171d6a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,14 +2,21 @@ ## Overview -Enigma is structured into four layers: +Enigma is structured into five layers: -1. Recipient / key wrapping layer +1. Key lifecycle and resolution layer +- `keymgmt`: key lifecycle interfaces and domain types. +- `keymgmt/localmlkem`: local ML-KEM key manager implementation. +- `resolver`: recipient resolution interfaces and registry. +- `resolver/localmlkem`: resolves stored local 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. -2. Symmetric encryption layer +3. Symmetric encryption layer - Uses one DEK per encrypted object. - Derives separated subkeys with HKDF-SHA256. - Encrypts content using AEAD suites: @@ -17,14 +24,14 @@ Enigma is structured into four layers: - optional: AES-256-GCM - Uses chunked authenticated framing for document/blob workloads. -3. Container format layer +4. Container format layer - Implements strict binary envelope parser/serializer. - Header split: - immutable section (content-bound) - recipient section (rewrap-mutable) - Header authentication tag is derived from DEK material. -4. High-level API layer +5. High-level API layer - `document` package: - `EncryptFile`, `DecryptFile` - `NewEncryptWriter`, `NewDecryptReader` @@ -42,6 +49,21 @@ Enigma is structured into four layers: - nonce salt - reserved material +## Key Lifecycle Model + +- `KeyManager` provisions and manages key lifecycle. +- `Recipient` only performs runtime `WrapKey`/`UnwrapKey`. +- `RecipientResolver` converts stored `KeyReference` records into runtime `recipient.Recipient` instances. +- `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. + +### Rotation versus Rewrap + +- Rotation creates or selects successor keys at lifecycle level. +- Rewrap updates recipient entries in encrypted documents. +- Rotation does not automatically re-encrypt existing payloads. +- Rewrap does not create or rotate backend keys. + ## Rewrap Model Rewrap attempts to unwrap DEK with supplied recipient(s), then rewrites only: diff --git a/docs/key-management.md b/docs/key-management.md new file mode 100644 index 0000000..4ff2322 --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,86 @@ +# Key Management + +## Purpose + +Enigma separates key lifecycle management from runtime wrapping and unwrapping. + +This split avoids mixing concerns: +- lifecycle APIs (`KeyManager`) provision and manage keys +- runtime recipient APIs (`recipient.Recipient`) wrap and unwrap DEKs +- resolver APIs (`RecipientResolver`) rebuild runtime recipients from stored key references + +## Core Concepts + +### KeyManager + +`KeyManager` is responsible for key lifecycle operations: +- create key +- inspect key +- rotate key (native or successor workflow) +- delete key +- report capability set + +### KeyReference + +`KeyReference` is a stable, serializable key pointer suitable for storage in an application database. + +A valid key reference must not include private key material. + +### RecipientResolver + +`RecipientResolver` turns a `KeyReference` back into a runtime `recipient.Recipient`. + +Applications can persist `KeyReference` records and resolve recipients on demand for encryption/decryption operations. + +## Local ML-KEM Backend + +The local backend is implemented in: +- `keymgmt/localmlkem` +- `resolver/localmlkem` + +### Storage model + +- records are stored under `/localmlkem/v1//.json` +- files are written with mode `0600` +- directories are created with mode `0700` +- references include backend, id, version, and URI +- private key material is persisted in backend storage, not in `KeyReference` + +### Filesystem trust assumptions + +- local backend security depends on host filesystem access controls +- protect the configured root path with strict OS permissions +- if host compromise is in scope, local software key storage may be insufficient + +## Rotation and Rewrap + +Rotation and rewrap are intentionally distinct operations: + +- `KeyManager.RotateKey` creates a successor key descriptor +- `document.Rewrap` updates recipient entries in encrypted containers + +Rotation does not automatically rewrite historical ciphertext. +Applications should perform rewrap workflows explicitly when policy requires migration to successor keys. + +## Application Ownership Mapping + +Enigma does not map keys to tenants, organizations, or environments. + +That mapping belongs to the application. + +Typical pattern: +1. application creates a key with `KeyManager` +2. application stores `KeyReference` in its own data model +3. application resolves a runtime recipient with `RecipientResolver` +4. application encrypts/decrypts via `document` or `field` packages + +## Capability Reporting + +`CapabilitySet` provides explicit backend capabilities, including: +- creation/deletion support +- native rotation support versus successor workflow +- recipient resolution support +- PQ-native support versus classical wrapping support +- rewrap workflow compatibility + +Capability reporting is descriptive and should be checked by the application before selecting a workflow. diff --git a/docs/roadmap.md b/docs/roadmap.md index 90b5c21..03dea49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,6 +4,8 @@ - Layered architecture (`recipient`, `container`, `document`, `field`) - Local ML-KEM recipient implementation +- Key lifecycle abstraction (`keymgmt`) and recipient resolution abstraction (`resolver`) +- Local ML-KEM key manager and resolver implementation - Chunked document encryption and stream APIs - Rewrap path without content re-encryption - Field encryption compact format @@ -12,8 +14,9 @@ ## Planned Next -1. Cloud backend implementations -- Replace provider stubs with production integrations. +1. Provider lifecycle backends +- Add `KeyManager` and `RecipientResolver` implementations for cloud backends. +- Keep capability reporting explicit for native rotation versus successor workflows. - Add live integration test matrix behind opt-in configuration. 2. Stronger policy controls @@ -25,7 +28,7 @@ 4. Advanced rewrap tooling - CLI improvements for inspection/rewrap automation. -- Batch rewrap workflows. +- Batch workflows that combine successor-key rotation and explicit rewrap execution. 5. Hardening and observability - Additional fuzzing corpora. diff --git a/errors.go b/errors.go index 3edc472..2bd142f 100644 --- a/errors.go +++ b/errors.go @@ -6,18 +6,26 @@ import ( ) var ( - ErrInvalidArgument = errors.New("enigma: invalid argument") - ErrInvalidContainer = errors.New("enigma: invalid container") - ErrUnsupportedVersion = errors.New("enigma: unsupported version") - ErrUnsupportedAlgorithm = errors.New("enigma: unsupported algorithm") - ErrNoRecipients = errors.New("enigma: no recipients configured") - ErrWrapFailed = errors.New("enigma: key wrap failed") - ErrUnwrapFailed = errors.New("enigma: key unwrap failed") - ErrDecryptFailed = errors.New("enigma: decrypt failed") - ErrIntegrity = errors.New("enigma: integrity check failed") - ErrNotImplemented = errors.New("enigma: not implemented") - ErrCapabilityMismatch = errors.New("enigma: capability mismatch") - ErrRecipientNotFound = errors.New("enigma: recipient not found") + ErrInvalidArgument = errors.New("enigma: invalid argument") + ErrInvalidContainer = errors.New("enigma: invalid container") + ErrUnsupportedVersion = errors.New("enigma: unsupported version") + ErrUnsupportedAlgorithm = errors.New("enigma: unsupported algorithm") + ErrNoRecipients = errors.New("enigma: no recipients configured") + ErrWrapFailed = errors.New("enigma: key wrap failed") + ErrUnwrapFailed = errors.New("enigma: key unwrap failed") + ErrDecryptFailed = errors.New("enigma: decrypt failed") + ErrIntegrity = errors.New("enigma: integrity check failed") + ErrNotImplemented = errors.New("enigma: not implemented") + ErrCapabilityMismatch = errors.New("enigma: capability mismatch") + ErrRecipientNotFound = errors.New("enigma: recipient not found") + ErrUnsupportedCapability = errors.New("enigma: unsupported capability") + ErrInvalidKeyReference = errors.New("enigma: invalid key reference") + ErrKeyNotFound = errors.New("enigma: key not found") + ErrKeyAlgorithmMismatch = errors.New("enigma: key algorithm mismatch") + ErrResolveRecipientFailed = errors.New("enigma: recipient resolver failed") + ErrCreateKeyFailed = errors.New("enigma: create key failed") + ErrDeleteKeyFailed = errors.New("enigma: delete key failed") + ErrRotateKeyFailed = errors.New("enigma: rotate key failed") ) // OpError stores operation context while preserving typed errors via errors.Is/errors.As. diff --git a/keymgmt/keymgmt.go b/keymgmt/keymgmt.go new file mode 100644 index 0000000..01219ad --- /dev/null +++ b/keymgmt/keymgmt.go @@ -0,0 +1,113 @@ +package keymgmt + +import "context" + +type KeyManager interface { + CreateKey(ctx context.Context, req CreateKeyRequest) (*KeyDescriptor, error) + GetKey(ctx context.Context, ref KeyReference) (*KeyDescriptor, error) + RotateKey(ctx context.Context, ref KeyReference, req RotateKeyRequest) (*KeyDescriptor, error) + DeleteKey(ctx context.Context, ref KeyReference) error + Capabilities(ctx context.Context) CapabilitySet +} + +type KeyClass string + +const ( + KeyClassAsymmetricKEM KeyClass = "asymmetric_kem" + KeyClassAsymmetricEncryption KeyClass = "asymmetric_encryption" + KeyClassSymmetricWrapping KeyClass = "symmetric_wrapping" +) + +type KeyPurpose string + +const ( + PurposeKeyEncapsulation KeyPurpose = "key_encapsulation" + PurposeKeyWrapping KeyPurpose = "key_wrapping" + PurposeRecipientDecrypt KeyPurpose = "recipient_decrypt" +) + +type KeyAlgorithm string + +const ( + AlgorithmMLKEM768 KeyAlgorithm = "ml-kem-768" + AlgorithmMLKEM1024 KeyAlgorithm = "ml-kem-1024" + AlgorithmRSAOAEP3072SHA256 KeyAlgorithm = "rsa-oaep-3072-sha256" + AlgorithmAES256GCM KeyAlgorithm = "aes-256-gcm" +) + +type ProtectionLevel string + +const ( + ProtectionSoftware ProtectionLevel = "software" + ProtectionHSM ProtectionLevel = "hsm" + ProtectionKMS ProtectionLevel = "kms" +) + +type SecurityLevel string + +const ( + SecurityLevelLocalPQ SecurityLevel = "local_pq" + SecurityLevelCloudPQNative SecurityLevel = "cloud_pq_native" + SecurityLevelCloudClassic SecurityLevel = "cloud_classical" +) + +type CreateKeyRequest struct { + Name string `json:"name"` + Purpose KeyPurpose `json:"purpose"` + Algorithm KeyAlgorithm `json:"algorithm"` + ProtectionLevel ProtectionLevel `json:"protection_level"` + Exportable bool `json:"exportable"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type RotateKeyRequest struct { + SuccessorName string `json:"successor_name"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type KeyReference struct { + Backend string `json:"backend"` + URI string `json:"uri"` + ID string `json:"id"` + Version string `json:"version"` +} + +type PublicKeyInfo struct { + Algorithm KeyAlgorithm `json:"algorithm"` + Data []byte `json:"data"` +} + +type KeyDescriptor struct { + ID string `json:"id"` + Backend string `json:"backend"` + Class KeyClass `json:"class"` + Purpose KeyPurpose `json:"purpose"` + Algorithm KeyAlgorithm `json:"algorithm"` + SecurityLevel SecurityLevel `json:"security_level"` + Reference KeyReference `json:"reference"` + PublicInfo *PublicKeyInfo `json:"public_info,omitempty"` + Capabilities CapabilitySet `json:"capabilities"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type CapabilitySet struct { + CanCreateKeys bool `json:"can_create_keys"` + CanDeleteKeys bool `json:"can_delete_keys"` + CanRotateProviderNative bool `json:"can_rotate_provider_native"` + CanExportPublicKey bool `json:"can_export_public_key"` + CanResolveRecipient bool `json:"can_resolve_recipient"` + SupportsPQNatively bool `json:"supports_pq_natively"` + SupportsClassicalWrapping bool `json:"supports_classical_wrapping"` + SupportsRewrapWorkflow bool `json:"supports_rewrap_workflow"` +} + +func CloneMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/keymgmt/keymgmt_test.go b/keymgmt/keymgmt_test.go new file mode 100644 index 0000000..5011d8e --- /dev/null +++ b/keymgmt/keymgmt_test.go @@ -0,0 +1,17 @@ +package keymgmt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCloneMap(t *testing.T) { + assert.Nil(t, CloneMap(nil)) + + in := map[string]string{"k": "v"} + out := CloneMap(in) + assert.Equal(t, "v", out["k"]) + out["k"] = "x" + assert.Equal(t, "v", in["k"]) +} diff --git a/keymgmt/localmlkem/manager.go b/keymgmt/localmlkem/manager.go new file mode 100644 index 0000000..7757490 --- /dev/null +++ b/keymgmt/localmlkem/manager.go @@ -0,0 +1,314 @@ +package localmlkem + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/hyperscale-stack/enigma/mem" + recipientlocalmlkem "github.com/hyperscale-stack/enigma/recipient/localmlkem" +) + +const ( + BackendName = "localmlkem" + schemaVersion = 1 + defaultVersion = "1" +) + +type Manager struct { + root string +} + +type keyRecordV1 struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Version string `json:"version"` + Name string `json:"name,omitempty"` + Purpose keymgmt.KeyPurpose `json:"purpose"` + Algorithm keymgmt.KeyAlgorithm `json:"algorithm"` + Protection keymgmt.ProtectionLevel `json:"protection_level"` + Exportable bool `json:"exportable"` + KeyRef string `json:"key_ref"` + Seed []byte `json:"seed"` + PublicKey []byte `json:"public_key"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedUnix int64 `json:"created_unix"` +} + +func NewManager(root string) (*Manager, error) { + if root == "" { + return nil, enigma.WrapError("keymgmt/localmlkem.NewManager", enigma.ErrInvalidArgument, fmt.Errorf("empty root path")) + } + m := &Manager{root: root} + if err := os.MkdirAll(m.baseDir(), 0o700); err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.NewManager", enigma.ErrInvalidArgument, err) + } + return m, nil +} + +func (m *Manager) CreateKey(ctx context.Context, req keymgmt.CreateKeyRequest) (*keymgmt.KeyDescriptor, error) { + _ = ctx + if req.Purpose == "" { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key purpose")) + } + switch req.Purpose { + case keymgmt.PurposeKeyEncapsulation, keymgmt.PurposeKeyWrapping, keymgmt.PurposeRecipientDecrypt: + default: + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("unsupported key purpose %q", req.Purpose)) + } + if req.Algorithm == "" { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrInvalidArgument, fmt.Errorf("missing key algorithm")) + } + if req.ProtectionLevel != "" && req.ProtectionLevel != keymgmt.ProtectionSoftware { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrUnsupportedCapability, fmt.Errorf("protection level %q is not supported by local backend", req.ProtectionLevel)) + } + set, err := setForAlgorithm(req.Algorithm) + if err != nil { + return nil, err + } + id, err := randomID() + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrCreateKeyFailed, err) + } + keyRef := fmt.Sprintf("%s:%s", BackendName, id) + recipient, err := recipientlocalmlkem.Generate(set, keyRef) + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrCreateKeyFailed, err) + } + record := keyRecordV1{ + SchemaVersion: schemaVersion, + ID: id, + Version: defaultVersion, + Name: req.Name, + Purpose: req.Purpose, + Algorithm: req.Algorithm, + Protection: keymgmt.ProtectionSoftware, + Exportable: req.Exportable, + KeyRef: keyRef, + Seed: recipient.Seed(), + PublicKey: recipient.PublicKey(), + Metadata: keymgmt.CloneMap(req.Metadata), + CreatedUnix: time.Now().Unix(), + } + if err := m.writeRecord(record); err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.CreateKey", enigma.ErrCreateKeyFailed, err) + } + desc := descriptorFromRecord(record) + mem.Zero(record.Seed) + return desc, nil +} + +func (m *Manager) GetKey(ctx context.Context, ref keymgmt.KeyReference) (*keymgmt.KeyDescriptor, error) { + _ = ctx + norm, err := normalizeReference(ref) + if err != nil { + return nil, err + } + record, err := m.readRecord(norm) + if err != nil { + return nil, err + } + desc := descriptorFromRecord(record) + mem.Zero(record.Seed) + return desc, nil +} + +func (m *Manager) RotateKey(ctx context.Context, ref keymgmt.KeyReference, req keymgmt.RotateKeyRequest) (*keymgmt.KeyDescriptor, error) { + current, err := m.GetKey(ctx, ref) + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.RotateKey", enigma.ErrRotateKeyFailed, err) + } + createReq := keymgmt.CreateKeyRequest{ + Name: req.SuccessorName, + Purpose: current.Purpose, + Algorithm: current.Algorithm, + ProtectionLevel: keymgmt.ProtectionSoftware, + Exportable: false, + Metadata: keymgmt.CloneMap(req.Metadata), + } + successor, err := m.CreateKey(ctx, createReq) + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.RotateKey", enigma.ErrRotateKeyFailed, err) + } + + norm, err := normalizeReference(ref) + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.RotateKey", enigma.ErrRotateKeyFailed, err) + } + record, err := m.readRecord(norm) + if err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.RotateKey", enigma.ErrRotateKeyFailed, err) + } + if record.Metadata == nil { + record.Metadata = make(map[string]string) + } + record.Metadata["successor_uri"] = successor.Reference.URI + if err := m.writeRecord(record); err != nil { + return nil, enigma.WrapError("keymgmt/localmlkem.RotateKey", enigma.ErrRotateKeyFailed, err) + } + mem.Zero(record.Seed) + return successor, nil +} + +func (m *Manager) DeleteKey(ctx context.Context, ref keymgmt.KeyReference) error { + _ = ctx + norm, err := normalizeReference(ref) + if err != nil { + return err + } + keyDir := filepath.Join(m.baseDir(), norm.ID) + if _, err := os.Stat(keyDir); err != nil { + if os.IsNotExist(err) { + return enigma.WrapError("keymgmt/localmlkem.DeleteKey", enigma.ErrKeyNotFound, err) + } + return enigma.WrapError("keymgmt/localmlkem.DeleteKey", enigma.ErrDeleteKeyFailed, err) + } + if err := os.RemoveAll(keyDir); err != nil { + return enigma.WrapError("keymgmt/localmlkem.DeleteKey", enigma.ErrDeleteKeyFailed, err) + } + return nil +} + +func (m *Manager) Capabilities(ctx context.Context) keymgmt.CapabilitySet { + _ = ctx + return keymgmt.CapabilitySet{ + CanCreateKeys: true, + CanDeleteKeys: true, + CanRotateProviderNative: false, + CanExportPublicKey: true, + CanResolveRecipient: true, + SupportsPQNatively: true, + SupportsClassicalWrapping: false, + SupportsRewrapWorkflow: true, + } +} + +func descriptorFromRecord(r keyRecordV1) *keymgmt.KeyDescriptor { + ref := keymgmt.KeyReference{ + Backend: BackendName, + URI: buildURI(r.ID, r.Version), + ID: r.ID, + Version: r.Version, + } + return &keymgmt.KeyDescriptor{ + ID: r.ID, + Backend: BackendName, + Class: keymgmt.KeyClassAsymmetricKEM, + Purpose: r.Purpose, + Algorithm: r.Algorithm, + SecurityLevel: keymgmt.SecurityLevelLocalPQ, + Reference: ref, + PublicInfo: &keymgmt.PublicKeyInfo{ + Algorithm: r.Algorithm, + Data: append([]byte(nil), r.PublicKey...), + }, + Capabilities: capabilitySet(), + Metadata: keymgmt.CloneMap(r.Metadata), + } +} + +func capabilitySet() keymgmt.CapabilitySet { + return keymgmt.CapabilitySet{ + CanCreateKeys: true, + CanDeleteKeys: true, + CanRotateProviderNative: false, + CanExportPublicKey: true, + CanResolveRecipient: true, + SupportsPQNatively: true, + SupportsClassicalWrapping: false, + SupportsRewrapWorkflow: true, + } +} + +func (m *Manager) baseDir() string { + return filepath.Join(m.root, BackendName, fmt.Sprintf("v%d", schemaVersion)) +} + +func (m *Manager) recordPath(id, version string) string { + return filepath.Join(m.baseDir(), id, version+".json") +} + +func (m *Manager) writeRecord(r keyRecordV1) error { + path := m.recordPath(r.ID, r.Version) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + blob, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, blob, 0o600) +} + +func (m *Manager) readRecord(ref keymgmt.KeyReference) (keyRecordV1, error) { + path := m.recordPath(ref.ID, ref.Version) + blob, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return keyRecordV1{}, enigma.WrapError("keymgmt/localmlkem.readRecord", enigma.ErrKeyNotFound, err) + } + return keyRecordV1{}, enigma.WrapError("keymgmt/localmlkem.readRecord", enigma.ErrInvalidArgument, err) + } + var record keyRecordV1 + if err := json.Unmarshal(blob, &record); err != nil { + return keyRecordV1{}, enigma.WrapError("keymgmt/localmlkem.readRecord", enigma.ErrInvalidContainer, err) + } + if record.SchemaVersion != schemaVersion { + return keyRecordV1{}, enigma.WrapError("keymgmt/localmlkem.readRecord", enigma.ErrUnsupportedVersion, fmt.Errorf("schema version %d", record.SchemaVersion)) + } + if record.ID == "" || record.Version == "" { + return keyRecordV1{}, enigma.WrapError("keymgmt/localmlkem.readRecord", enigma.ErrInvalidContainer, fmt.Errorf("malformed key record")) + } + return record, nil +} + +func normalizeReference(ref keymgmt.KeyReference) (keymgmt.KeyReference, error) { + if ref.Backend == "" { + return keymgmt.KeyReference{}, enigma.WrapError("keymgmt/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing backend")) + } + if ref.Backend != BackendName { + return keymgmt.KeyReference{}, enigma.WrapError("keymgmt/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("backend %q", ref.Backend)) + } + if ref.ID == "" { + return keymgmt.KeyReference{}, enigma.WrapError("keymgmt/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing key id")) + } + if ref.Version == "" { + ref.Version = defaultVersion + } + if ref.URI == "" { + ref.URI = buildURI(ref.ID, ref.Version) + } + return ref, nil +} + +func buildURI(id, version string) string { + return fmt.Sprintf("enigma-localmlkem://v%d/%s/%s", schemaVersion, id, version) +} + +func randomID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func setForAlgorithm(alg keymgmt.KeyAlgorithm) (recipientlocalmlkem.ParameterSet, error) { + switch alg { + case keymgmt.AlgorithmMLKEM768: + return recipientlocalmlkem.MLKEM768, nil + case keymgmt.AlgorithmMLKEM1024: + return recipientlocalmlkem.MLKEM1024, nil + case keymgmt.AlgorithmAES256GCM, keymgmt.AlgorithmRSAOAEP3072SHA256: + return "", enigma.WrapError("keymgmt/localmlkem.setForAlgorithm", enigma.ErrKeyAlgorithmMismatch, fmt.Errorf("algorithm %q is not supported by local ML-KEM backend", alg)) + default: + return "", enigma.WrapError("keymgmt/localmlkem.setForAlgorithm", enigma.ErrUnsupportedAlgorithm, fmt.Errorf("algorithm %q", alg)) + } +} diff --git a/keymgmt/localmlkem/manager_test.go b/keymgmt/localmlkem/manager_test.go new file mode 100644 index 0000000..6592f71 --- /dev/null +++ b/keymgmt/localmlkem/manager_test.go @@ -0,0 +1,120 @@ +package localmlkem + +import ( + "context" + "errors" + "testing" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/stretchr/testify/assert" +) + +func TestCreateGetDeleteLifecycle(t *testing.T) { + m, err := NewManager(t.TempDir()) + assert.NoError(t, err) + + desc, err := m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "tenant-a", + Purpose: keymgmt.PurposeKeyEncapsulation, + Algorithm: keymgmt.AlgorithmMLKEM768, + ProtectionLevel: keymgmt.ProtectionSoftware, + Metadata: map[string]string{"tenant": "a"}, + }) + assert.NoError(t, err) + assert.NotEmpty(t, desc.ID) + assert.Equal(t, BackendName, desc.Reference.Backend) + assert.NotContains(t, desc.Reference.URI, "seed") + assert.Equal(t, keymgmt.KeyClassAsymmetricKEM, desc.Class) + assert.Equal(t, keymgmt.SecurityLevelLocalPQ, desc.SecurityLevel) + + out, err := m.GetKey(context.Background(), desc.Reference) + assert.NoError(t, err) + assert.Equal(t, desc.ID, out.ID) + assert.Equal(t, "a", out.Metadata["tenant"]) + + assert.NoError(t, m.DeleteKey(context.Background(), desc.Reference)) + + _, err = m.GetKey(context.Background(), desc.Reference) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyNotFound)) +} + +func TestCreateKeyUnsupportedRequests(t *testing.T) { + m, err := NewManager(t.TempDir()) + assert.NoError(t, err) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{Algorithm: keymgmt.AlgorithmMLKEM768}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyEncapsulation, + Algorithm: keymgmt.AlgorithmRSAOAEP3072SHA256, + ProtectionLevel: keymgmt.ProtectionSoftware, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyAlgorithmMismatch)) + + _, err = m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Purpose: keymgmt.PurposeKeyEncapsulation, + Algorithm: keymgmt.AlgorithmMLKEM768, + ProtectionLevel: keymgmt.ProtectionHSM, + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrUnsupportedCapability)) +} + +func TestInvalidReferenceAndDeleteNotFound(t *testing.T) { + m, err := NewManager(t.TempDir()) + assert.NoError(t, err) + + _, err = m.GetKey(context.Background(), keymgmt.KeyReference{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + err = m.DeleteKey(context.Background(), keymgmt.KeyReference{Backend: BackendName, ID: "missing"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyNotFound)) +} + +func TestRotateSuccessorWorkflow(t *testing.T) { + m, err := NewManager(t.TempDir()) + assert.NoError(t, err) + + created, err := m.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "primary", + Purpose: keymgmt.PurposeRecipientDecrypt, + Algorithm: keymgmt.AlgorithmMLKEM1024, + ProtectionLevel: keymgmt.ProtectionSoftware, + }) + assert.NoError(t, err) + + succ, err := m.RotateKey(context.Background(), created.Reference, keymgmt.RotateKeyRequest{ + SuccessorName: "primary-v2", + Metadata: map[string]string{"rotation": "planned"}, + }) + assert.NoError(t, err) + assert.NotEqual(t, created.ID, succ.ID) + assert.Equal(t, created.Algorithm, succ.Algorithm) + assert.Equal(t, "planned", succ.Metadata["rotation"]) + + oldDesc, err := m.GetKey(context.Background(), created.Reference) + assert.NoError(t, err) + assert.NotEmpty(t, oldDesc.Metadata["successor_uri"]) +} + +func TestCapabilities(t *testing.T) { + m, err := NewManager(t.TempDir()) + assert.NoError(t, err) + + caps := m.Capabilities(context.Background()) + assert.True(t, caps.CanCreateKeys) + assert.True(t, caps.CanDeleteKeys) + assert.False(t, caps.CanRotateProviderNative) + assert.True(t, caps.CanExportPublicKey) + assert.True(t, caps.CanResolveRecipient) + assert.True(t, caps.SupportsPQNatively) + assert.False(t, caps.SupportsClassicalWrapping) + assert.True(t, caps.SupportsRewrapWorkflow) +} diff --git a/resolver/localmlkem/resolver.go b/resolver/localmlkem/resolver.go new file mode 100644 index 0000000..148370c --- /dev/null +++ b/resolver/localmlkem/resolver.go @@ -0,0 +1,120 @@ +package localmlkem + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + keymgmtlocal "github.com/hyperscale-stack/enigma/keymgmt/localmlkem" + "github.com/hyperscale-stack/enigma/mem" + "github.com/hyperscale-stack/enigma/recipient" + recipientlocalmlkem "github.com/hyperscale-stack/enigma/recipient/localmlkem" +) + +type Resolver struct { + root string +} + +type keyRecordV1 struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Version string `json:"version"` + Algorithm keymgmt.KeyAlgorithm `json:"algorithm"` + KeyRef string `json:"key_ref"` + Seed []byte `json:"seed"` +} + +func New(root string) (*Resolver, error) { + if root == "" { + return nil, enigma.WrapError("resolver/localmlkem.New", enigma.ErrInvalidArgument, fmt.Errorf("empty root path")) + } + return &Resolver{root: root}, nil +} + +func (r *Resolver) ResolveRecipient(ctx context.Context, ref keymgmt.KeyReference) (recipient.Recipient, error) { + _ = ctx + norm, err := normalizeReference(ref) + if err != nil { + return nil, err + } + record, err := r.readRecord(norm) + if err != nil { + return nil, err + } + defer mem.Zero(record.Seed) + set, err := setForAlgorithm(record.Algorithm) + if err != nil { + return nil, err + } + rcp, err := recipientlocalmlkem.NewFromSeed(set, record.Seed, record.KeyRef) + if err != nil { + return nil, enigma.WrapError("resolver/localmlkem.ResolveRecipient", enigma.ErrResolveRecipientFailed, err) + } + return rcp, nil +} + +func (r *Resolver) baseDir() string { + return filepath.Join(r.root, keymgmtlocal.BackendName, "v1") +} + +func (r *Resolver) recordPath(id, version string) string { + return filepath.Join(r.baseDir(), id, version+".json") +} + +func (r *Resolver) readRecord(ref keymgmt.KeyReference) (keyRecordV1, error) { + path := r.recordPath(ref.ID, ref.Version) + blob, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return keyRecordV1{}, enigma.WrapError("resolver/localmlkem.readRecord", enigma.ErrKeyNotFound, err) + } + return keyRecordV1{}, enigma.WrapError("resolver/localmlkem.readRecord", enigma.ErrResolveRecipientFailed, err) + } + var record keyRecordV1 + if err := json.Unmarshal(blob, &record); err != nil { + return keyRecordV1{}, enigma.WrapError("resolver/localmlkem.readRecord", enigma.ErrInvalidContainer, err) + } + if record.SchemaVersion != 1 { + return keyRecordV1{}, enigma.WrapError("resolver/localmlkem.readRecord", enigma.ErrUnsupportedVersion, fmt.Errorf("schema version %d", record.SchemaVersion)) + } + if len(record.Seed) == 0 { + return keyRecordV1{}, enigma.WrapError("resolver/localmlkem.readRecord", enigma.ErrInvalidContainer, fmt.Errorf("missing seed")) + } + if record.KeyRef == "" { + record.KeyRef = fmt.Sprintf("%s:%s", keymgmtlocal.BackendName, ref.ID) + } + return record, nil +} + +func normalizeReference(ref keymgmt.KeyReference) (keymgmt.KeyReference, error) { + if ref.Backend == "" { + return keymgmt.KeyReference{}, enigma.WrapError("resolver/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing backend")) + } + if ref.Backend != keymgmtlocal.BackendName { + return keymgmt.KeyReference{}, enigma.WrapError("resolver/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("backend %q", ref.Backend)) + } + if ref.ID == "" { + return keymgmt.KeyReference{}, enigma.WrapError("resolver/localmlkem.normalizeReference", enigma.ErrInvalidKeyReference, fmt.Errorf("missing key id")) + } + if ref.Version == "" { + ref.Version = "1" + } + return ref, nil +} + +func setForAlgorithm(alg keymgmt.KeyAlgorithm) (recipientlocalmlkem.ParameterSet, error) { + switch alg { + case keymgmt.AlgorithmMLKEM768: + return recipientlocalmlkem.MLKEM768, nil + case keymgmt.AlgorithmMLKEM1024: + return recipientlocalmlkem.MLKEM1024, nil + case keymgmt.AlgorithmRSAOAEP3072SHA256, keymgmt.AlgorithmAES256GCM: + return "", enigma.WrapError("resolver/localmlkem.setForAlgorithm", enigma.ErrKeyAlgorithmMismatch, fmt.Errorf("algorithm %q cannot be resolved as local ML-KEM recipient", alg)) + default: + return "", enigma.WrapError("resolver/localmlkem.setForAlgorithm", enigma.ErrKeyAlgorithmMismatch, fmt.Errorf("algorithm %q cannot be resolved as local ML-KEM recipient", alg)) + } +} diff --git a/resolver/localmlkem/resolver_test.go b/resolver/localmlkem/resolver_test.go new file mode 100644 index 0000000..45d4d5d --- /dev/null +++ b/resolver/localmlkem/resolver_test.go @@ -0,0 +1,67 @@ +package localmlkem + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/document" + "github.com/hyperscale-stack/enigma/keymgmt" + keymgmtlocal "github.com/hyperscale-stack/enigma/keymgmt/localmlkem" + "github.com/stretchr/testify/assert" +) + +func TestResolveRecipientAndDocumentRoundTrip(t *testing.T) { + root := t.TempDir() + km, err := keymgmtlocal.NewManager(root) + assert.NoError(t, err) + + desc, err := km.CreateKey(context.Background(), keymgmt.CreateKeyRequest{ + Name: "org-main", + Purpose: keymgmt.PurposeRecipientDecrypt, + Algorithm: keymgmt.AlgorithmMLKEM768, + ProtectionLevel: keymgmt.ProtectionSoftware, + }) + assert.NoError(t, err) + + res, err := New(root) + assert.NoError(t, err) + rcp, err := res.ResolveRecipient(context.Background(), desc.Reference) + assert.NoError(t, err) + + var encrypted bytes.Buffer + ew, err := document.NewEncryptWriter(context.Background(), &encrypted, document.WithRecipient(rcp)) + assert.NoError(t, err) + _, err = ew.Write([]byte("resolver-integration-payload")) + assert.NoError(t, err) + assert.NoError(t, ew.Close()) + + rcp2, err := res.ResolveRecipient(context.Background(), desc.Reference) + assert.NoError(t, err) + dr, err := document.NewDecryptReader(context.Background(), bytes.NewReader(encrypted.Bytes()), document.WithRecipient(rcp2)) + assert.NoError(t, err) + pt, err := io.ReadAll(dr) + assert.NoError(t, err) + assert.Equal(t, "resolver-integration-payload", string(pt)) +} + +func TestResolveRecipientErrors(t *testing.T) { + root := t.TempDir() + res, err := New(root) + assert.NoError(t, err) + + _, err = res.ResolveRecipient(context.Background(), keymgmt.KeyReference{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + _, err = res.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: "unknown", ID: "x"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + _, err = res.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: keymgmtlocal.BackendName, ID: "missing"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrKeyNotFound)) +} diff --git a/resolver/resolver.go b/resolver/resolver.go new file mode 100644 index 0000000..59ef949 --- /dev/null +++ b/resolver/resolver.go @@ -0,0 +1,48 @@ +package resolver + +import ( + "context" + "fmt" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/hyperscale-stack/enigma/recipient" +) + +type RecipientResolver interface { + ResolveRecipient(ctx context.Context, ref keymgmt.KeyReference) (recipient.Recipient, error) +} + +type Registry struct { + backends map[string]RecipientResolver +} + +func NewRegistry() *Registry { + return &Registry{backends: make(map[string]RecipientResolver)} +} + +func (r *Registry) RegisterBackend(backend string, rr RecipientResolver) error { + if backend == "" { + return enigma.WrapError("resolver.Registry.RegisterBackend", enigma.ErrInvalidArgument, fmt.Errorf("empty backend")) + } + if rr == nil { + return enigma.WrapError("resolver.Registry.RegisterBackend", enigma.ErrInvalidArgument, fmt.Errorf("nil resolver")) + } + r.backends[backend] = rr + return nil +} + +func (r *Registry) ResolveRecipient(ctx context.Context, ref keymgmt.KeyReference) (recipient.Recipient, error) { + if ref.Backend == "" { + return nil, enigma.WrapError("resolver.Registry.ResolveRecipient", enigma.ErrInvalidKeyReference, fmt.Errorf("missing backend")) + } + rr, ok := r.backends[ref.Backend] + if !ok { + return nil, enigma.WrapError("resolver.Registry.ResolveRecipient", enigma.ErrResolveRecipientFailed, fmt.Errorf("unknown backend %q", ref.Backend)) + } + rcp, err := rr.ResolveRecipient(ctx, ref) + if err != nil { + return nil, enigma.WrapError("resolver.Registry.ResolveRecipient", enigma.ErrResolveRecipientFailed, err) + } + return rcp, nil +} diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go new file mode 100644 index 0000000..41b4a07 --- /dev/null +++ b/resolver/resolver_test.go @@ -0,0 +1,47 @@ +package resolver + +import ( + "context" + "errors" + "testing" + + "github.com/hyperscale-stack/enigma" + "github.com/hyperscale-stack/enigma/keymgmt" + "github.com/hyperscale-stack/enigma/recipient" + "github.com/stretchr/testify/assert" +) + +type stubResolver struct{} + +func (stubResolver) ResolveRecipient(_ context.Context, _ keymgmt.KeyReference) (recipient.Recipient, error) { + return nil, errors.New("resolve failure") +} + +func TestRegistryValidation(t *testing.T) { + r := NewRegistry() + + err := r.RegisterBackend("", stubResolver{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) + + err = r.RegisterBackend("localmlkem", nil) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidArgument)) +} + +func TestRegistryResolveErrors(t *testing.T) { + r := NewRegistry() + + _, err := r.ResolveRecipient(context.Background(), keymgmt.KeyReference{}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrInvalidKeyReference)) + + _, err = r.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: "missing"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrResolveRecipientFailed)) + + assert.NoError(t, r.RegisterBackend("broken", stubResolver{})) + _, err = r.ResolveRecipient(context.Background(), keymgmt.KeyReference{Backend: "broken", ID: "x"}) + assert.Error(t, err) + assert.True(t, errors.Is(err, enigma.ErrResolveRecipientFailed)) +}