diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f452ee3..f5a8ec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,17 @@ concurrency: cancel-in-progress: true jobs: + managed-key-client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: keyservice/go.mod + cache-dependency-path: keyservice/go.sum + - run: go test -race ./... && go vet ./... + working-directory: keyservice + ci: uses: codefly-dev/core/.github/workflows/go-service-ci.yml@v0.19.4 diff --git a/keyservice/README.md b/keyservice/README.md new file mode 100644 index 0000000..997ff16 --- /dev/null +++ b/keyservice/README.md @@ -0,0 +1,52 @@ +# Managed key-service runtime client + +This leaf Go module keeps the managed-key provider driver with the existing +key-service agent owner. Host imports the runtime client without importing an +agent executable or its Core/build dependencies. No server or proxy is added. + +The first provider is Azure Managed HSM: remote AES-256-GCM with authenticated +purpose, version-pinned HS256 MAC, and a separate AES key for wrapping signing +material. The client never generates or changes Host's signing identity. A +consumer keeps its existing envelope framing and validates unwrapped key material. + +`New` accepts a deployment-produced JSON descriptor with `provider` set to +`azure-managed-hsm` and `settings` containing `tenant_id`, `client_id`, +`envelope_key`, `mac_key`, and `signing_wrap_key`. Each key is an exact version URL +under the same public-cloud `managedhsm.azure.net` endpoint; purpose keys must be +distinct. Only Azure Workload Identity is used. There is no operator-login or +environment-secret credential fallback. + +The constructor validates key identity, HSM custody, enabled state, validity and +operations, then proves encryption/decryption and MAC access before returning. +Ciphertexts retain their key version, IV and authentication tag. Reads accept +historical versions of the same purpose key, so normal rotation preserves old +data. MAC does not follow the latest key version: changing its configured version +requires an explicit lookup migration. Organization-bound clients cannot use +deployment MAC or signing-material operations. Provider errors preserve status +without retaining response bodies or reflected plaintext. + +## Build or reuse + +| Searched | Result and decision | +| --- | --- | +| service-vault external binding, projection and provisioning; Host key-service interfaces; CLI #924; SDK Go accessors; handbook service/agent inventory | Reuse the existing owner and Host's envelope/signing contracts. No managed runtime driver exists. Add a leaf client, following the existing service-redis/cache and service-postgres/libs/go ownership pattern. | +| Azure Go SDK azkeys/azidentity and Go crypto/encoding libraries | Use the existing SDK for identity, challenges, serialization and key operations. Use standard encoding for retained provider payloads; no HTTP/auth implementation or cryptographic primitive is added. | + +## Qualification and deployment boundary + +Run `go test -race ./...` and `go vet ./...` in this directory. Tests exercise the +real Azure SDK against a controlled transport using actual AES-GCM/HMAC, including +restart, rotation, preserved Ed25519 material, wrong purpose, denied access, +wrong key, exportable keys and organization isolation. They do not prove Azure +provisioning, live workload federation, backup/restore or HSM API compatibility. + +A Standard Key Vault cannot provide this binding. Deployment still needs an +activated Managed HSM, supported recovery custody, exact workload grants and +applied key-version references. This package creates none of those resources. +Remote HS256 behavior and service limits must be qualified against that installed +backend before deployment; a controlled transport is not live evidence. + +References: [Managed HSM algorithms](https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/about-keys-details), +[encryption API](https://learn.microsoft.com/en-us/rest/api/keyvault/keys/encrypt/encrypt), +[sign API](https://learn.microsoft.com/en-us/rest/api/keyvault/keys/sign/sign), +[existing owner gap](https://github.com/codefly-dev/cli/issues/924). diff --git a/keyservice/azure.go b/keyservice/azure.go new file mode 100644 index 0000000..423dd7b --- /dev/null +++ b/keyservice/azure.go @@ -0,0 +1,354 @@ +// Package keyservice contains runtime clients owned by the key-service agent. +// Consumers retain their envelope framing and signing contracts; provider SDKs +// and provider-specific validation stay here, outside product services. +package keyservice + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" +) + +// AzureBinding is a non-secret, explicit installation. Key references pin a +// version. All three keys must be distinct and in the same Managed HSM. +// No default credential chain or operator login is used by a hosted consumer. +type AzureBinding struct { + TenantID string `json:"tenant_id"` + ClientID string `json:"client_id"` + EnvelopeKey string `json:"envelope_key"` + MACKey string `json:"mac_key"` + SigningWrapKey string `json:"signing_wrap_key"` +} + +type keyRef struct{ uri, base, name, version string } + +var keyURL = regexp.MustCompile(`^https://([a-z][a-z0-9-]{1,22}[a-z0-9]\.managedhsm\.azure\.net)/keys/([A-Za-z0-9-]{1,127})/([a-f0-9]{32})$`) +var uuid = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`) + +func parseKey(uri string) (keyRef, error) { + m := keyURL.FindStringSubmatch(uri) + if m == nil { + return keyRef{}, errors.New("key service requires a version-pinned public-cloud Managed HSM key URL") + } + return keyRef{uri: uri, base: "https://" + m[1], name: m[2], version: m[3]}, nil +} + +func (b AzureBinding) validate() ([3]keyRef, error) { + var keys [3]keyRef + for _, id := range []string{b.TenantID, b.ClientID} { + if !uuid.MatchString(id) || id == "00000000-0000-0000-0000-000000000000" { + return keys, errors.New("key service requires explicit tenant and workload client UUIDs") + } + } + for i, uri := range []string{b.EnvelopeKey, b.MACKey, b.SigningWrapKey} { + key, err := parseKey(uri) + if err != nil { + return keys, err + } + keys[i] = key + for j := 0; j < i; j++ { + if key.base != keys[j].base || key.name == keys[j].name { + return keys, errors.New("key service requires distinct purpose keys in one Managed HSM") + } + } + } + return keys, nil +} + +// Azure operates on non-exportable symmetric keys in Azure Managed HSM. +// An ordinary Standard Key Vault cannot satisfy this binding. +type Azure struct { + client *azkeys.Client + keys [3]keyRef +} + +func NewAzure(ctx context.Context, binding AzureBinding) (*Azure, error) { + return newAzure(ctx, binding, nil, nil) +} + +func newAzure(ctx context.Context, binding AzureBinding, credential azcore.TokenCredential, transport policy.Transporter) (*Azure, error) { + keys, err := binding.validate() + if err != nil { + return nil, err + } + if credential == nil { + credential, err = azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{TenantID: binding.TenantID, ClientID: binding.ClientID}) + if err != nil { + return nil, errors.New("cannot initialize the key-service workload credential") + } + } + if transport == nil { + transport = &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + } + client, err := azkeys.NewClient(keys[0].base, credential, &azkeys.ClientOptions{ClientOptions: azcore.ClientOptions{Transport: transport, Retry: policy.RetryOptions{MaxRetries: -1}}}) + if err != nil { + return nil, errors.New("cannot initialize the managed key service") + } + a := &Azure{client: client, keys: keys} + for i, key := range keys { + ops := []azkeys.KeyOperation{azkeys.KeyOperationEncrypt, azkeys.KeyOperationDecrypt} + if i == 1 { + ops = []azkeys.KeyOperation{azkeys.KeyOperationSign} + } + if err := a.checkKey(ctx, key, ops); err != nil { + return nil, err + } + } + for _, key := range []keyRef{keys[0], keys[2]} { + if err := a.probe(ctx, key); err != nil { + return nil, err + } + } + if _, err := a.MAC(ctx, "key-service boot probe"); err != nil { + return nil, err + } + return a, nil +} + +func (a *Azure) probe(ctx context.Context, key keyRef) error { + const purpose, value = "codefly.key-service.boot-probe/v1", "key-service boot probe" + sealed, err := a.seal(ctx, key, purpose, value) + if err != nil { + return err + } + opened, err := a.open(ctx, key, purpose, sealed) + if err != nil { + return err + } + if opened != value { + return errors.New("managed key service failed its encryption round trip") + } + return nil +} + +func (a *Azure) checkKey(ctx context.Context, key keyRef, ops []azkeys.KeyOperation) error { + r, err := a.client.GetKey(ctx, key.name, key.version, nil) + if err != nil { + return safeError(err) + } + now := time.Now() + if r.Attributes == nil || r.Attributes.Enabled == nil || !*r.Attributes.Enabled || + (r.Attributes.Exportable != nil && *r.Attributes.Exportable) || + (r.Attributes.NotBefore != nil && now.Before(*r.Attributes.NotBefore)) || + (r.Attributes.Expires != nil && !now.Before(*r.Attributes.Expires)) { + return errors.New("key service requires an enabled, non-exportable key within its validity period") + } + if r.Key == nil || r.Key.KID == nil || string(*r.Key.KID) != key.uri || r.Key.Kty == nil || *r.Key.Kty != azkeys.KeyTypeOctHSM || len(r.Key.K) != 0 || r.ReleasePolicy != nil { + return errors.New("key service returned the wrong key identity, type or custody") + } + for _, required := range ops { + found := false + for _, op := range r.Key.KeyOps { + if op != nil && *op == required { + found = true + } + } + if !found { + return errors.New("key service key does not permit its declared operations") + } + } + return nil +} + +// payload is only the provider portion of the consumer's stored envelope. +// The key version survives rotation, while the configured key family bounds +// which historical versions a reader may ask the service to decrypt. +type payload struct { + Version int `json:"v"` + Key string `json:"kid"` + IV []byte `json:"iv"` + Tag []byte `json:"tag"` + Ciphertext []byte `json:"ciphertext"` +} + +const maxPlaintext = 64 * 1024 + +func decode(value string) (payload, error) { + var p payload + if len(value) > 2*maxPlaintext { + return p, errors.New("key-service ciphertext is too large") + } + b, err := base64.RawURLEncoding.Strict().DecodeString(value) + if err != nil { + return p, errors.New("invalid key-service ciphertext encoding") + } + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if d.Decode(&p) != nil || d.Decode(new(any)) != io.EOF || p.Version != 1 || len(p.IV) != 12 || len(p.Tag) != 16 || len(p.Ciphertext) > maxPlaintext { + return p, errors.New("invalid key-service ciphertext") + } + return p, nil +} + +func (a *Azure) Identity() string { return a.keys[0].base + "/keys/" + a.keys[0].name } + +func (a *Azure) Accepts(value string) bool { + p, err := decode(value) + if err != nil { + return false + } + k, err := parseKey(p.Key) + return err == nil && k.base == a.keys[0].base && k.name == a.keys[0].name +} + +func (a *Azure) Seal(ctx context.Context, purpose, plaintext string) (string, error) { + return a.seal(ctx, a.keys[0], purpose, plaintext) +} + +func (a *Azure) seal(ctx context.Context, key keyRef, purpose, plaintext string) (string, error) { + if purpose == "" || len(plaintext) > maxPlaintext || len(purpose) > 4096 { + return "", errors.New("invalid key-service purpose or plaintext size") + } + // Managed HSM owns GCM nonce generation. A caller-supplied IV is refused; + // retain the returned nonce with the authenticated ciphertext for Open. + r, err := a.client.Encrypt(ctx, key.name, key.version, azkeys.KeyOperationParameters{Algorithm: ptr(azkeys.EncryptionAlgorithmA256GCM), Value: []byte(plaintext), AdditionalAuthenticatedData: []byte(purpose)}, nil) + if err != nil { + return "", safeError(err) + } + if r.KID == nil || string(*r.KID) != key.uri || len(r.IV) != 12 || len(r.AuthenticationTag) != 16 || len(r.Result) != len(plaintext) { + return "", errors.New("invalid managed encryption response") + } + b, _ := json.Marshal(payload{Version: 1, Key: key.uri, IV: r.IV, Tag: r.AuthenticationTag, Ciphertext: r.Result}) + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (a *Azure) Open(ctx context.Context, purpose, value string) (string, error) { + return a.open(ctx, a.keys[0], purpose, value) +} + +func (a *Azure) open(ctx context.Context, key keyRef, purpose, value string) (string, error) { + if purpose == "" || len(purpose) > 4096 { + return "", errors.New("invalid key-service purpose") + } + p, err := decode(value) + if err != nil { + return "", errors.Join(ErrInvalidCiphertext, err) + } + k, err := parseKey(p.Key) + if err != nil || k.base != key.base || k.name != key.name { + return "", errors.Join(ErrInvalidCiphertext, errors.New("ciphertext belongs to a different key")) + } + r, err := a.client.Decrypt(ctx, k.name, k.version, azkeys.KeyOperationParameters{Algorithm: ptr(azkeys.EncryptionAlgorithmA256GCM), Value: p.Ciphertext, AdditionalAuthenticatedData: []byte(purpose), IV: p.IV, AuthenticationTag: p.Tag}, nil) + if err != nil { + var response *azcore.ResponseError + if errors.As(err, &response) && response.StatusCode == http.StatusBadRequest { + return "", errors.Join(ErrInvalidCiphertext, safeError(err)) + } + return "", safeError(err) + } + if r.KID == nil || string(*r.KID) != k.uri || len(r.Result) != len(p.Ciphertext) { + return "", errors.New("invalid managed decryption response") + } + return string(r.Result), nil +} + +// MAC stays pinned to the exact version in the binding. The caller owns its +// persisted hash framing; key rotation must never silently change lookups. +func (a *Azure) MAC(ctx context.Context, value string) (string, error) { + if a.keys[1].uri == "" { + return "", errors.New("organization keys cannot compute deployment MACs") + } + if len(value) > maxPlaintext { + return "", errors.New("key-service MAC input is too large") + } + k := a.keys[1] + r, err := a.client.Sign(ctx, k.name, k.version, azkeys.SignParameters{Algorithm: ptr(azkeys.SignatureAlgorithmHS256), Value: []byte(value)}, nil) + if err != nil { + return "", safeError(err) + } + if r.KID == nil || string(*r.KID) != k.uri || len(r.Result) != 32 { + return "", errors.New("invalid managed MAC response") + } + return base64.RawURLEncoding.EncodeToString([]byte(k.uri)) + ":" + base64.StdEncoding.EncodeToString(r.Result), nil +} + +// Wrap and Unwrap preserve the consumer's signing material. The caller supplies +// the existing purpose string and validates its own key type; no signing +// algorithm or key generation policy is invented by this client. +func (a *Azure) Wrap(ctx context.Context, purpose, value string) (string, error) { + if a.keys[2].uri == "" { + return "", errors.New("organization keys cannot wrap deployment signing material") + } + return a.seal(ctx, a.keys[2], purpose, value) +} +func (a *Azure) Unwrap(ctx context.Context, purpose, value string) (string, error) { + if a.keys[2].uri == "" { + return "", errors.New("organization keys cannot unwrap deployment signing material") + } + return a.open(ctx, a.keys[2], purpose, value) +} + +// ForKey binds an organization's envelope key in this installation. It cannot +// select another HSM or either reserved deployment key, and it never gets MAC. +func (a *Azure) ForKey(ctx context.Context, uri string) (Client, error) { + if a.keys[1].uri == "" { + return nil, errors.New("organization keys cannot select further key bindings") + } + k, err := parseKey(uri) + if err != nil { + return nil, err + } + if k.base != a.keys[0].base { + return nil, errors.New("organization key belongs to a different key service") + } + for _, reserved := range a.keys { + if k.name == reserved.name { + return nil, errors.New("organization key is reserved for the deployment") + } + } + if err := a.checkKey(ctx, k, []azkeys.KeyOperation{azkeys.KeyOperationEncrypt, azkeys.KeyOperationDecrypt}); err != nil { + return nil, err + } + if err := a.probe(ctx, k); err != nil { + return nil, err + } + return &Azure{client: a.client, keys: [3]keyRef{k}}, nil +} + +type operationError struct{ status int } + +func (e *operationError) Error() string { + return fmt.Sprintf("managed key service request failed (HTTP %d)", e.status) +} +func (e *operationError) HTTPStatusCode() int { return e.status } +func safeError(err error) error { + var response *azcore.ResponseError + if errors.As(err, &response) { + return &operationError{status: response.StatusCode} + } + if errors.Is(err, context.Canceled) { + return context.Canceled + } + if errors.Is(err, context.DeadlineExceeded) { + return context.DeadlineExceeded + } + // SDK errors can carry URLs, response bodies and reflected plaintext. + return errors.New("managed key service request failed") +} +func ptr[T any](v T) *T { return &v } + +// DecodeAzureBinding rejects unknown fields rather than accepting a typo that +// drops a custody or workload constraint. +func DecodeAzureBinding(value string) (AzureBinding, error) { + var b AzureBinding + d := json.NewDecoder(strings.NewReader(value)) + d.DisallowUnknownFields() + if d.Decode(&b) != nil || d.Decode(new(any)) != io.EOF { + return b, errors.New("invalid Azure key-service binding") + } + _, err := b.validate() + return b, err +} diff --git a/keyservice/azure_test.go b/keyservice/azure_test.go new file mode 100644 index 0000000..be735f2 --- /dev/null +++ b/keyservice/azure_test.go @@ -0,0 +1,298 @@ +package keyservice + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ed25519" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +const hsm = "https://example-keys.managedhsm.azure.net" +const version = "0123456789abcdef0123456789abcdef" + +type credential struct{} + +func (credential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "test-only-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type server struct { + t *testing.T + requests int + denied, exportable, wrongKey, software, disabled bool +} + +// The real azkeys SDK serializes to this transport. The controlled service uses +// real AES-GCM/HMAC to exercise purpose binding, corruption and retained keys. +func (s *server) Do(req *http.Request) (*http.Response, error) { + s.requests++ + path := strings.Split(strings.Trim(req.URL.Path, "/"), "/") + if len(path) < 3 || path[0] != "keys" || req.URL.Scheme != "https" || req.URL.Host != "example-keys.managedhsm.azure.net" { + s.t.Fatalf("unexpected SDK request %s", req.URL) + } + kid := hsm + "/keys/" + path[1] + "/" + path[2] + answer := func(status int, value any) (*http.Response, error) { + b, err := json.Marshal(value) + if err != nil { + s.t.Fatal(err) + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(string(b))), Request: req}, nil + } + if req.Header.Get("Authorization") == "" { + response, err := answer(401, map[string]any{}) + response.Header.Set("WWW-Authenticate", `Bearer authorization="https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111", resource="https://managedhsm.azure.net"`) + return response, err + } + if s.denied { + return answer(403, map[string]any{"error": map[string]string{"code": "Forbidden", "message": "REFLECTED-SECRET-MUST-NOT-LEAK"}}) + } + if req.Method == http.MethodGet { + kind := "oct-HSM" + if s.software { + kind = "oct" + } + if s.wrongKey { + kid += "0" + } + return answer(200, map[string]any{"key": map[string]any{"kid": kid, "kty": kind, "key_ops": []string{"encrypt", "decrypt", "sign"}}, "attributes": map[string]any{"enabled": !s.disabled, "exportable": s.exportable}}) + } + if len(path) != 4 { + s.t.Fatal("unexpected key operation path") + } + if s.disabled { + return answer(403, map[string]any{"error": map[string]string{"code": "Forbidden"}}) + } + var body struct { + Algorithm string `json:"alg"` + Value string `json:"value"` + IV string `json:"iv"` + AAD string `json:"aad"` + Tag string `json:"tag"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + s.t.Fatal(err) + } + decode := func(v string) []byte { + b, err := base64.RawURLEncoding.DecodeString(v) + if err != nil { + s.t.Fatal(err) + } + return b + } + value := decode(body.Value) + key := sha256.Sum256([]byte(kid)) + block, _ := aes.NewCipher(key[:]) + gcm, _ := cipher.NewGCM(block) + response := map[string]any{"kid": kid} + switch path[3] { + case "encrypt": + if body.Algorithm != "A256GCM" { + s.t.Fatal("wrong encryption algorithm") + } + if body.IV != "" { + s.t.Fatal("Managed HSM refuses a caller-supplied GCM nonce") + } + iv := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(iv); err != nil { + s.t.Fatal(err) + } + sealed := gcm.Seal(nil, iv, value, decode(body.AAD)) + response["value"] = base64.RawURLEncoding.EncodeToString(sealed[:len(sealed)-16]) + response["tag"] = base64.RawURLEncoding.EncodeToString(sealed[len(sealed)-16:]) + response["iv"] = base64.RawURLEncoding.EncodeToString(iv) + case "decrypt": + if body.Algorithm != "A256GCM" { + s.t.Fatal("wrong decryption algorithm") + } + plain, err := gcm.Open(nil, decode(body.IV), append(value, decode(body.Tag)...), decode(body.AAD)) + if err != nil { + return answer(400, map[string]any{"error": map[string]string{"code": "BadParameter", "message": "REFLECTED-SECRET-MUST-NOT-LEAK"}}) + } + response["value"] = base64.RawURLEncoding.EncodeToString(plain) + case "sign": + if body.Algorithm != "HS256" { + s.t.Fatal("wrong MAC algorithm") + } + h := hmac.New(sha256.New, key[:]) + h.Write(value) + response["value"] = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + default: + s.t.Fatalf("unexpected operation %s", path[3]) + } + if s.wrongKey { + response["kid"] = kid + "0" + } + return answer(200, response) +} + +func binding() AzureBinding { + return AzureBinding{TenantID: "11111111-1111-1111-1111-111111111111", ClientID: "22222222-2222-2222-2222-222222222222", EnvelopeKey: hsm + "/keys/envelope/" + version, MACKey: hsm + "/keys/mac/" + version, SigningWrapKey: hsm + "/keys/signing-wrap/" + version} +} +func client(t *testing.T, s *server, b AzureBinding) *Azure { + t.Helper() + a, err := newAzure(t.Context(), b, credential{}, s) + if err != nil { + t.Fatal(err) + } + return a +} +func requireError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected refusal") + } + if strings.Contains(err.Error(), "REFLECTED-SECRET") { + t.Fatalf("secret leaked: %v", err) + } +} + +func TestAzureRestartRotationAndSigningMaterial(t *testing.T) { + s := &server{t: t} + b := binding() + a := client(t, s, b) + sealed, err := a.Seal(t.Context(), "credential:row-1", "retained credential") + if err != nil { + t.Fatal(err) + } + other, err := a.Seal(t.Context(), "credential:row-1", "retained credential") + if err != nil { + t.Fatal(err) + } + if sealed == other { + t.Fatal("nonce reused") + } + _, err = a.Open(t.Context(), "credential:row-2", sealed) + requireError(t, err) + hash, err := a.MAC(t.Context(), "api-key") + if err != nil { + t.Fatal(err) + } + seed := sha256.Sum256([]byte("test signing seed")) + wrapped, err := a.Wrap(t.Context(), "host-signing", base64.StdEncoding.EncodeToString(seed[:])) + if err != nil { + t.Fatal(err) + } + b.EnvelopeKey = strings.Replace(b.EnvelopeKey, version, strings.Repeat("a", 32), 1) + b.SigningWrapKey = strings.Replace(b.SigningWrapKey, version, strings.Repeat("b", 32), 1) + restarted := client(t, s, b) + if !restarted.Accepts(sealed) { + t.Fatal("rotation lost historical key") + } + plain, err := restarted.Open(t.Context(), "credential:row-1", sealed) + if err != nil || plain != "retained credential" { + t.Fatalf("restart lost ciphertext: %v", err) + } + got, err := restarted.MAC(t.Context(), "api-key") + if err != nil || got != hash { + t.Fatalf("lookup changed after restart: %v", err) + } + encoded, err := restarted.Unwrap(t.Context(), "host-signing", wrapped) + if err != nil { + t.Fatal(err) + } + recovered, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + if !ed25519.NewKeyFromSeed(seed[:]).Equal(ed25519.NewKeyFromSeed(recovered)) { + t.Fatal("restart changed signing identity") + } + _, err = restarted.Open(t.Context(), "host-signing", wrapped) + requireError(t, err) + _, err = restarted.Unwrap(t.Context(), "different-purpose", wrapped) + requireError(t, err) + s.denied = true + _, err = restarted.Open(t.Context(), "credential:row-1", sealed) + requireError(t, err) + _, err = restarted.MAC(t.Context(), "api-key") + requireError(t, err) + _, err = restarted.Unwrap(t.Context(), "host-signing", wrapped) + requireError(t, err) +} + +func TestAzureRefusesWrongCustodyAndIdentity(t *testing.T) { + for _, mode := range []string{"exportable", "software", "disabled", "wrong-key", "denied"} { + t.Run(mode, func(t *testing.T) { + s := &server{t: t, exportable: mode == "exportable", software: mode == "software", disabled: mode == "disabled", wrongKey: mode == "wrong-key", denied: mode == "denied"} + _, err := newAzure(t.Context(), binding(), credential{}, s) + requireError(t, err) + }) + } + s := &server{t: t} + a := client(t, s, binding()) + s.wrongKey = true + _, err := a.Seal(t.Context(), "purpose", "secret") + requireError(t, err) + _, err = a.MAC(t.Context(), "api-key") + requireError(t, err) +} + +func TestAzureRefusesMalformedBindingsBeforeNetwork(t *testing.T) { + for _, edit := range []func(*AzureBinding){ + func(b *AzureBinding) { b.ClientID = "" }, func(b *AzureBinding) { b.TenantID = strings.Repeat("0", 36) }, + func(b *AzureBinding) { b.EnvelopeKey = strings.Replace(b.EnvelopeKey, "managedhsm", "vault", 1) }, + func(b *AzureBinding) { b.MACKey = strings.TrimSuffix(b.MACKey, version) }, + func(b *AzureBinding) { b.MACKey = b.EnvelopeKey }, func(b *AzureBinding) { b.SigningWrapKey += "?key=other" }, + func(b *AzureBinding) { b.EnvelopeKey = strings.Replace(b.EnvelopeKey, "example-keys", "other-keys", 1) }, + } { + b := binding() + edit(&b) + s := &server{t: t} + _, err := newAzure(t.Context(), b, credential{}, s) + requireError(t, err) + if s.requests != 0 { + t.Fatal("invalid binding reached provider") + } + } + _, err := New(t.Context(), `{"provider":"unknown","settings":{}}`) + requireError(t, err) + _, err = DecodeAzureBinding(`{"tenant_id":"x","unknown":true}`) + requireError(t, err) +} + +func TestAzureOrganizationKeysCannotUseDeploymentCapabilities(t *testing.T) { + s := &server{t: t} + a := client(t, s, binding()) + org, err := a.ForKey(t.Context(), hsm+"/keys/org-one/"+version) + if err != nil { + t.Fatal(err) + } + sealed, err := org.Seal(t.Context(), "row", "org secret") + if err != nil { + t.Fatal(err) + } + if a.Accepts(sealed) { + t.Fatal("deployment accepts organization's ciphertext") + } + before := s.requests + _, err = a.Open(t.Context(), "row", sealed) + requireError(t, err) + _, err = org.MAC(t.Context(), "api-key") + requireError(t, err) + _, err = org.Wrap(t.Context(), "host", "seed") + requireError(t, err) + _, err = org.Unwrap(t.Context(), "host", sealed) + requireError(t, err) + _, err = a.ForKey(t.Context(), binding().MACKey) + requireError(t, err) + if s.requests != before { + t.Fatal("forbidden operation reached provider") + } + plain, err := org.Open(t.Context(), "row", sealed) + if err != nil || plain != "org secret" { + t.Fatal(err) + } +} diff --git a/keyservice/client.go b/keyservice/client.go new file mode 100644 index 0000000..fc31acc --- /dev/null +++ b/keyservice/client.go @@ -0,0 +1,51 @@ +package keyservice + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" +) + +// Client is the runtime capability consumed by Host. It owns provider payloads, +// not the consumer's outer envelope, signing algorithm or authorization policy. +type Client interface { + Tag() string + Identity() string + Accepts(string) bool + Seal(context.Context, string, string) (string, error) + Open(context.Context, string, string) (string, error) + MAC(context.Context, string) (string, error) + Wrap(context.Context, string, string) (string, error) + Unwrap(context.Context, string, string) (string, error) + ForKey(context.Context, string) (Client, error) +} + +// ErrInvalidCiphertext distinguishes corrupt or purpose-mismatched input from +// provider outages and revoked access. Consumers map it to their domain error. +var ErrInvalidCiphertext = errors.New("invalid managed key-service ciphertext") + +func (a *Azure) Tag() string { return "azure-managed-hsm" } + +// New consumes an explicit, non-secret provider binding projected by the +// deployment owner. Unknown providers and unknown fields fail before auth. +func New(ctx context.Context, descriptor string) (Client, error) { + var binding struct { + Provider string `json:"provider"` + Settings json.RawMessage `json:"settings"` + } + d := json.NewDecoder(strings.NewReader(descriptor)) + d.DisallowUnknownFields() + if d.Decode(&binding) != nil || d.Decode(new(any)) != io.EOF { + return nil, errors.New("invalid managed key-service descriptor") + } + if binding.Provider != "azure-managed-hsm" { + return nil, errors.New("unsupported managed key-service provider") + } + azure, err := DecodeAzureBinding(string(binding.Settings)) + if err != nil { + return nil, err + } + return NewAzure(ctx, azure) +} diff --git a/keyservice/go.mod b/keyservice/go.mod new file mode 100644 index 0000000..ddfce2e --- /dev/null +++ b/keyservice/go.mod @@ -0,0 +1,23 @@ +module github.com/codefly-dev/service-vault/keyservice + +go 1.27.3 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/keyservice/go.sum b/keyservice/go.sum new file mode 100644 index 0000000..b87aea6 --- /dev/null +++ b/keyservice/go.sum @@ -0,0 +1,43 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +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/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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=