diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 8c02da60fe..846df9b818 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -529,3 +529,31 @@ const BuildProgressUrlPathName = "distributions" // RuntimeCacheSizeConfigKey is the config key for the runtime cache size. const RuntimeCacheSizeConfigKey = "runtime.cache.size" + +// PrivateIngredientKeyServiceURLConfig is the config key holding the URL of the +// customer-hosted org key service (the GET .../v1/org-key endpoint). +const PrivateIngredientKeyServiceURLConfig = "privateingredient.key_service_url" + +// PrivateIngredientKeyServiceCAConfig is the config key holding the path to the +// CA bundle (or pinned certificate) used to verify the key service's TLS certificate. +const PrivateIngredientKeyServiceCAConfig = "privateingredient.key_service_ca" + +// PrivateIngredientMTLSCertConfig is the config key holding the path to the mTLS +// client certificate used to authenticate to the key service. +const PrivateIngredientMTLSCertConfig = "privateingredient.mtls_cert" + +// PrivateIngredientMTLSKeyConfig is the config key holding the path to the mTLS +// client private key used to authenticate to the key service. +const PrivateIngredientMTLSKeyConfig = "privateingredient.mtls_key" + +// PrivateIngredientBearerTokenEnvConfig is the config key holding the name of the +// environment variable from which to read a bearer token for the key service. +const PrivateIngredientBearerTokenEnvConfig = "privateingredient.bearer_token_env" + +// PrivateIngredientBearerTokenFileConfig is the config key holding the path to a +// file from which to read a bearer token for the key service. +const PrivateIngredientBearerTokenFileConfig = "privateingredient.bearer_token_file" + +// PrivateIngredientCacheKeyConfig is the config key that opts into caching the +// fetched org key on disk (0600) for headless/offline/CI reuse. +const PrivateIngredientCacheKeyConfig = "privateingredient.cache_key_on_disk" diff --git a/internal/runbits/orgkey/cache.go b/internal/runbits/orgkey/cache.go new file mode 100644 index 0000000000..9f43471135 --- /dev/null +++ b/internal/runbits/orgkey/cache.go @@ -0,0 +1,54 @@ +package orgkey + +import ( + "os" + "path/filepath" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/logging" +) + +// cacheFileName is the on-disk cache file (the validated contract JSON) under the config dir. +const cacheFileName = "private_ingredient_orgkey.json" + +func (p *provider) diskCacheEnabled() bool { + return p.cfg.GetBool(constants.PrivateIngredientCacheKeyConfig) +} + +func (p *provider) cachePath() string { + return filepath.Join(p.cfg.ConfigPath(), cacheFileName) +} + +// readDiskCache returns the cached contract bytes if a usable cache file exists. +// A missing file is normal (first run); an unsafe or unreadable file is ignored +// with a warning so the run falls back to a fresh fetch. +func (p *provider) readDiskCache() ([]byte, bool) { + path := p.cachePath() + info, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + logging.Warning("Ignoring on-disk org key cache: %v", errs.JoinMessage(err)) + } + return nil, false + } + if err := checkCacheMode(info); err != nil { + logging.Warning("Ignoring on-disk org key cache: %v", errs.JoinMessage(err)) + return nil, false + } + b, err := os.ReadFile(path) + if err != nil { + logging.Warning("Could not read on-disk org key cache: %v", errs.JoinMessage(err)) + return nil, false + } + return b, true +} + +// writeDiskCache persists the validated contract for reuse by later runs, +// owner-readable only. +func (p *provider) writeDiskCache(raw []byte) error { + if err := os.WriteFile(p.cachePath(), raw, 0600); err != nil { + return errs.Wrap(err, "unable to write org key cache") + } + return nil +} diff --git a/internal/runbits/orgkey/cache_lin_mac.go b/internal/runbits/orgkey/cache_lin_mac.go new file mode 100644 index 0000000000..3ea4db735a --- /dev/null +++ b/internal/runbits/orgkey/cache_lin_mac.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package orgkey + +import ( + "os" + + "github.com/ActiveState/cli/internal/errs" +) + +// checkCacheMode rejects a cache file that is readable or writable by anyone +// other than the owner (anything beyond u+rw). +func checkCacheMode(info os.FileInfo) error { + if info.Mode()&0177 != 0 { + return errs.New("cache file %q must be mode 0600", info.Name()) + } + return nil +} diff --git a/internal/runbits/orgkey/cache_win.go b/internal/runbits/orgkey/cache_win.go new file mode 100644 index 0000000000..94970f34ca --- /dev/null +++ b/internal/runbits/orgkey/cache_win.go @@ -0,0 +1,12 @@ +//go:build windows +// +build windows + +package orgkey + +import "os" + +// checkCacheMode is a no-op on Windows, where POSIX permission bits do not +// apply; the cache file is written to the owner's config directory. +func checkCacheMode(info os.FileInfo) error { + return nil +} diff --git a/internal/runbits/orgkey/env.go b/internal/runbits/orgkey/env.go new file mode 100644 index 0000000000..b57e193002 --- /dev/null +++ b/internal/runbits/orgkey/env.go @@ -0,0 +1,16 @@ +package orgkey + +import "github.com/ActiveState/cli/internal/constants" + +// stringConfigReader reads string-valued config options. +type stringConfigReader interface { + GetString(key string) string +} + +// SanitizeChildEnv removes private-ingredient key-service credentials from env +// so they are never propagated to child process environments. +func SanitizeChildEnv(cfg stringConfigReader, env map[string]string) { + if tokenEnv := cfg.GetString(constants.PrivateIngredientBearerTokenEnvConfig); tokenEnv != "" { + delete(env, tokenEnv) + } +} diff --git a/internal/runbits/orgkey/helpers_test.go b/internal/runbits/orgkey/helpers_test.go new file mode 100644 index 0000000000..108f667f36 --- /dev/null +++ b/internal/runbits/orgkey/helpers_test.go @@ -0,0 +1,131 @@ +package orgkey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +// testKey is a fixed 32-byte AES-256 key used across tests. +func testKey() []byte { + k := make([]byte, artifactcrypto.KeySize) + for i := range k { + k[i] = byte(i + 1) + } + return k +} + +// fakeConfig is an in-memory implementation of configurable. +type fakeConfig struct { + strings map[string]string + bools map[string]bool + dir string +} + +func newFakeConfig(t *testing.T) *fakeConfig { + return &fakeConfig{ + strings: map[string]string{}, + bools: map[string]bool{}, + dir: t.TempDir(), + } +} + +func (f *fakeConfig) GetString(key string) string { return f.strings[key] } +func (f *fakeConfig) GetBool(key string) bool { return f.bools[key] } +func (f *fakeConfig) ConfigPath() string { return f.dir } + +// contractFields returns a valid contract as a field map, so tests can mutate +// individual fields before marshaling. +func contractFields(key []byte, org, keyID string) map[string]string { + return map[string]string{ + "schema": contractSchema, + "org": org, + "key_id": keyID, + "algorithm": contractAlgorithm, + "encoding": contractEncoding, + "key": "b64:" + base64.StdEncoding.EncodeToString(key), + "fingerprint": artifactcrypto.Fingerprint(key), + } +} + +func mustJSON(t *testing.T, v interface{}) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// writeServerCA writes the test server's certificate to a temp PEM file and +// returns its path, for use as the configured key-service CA. +func writeServerCA(t *testing.T, srv *httptest.Server) string { + t.Helper() + path := filepath.Join(t.TempDir(), "ca.pem") + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + t.Fatal(err) + } + return path +} + +// genClientCert generates a self-signed client certificate and key, writes them +// to temp files, and returns their paths (for the mTLS path). +func genClientCert(t *testing.T) (certPath, keyPath string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + certPath = filepath.Join(dir, "client.crt") + keyPath = filepath.Join(dir, "client.key") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + +// configForServer returns a fakeConfig pointed at srv with its CA trusted. +func configForServer(t *testing.T, srv *httptest.Server) *fakeConfig { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = srv.URL + cfg.strings[constants.PrivateIngredientKeyServiceCAConfig] = writeServerCA(t, srv) + return cfg +} diff --git a/internal/runbits/orgkey/https.go b/internal/runbits/orgkey/https.go new file mode 100644 index 0000000000..9db12a2bb1 --- /dev/null +++ b/internal/runbits/orgkey/https.go @@ -0,0 +1,219 @@ +package orgkey + +import ( + "context" + "crypto/tls" + "crypto/x509" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/logging" +) + +const ( + // fetchTimeout bounds a single key-service request. + fetchTimeout = 15 * time.Second + // maxResponseBytes caps the contract response read from the key service. + maxResponseBytes = 1 << 20 // 1 MiB +) + +// provider fetches the org key over HTTPS and caches it in memory for the run. +type provider struct { + cfg configurable + owner string + + mu sync.Mutex + done bool + key []byte + keyID string + err error +} + +// New returns a Provider that reads its key-service configuration from cfg and +// validates the fetched key against owner (the project's organization). +func New(cfg configurable, owner string) Provider { + return &provider{cfg: cfg, owner: owner} +} + +func (p *provider) Configured() bool { + return p.cfg.GetString(constants.PrivateIngredientKeyServiceURLConfig) != "" +} + +// Key fetches and validates the org key on first call and returns the cached +// result (including a cached error) on every subsequent call in the run. +func (p *provider) Key(ctx context.Context) ([]byte, string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.done { + return p.key, p.keyID, p.err + } + p.done = true + p.key, p.keyID, p.err = p.load(ctx) + return p.key, p.keyID, p.err +} + +func (p *provider) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for i := range p.key { + p.key[i] = 0 + } + p.key = nil +} + +func (p *provider) load(ctx context.Context) (key []byte, keyID string, err error) { + if !p.Configured() { + return nil, "", ErrNotConfigured + } + + if p.diskCacheEnabled() { + if raw, ok := p.readDiskCache(); ok { + if key, keyID, err := validateContract(raw, p.owner); err == nil { + return key, keyID, nil + } else { + logging.Warning("Ignoring invalid on-disk org key cache: %v", errs.JoinMessage(err)) + } + } + } + + raw, err := p.fetch(ctx) + if err != nil { + return nil, "", errs.Wrap(err, "unable to fetch org key") + } + key, keyID, err = validateContract(raw, p.owner) + if err != nil { + return nil, "", errs.Wrap(err, "unable to validate org key") + } + + if p.diskCacheEnabled() { + if werr := p.writeDiskCache(raw); werr != nil { + logging.Warning("Could not cache org key on disk: %v", errs.JoinMessage(werr)) + } + } + return key, keyID, nil +} + +// fetch performs the HTTPS GET against the configured key service and returns +// the raw contract body. +func (p *provider) fetch(ctx context.Context) ([]byte, error) { + base := p.cfg.GetString(constants.PrivateIngredientKeyServiceURLConfig) + u, err := url.Parse(base) + if err != nil { + return nil, errs.Wrap(err, "unable to parse key service URL") + } + if u.Scheme != "https" { + return nil, ErrInsecureURL + } + u.Path = strings.TrimRight(u.Path, "/") + endpointPath + + client, err := p.httpClient() + if err != nil { + return nil, errs.Wrap(err, "unable to build key service HTTP client") + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, errs.Wrap(err, "unable to build key service request") + } + if err := p.applyAuth(req); err != nil { + return nil, errs.Wrap(err, "unable to apply key service authentication") + } + + resp, err := client.Do(req) + if err != nil { + return nil, errs.Wrap(err, "unable to reach key service") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errs.New("key service returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, errs.Wrap(err, "unable to read key service response") + } + return body, nil +} + +// httpClient builds an HTTPS client enforcing TLS 1.2+, the configured CA or +// pinned certificate, optional mTLS, and a refusal to follow redirects (the URL +// is pinned). +func (p *provider) httpClient() (*http.Client, error) { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + + if caPath := p.cfg.GetString(constants.PrivateIngredientKeyServiceCAConfig); caPath != "" { + pem, err := os.ReadFile(caPath) + if err != nil { + return nil, errs.Wrap(err, "unable to read key service CA file") + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, errs.New("key service CA file contains no valid certificates") + } + tlsCfg.RootCAs = pool + } + + certPath := p.cfg.GetString(constants.PrivateIngredientMTLSCertConfig) + keyPath := p.cfg.GetString(constants.PrivateIngredientMTLSKeyConfig) + if certPath != "" || keyPath != "" { + if certPath == "" || keyPath == "" { + return nil, errs.New("mTLS requires both a client certificate and a client key") + } + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, errs.Wrap(err, "unable to load mTLS client certificate") + } + tlsCfg.Certificates = []tls.Certificate{cert} + } + + return &http.Client{ + Timeout: fetchTimeout, + Transport: &http.Transport{ + TLSClientConfig: tlsCfg, + Proxy: http.ProxyFromEnvironment, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return errs.New("key service redirects are not allowed") + }, + }, nil +} + +// applyAuth attaches a bearer token to the request when one is configured. mTLS +// (if configured) is applied at the transport layer in httpClient. +func (p *provider) applyAuth(req *http.Request) error { + token, err := p.bearerToken() + if err != nil { + return errs.Wrap(err, "unable to read bearer token") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return nil +} + +// bearerToken reads the short-lived bearer token from the configured env var or +// file. It never returns the token in an error. +func (p *provider) bearerToken() (string, error) { + if envName := p.cfg.GetString(constants.PrivateIngredientBearerTokenEnvConfig); envName != "" { + return strings.TrimSpace(os.Getenv(envName)), nil + } + if path := p.cfg.GetString(constants.PrivateIngredientBearerTokenFileConfig); path != "" { + b, err := os.ReadFile(path) + if err != nil { + return "", errs.Wrap(err, "unable to read bearer token file") + } + return strings.TrimSpace(string(b)), nil + } + return "", nil +} diff --git a/internal/runbits/orgkey/https_test.go b/internal/runbits/orgkey/https_test.go new file mode 100644 index 0000000000..b5fbd9d86a --- /dev/null +++ b/internal/runbits/orgkey/https_test.go @@ -0,0 +1,253 @@ +package orgkey + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +// keyServiceHandler serves a valid contract at endpointPath and counts requests. +// When expectToken is non-empty, it requires a matching bearer token. +func keyServiceHandler(t *testing.T, key []byte, org, keyID, expectToken string, fetches *atomic.Int32) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fetches.Add(1) + if r.URL.Path != endpointPath { + w.WriteHeader(http.StatusNotFound) + return + } + if expectToken != "" && r.Header.Get("Authorization") != "Bearer "+expectToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write(mustJSON(t, contractFields(key, org, keyID))) + } +} + +func TestKeyHappyPathAndSingleFetch(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid-1", "secret-token", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientBearerTokenEnvConfig] = "ORGKEY_TOKEN" + t.Setenv("ORGKEY_TOKEN", "secret-token") + + p := New(cfg, "myorg") + defer p.Close() + + gotKey, gotID, err := p.Key(context.Background()) + if err != nil { + t.Fatalf("Key: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } + if gotID != "kid-1" { + t.Errorf("keyID = %q, want kid-1", gotID) + } + + // Subsequent calls reuse the in-run cache: still exactly one fetch. + for i := 0; i < 3; i++ { + if _, _, err := p.Key(context.Background()); err != nil { + t.Fatalf("Key (cached): %v", err) + } + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetch count = %d, want exactly 1", got) + } +} + +func TestKeyBearerTokenFromFile(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "file-token", &fetches)) + defer srv.Close() + + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("file-token\n"), 0600); err != nil { // trailing newline is trimmed + t.Fatal(err) + } + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientBearerTokenFileConfig] = tokenFile + + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("Key (bearer file): %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } +} + +func TestKeyRefusesNonHTTPS(t *testing.T) { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = "http://insecure.example.com" + + _, _, err := New(cfg, "myorg").Key(context.Background()) + if !errors.Is(err, ErrInsecureURL) { + t.Fatalf("error = %v, want ErrInsecureURL", err) + } +} + +func TestKeyRejectsUntrustedCertificate(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + // Point at the server but do NOT configure its CA, so its self-signed cert + // is untrusted. + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = srv.URL + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err == nil { + t.Fatal("expected a TLS verification error for an untrusted certificate") + } +} + +func TestKeyFailsClosedOnTimeout(t *testing.T) { + key := testKey() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + defer srv.Close() + cfg := configForServer(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + if _, _, err := New(cfg, "myorg").Key(ctx); err == nil { + t.Fatal("expected a timeout error, got nil") + } +} + +func TestKeyMTLS(t *testing.T) { + key := testKey() + certPath, keyPath := genClientCert(t) + + var sawClientCert bool + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawClientCert = len(r.TLS.PeerCertificates) > 0 + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + srv.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert} + srv.StartTLS() + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientMTLSCertConfig] = certPath + cfg.strings[constants.PrivateIngredientMTLSKeyConfig] = keyPath + + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("Key (mTLS): %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } + if !sawClientCert { + t.Error("server did not receive a client certificate") + } +} + +func TestKeyRejectsTLSBelow12(t *testing.T) { + key := testKey() + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + srv.TLS = &tls.Config{MaxVersion: tls.VersionTLS11} + srv.StartTLS() + defer srv.Close() + cfg := configForServer(t, srv) + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err == nil { + t.Fatal("expected handshake failure against a TLS 1.1 server") + } +} + +func TestNotConfiguredIsNoOp(t *testing.T) { + cfg := newFakeConfig(t) // no URL set + p := New(cfg, "myorg") + if p.Configured() { + t.Error("Configured() = true with no URL set") + } + if _, _, err := p.Key(context.Background()); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestOnDiskCacheReusedAcrossRuns(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.bools[constants.PrivateIngredientCacheKeyConfig] = true + + // First run fetches over the network and writes the cache. + if _, _, err := New(cfg, "myorg").Key(context.Background()); err != nil { + t.Fatalf("first Key: %v", err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("fetch count after first run = %d, want 1", got) + } + + cachePath := filepath.Join(cfg.dir, cacheFileName) + info, err := os.Stat(cachePath) + if err != nil { + t.Fatalf("cache file not written: %v", err) + } + if runtime.GOOS != "windows" && info.Mode()&0177 != 0 { + t.Errorf("cache file mode = %v, want 0600", info.Mode()) + } + + // Second run (fresh provider, same config dir) reads from disk: no new fetch. + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("second Key: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("cached key does not match") + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetch count after cached run = %d, want still 1", got) + } +} + +func TestMemoryOnlyWritesNothingToDisk(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) // cache opt-in left false (default) + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err != nil { + t.Fatalf("Key: %v", err) + } + if _, err := os.Stat(filepath.Join(cfg.dir, cacheFileName)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("cache file should not exist in memory-only mode (stat err = %v)", err) + } +} + +// sanity: artifactcrypto.KeySize is what the contract helpers assume. +func TestKeySizeAssumption(t *testing.T) { + if artifactcrypto.KeySize != 32 { + t.Fatalf("unexpected KeySize %d", artifactcrypto.KeySize) + } +} diff --git a/internal/runbits/orgkey/orgkey.go b/internal/runbits/orgkey/orgkey.go new file mode 100644 index 0000000000..120fa6591e --- /dev/null +++ b/internal/runbits/orgkey/orgkey.go @@ -0,0 +1,122 @@ +// Package orgkey fetches and validates an organization's single AES-256 +// encryption key from the customer-hosted HTTPS key service, caches it for the +// duration of a run, and hands the raw key bytes to the artifactcrypto +// primitives. The key is read only from the customer's own service and is never +// placed in a request to the ActiveState Platform. +// +// The custody backend lives caller-side (it makes network calls and reads +// config). +package orgkey + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + configMediator "github.com/ActiveState/cli/internal/mediators/config" +) + +func init() { + configMediator.RegisterOption(constants.PrivateIngredientKeyServiceURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientKeyServiceCAConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientMTLSCertConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientMTLSKeyConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientBearerTokenEnvConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientBearerTokenFileConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientCacheKeyConfig, configMediator.Bool, false) +} + +var ( + // ErrNotConfigured indicates no key-service URL has been configured. + ErrNotConfigured = errs.New("org key service is not configured") + // ErrInsecureURL indicates the configured key-service URL is not https. + ErrInsecureURL = errs.New("org key service URL must use https") + // ErrUnknownSchema indicates the contract's schema field is not recognized. + ErrUnknownSchema = errs.New("org key contract has an unrecognized schema") + // ErrOrgMismatch indicates the contract is for a different organization than the project's. + ErrOrgMismatch = errs.New("org key does not belong to this project's organization") + // ErrBadAlgorithm indicates the contract specifies an unsupported algorithm. + ErrBadAlgorithm = errs.New("org key contract specifies an unsupported algorithm") + // ErrBadEncoding indicates the contract's key encoding is unsupported or the key is not valid base64. + ErrBadEncoding = errs.New("org key contract specifies an unsupported or invalid key encoding") + // ErrBadKeyLength indicates the decoded key is not a 32-byte AES-256 key. + ErrBadKeyLength = errs.New("org key must be 32 bytes (AES-256)") + // ErrFingerprintMismatch indicates the decoded key does not match its stated fingerprint. + ErrFingerprintMismatch = errs.New("org key does not match its stated fingerprint") +) + +const ( + contractSchema = "activestate.pim.orgkey/v1" + contractAlgorithm = "AES-256-GCM" + contractEncoding = "base64" + // endpointPath is appended to the configured base URL to form the request URL. + endpointPath = "/v1/org-key" +) + +// configurable is the subset of the config instance this package reads. +type configurable interface { + GetString(key string) string + GetBool(key string) bool + ConfigPath() string +} + +// Provider supplies the organization's AES-256 key for a run. Implementations +// fetch and validate the key on first use and return the cached value +// thereafter; the at-rest backend is swappable behind this interface. +type Provider interface { + // Configured reports whether a key service has been configured. When it + // returns false the provider is a no-op and Key returns ErrNotConfigured. + Configured() bool + // Key returns the raw 32-byte org key and its id for this run. + Key(ctx context.Context) (key []byte, keyID string, err error) + // Close zeroizes any in-memory key material held by the provider. + Close() +} + +// contract is the org-key JSON document served by the key service. +type contract struct { + Schema string `json:"schema"` + Org string `json:"org"` + KeyID string `json:"key_id"` + Algorithm string `json:"algorithm"` + Encoding string `json:"encoding"` + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` +} + +// validateContract parses raw, checks it against the expected organization, and +// returns the decoded 32-byte key and its id. Errors never include the key +// bytes. +func validateContract(raw []byte, expectedOrg string) (key []byte, keyID string, err error) { + var c contract + if err := json.Unmarshal(raw, &c); err != nil { + return nil, "", errs.Wrap(err, "unable to parse org key contract") + } + if c.Schema != contractSchema { + return nil, "", ErrUnknownSchema + } + if !strings.EqualFold(c.Org, expectedOrg) { + return nil, "", ErrOrgMismatch + } + if c.Algorithm != contractAlgorithm { + return nil, "", ErrBadAlgorithm + } + if c.Encoding != contractEncoding { + return nil, "", ErrBadEncoding + } + key, decErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(c.Key, "b64:")) + if decErr != nil { + return nil, "", ErrBadEncoding + } + if len(key) != artifactcrypto.KeySize { + return nil, "", ErrBadKeyLength + } + if artifactcrypto.Fingerprint(key) != c.Fingerprint { + return nil, "", ErrFingerprintMismatch + } + return key, c.KeyID, nil +} diff --git a/internal/runbits/orgkey/preflight.go b/internal/runbits/orgkey/preflight.go new file mode 100644 index 0000000000..87cc25960f --- /dev/null +++ b/internal/runbits/orgkey/preflight.go @@ -0,0 +1,23 @@ +package orgkey + +import ( + "io" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/errs" +) + +// PreflightKey confirms that key matches the artifact whose encrypted payload +// begins at r, checking the payload header's fingerprint before any body is +// read, so publish can fail before upload and pull can fail before a body +// transfer. A mismatch returns an error matching artifactcrypto.ErrWrongKey. +func PreflightKey(r io.Reader, key []byte) error { + header, err := artifactcrypto.ParseHeader(r) + if err != nil { + return errs.Wrap(err, "unable to read artifact header") + } + if err := header.CheckKey(key); err != nil { + return errs.Wrap(err, "key does not match artifact") + } + return nil +} diff --git a/internal/runbits/orgkey/validate_test.go b/internal/runbits/orgkey/validate_test.go new file mode 100644 index 0000000000..ddbeb50318 --- /dev/null +++ b/internal/runbits/orgkey/validate_test.go @@ -0,0 +1,109 @@ +package orgkey + +import ( + "bytes" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +func TestValidateContract(t *testing.T) { + key := testKey() + + tests := []struct { + name string + mutate func(m map[string]string) + wantErr error // nil means success + }{ + {name: "valid", mutate: func(map[string]string) {}}, + {name: "unknown schema", mutate: func(m map[string]string) { m["schema"] = "something/v9" }, wantErr: ErrUnknownSchema}, + {name: "org mismatch", mutate: func(m map[string]string) { m["org"] = "someoneelse" }, wantErr: ErrOrgMismatch}, + {name: "bad algorithm", mutate: func(m map[string]string) { m["algorithm"] = "AES-128-GCM" }, wantErr: ErrBadAlgorithm}, + {name: "bad encoding", mutate: func(m map[string]string) { m["encoding"] = "hex" }, wantErr: ErrBadEncoding}, + {name: "invalid base64", mutate: func(m map[string]string) { m["key"] = "b64:!!!notbase64!!!" }, wantErr: ErrBadEncoding}, + {name: "wrong key length", mutate: func(m map[string]string) { + m["key"] = "b64:" + base64.StdEncoding.EncodeToString([]byte("too short")) + }, wantErr: ErrBadKeyLength}, + {name: "fingerprint mismatch", mutate: func(m map[string]string) { m["fingerprint"] = "sha256:deadbeef" }, wantErr: ErrFingerprintMismatch}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fields := contractFields(key, "myorg", "kid-1") + tc.mutate(fields) + gotKey, gotID, err := validateContract(mustJSON(t, fields), "myorg") + + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("error = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("decoded key does not match") + } + if gotID != "kid-1" { + t.Errorf("keyID = %q, want kid-1", gotID) + } + }) + } +} + +func TestValidateContractOrgIsCaseInsensitive(t *testing.T) { + key := testKey() + fields := contractFields(key, "MyOrg", "kid") + if _, _, err := validateContract(mustJSON(t, fields), "myorg"); err != nil { + t.Fatalf("expected case-insensitive org match, got %v", err) + } +} + +func TestValidateContractRejectsGarbageJSON(t *testing.T) { + if _, _, err := validateContract([]byte("not json"), "myorg"); err == nil { + t.Fatal("expected an error for non-JSON contract") + } +} + +func TestPreflightKey(t *testing.T) { + key := testKey() + other := make([]byte, artifactcrypto.KeySize) // all zeros, different key + + var payload bytes.Buffer + if err := artifactcrypto.Encrypt(strings.NewReader("private wheel"), &payload, key, "kid"); err != nil { + t.Fatal(err) + } + + if err := PreflightKey(bytes.NewReader(payload.Bytes()), key); err != nil { + t.Errorf("PreflightKey(correct key) = %v, want nil", err) + } + if err := PreflightKey(bytes.NewReader(payload.Bytes()), other); !errors.Is(err, artifactcrypto.ErrWrongKey) { + t.Errorf("PreflightKey(wrong key) = %v, want ErrWrongKey", err) + } +} + +func TestSanitizeChildEnv(t *testing.T) { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientBearerTokenEnvConfig] = "ORGKEY_TOKEN" + + env := map[string]string{"ORGKEY_TOKEN": "secret", "PATH": "/usr/bin"} + SanitizeChildEnv(cfg, env) + if _, ok := env["ORGKEY_TOKEN"]; ok { + t.Error("bearer-token env var was not scrubbed") + } + if env["PATH"] != "/usr/bin" { + t.Error("unrelated env var was removed") + } + + // No configured token var: nothing is removed. + env2 := map[string]string{"PATH": "/usr/bin"} + SanitizeChildEnv(newFakeConfig(t), env2) + if len(env2) != 1 { + t.Error("env modified when no token var configured") + } +} diff --git a/internal/subshell/subshell.go b/internal/subshell/subshell.go index f547a1b865..dc13ce32f7 100644 --- a/internal/subshell/subshell.go +++ b/internal/subshell/subshell.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveState/cli/internal/osutils" "github.com/ActiveState/cli/internal/output" "github.com/ActiveState/cli/internal/rollbar" + "github.com/ActiveState/cli/internal/runbits/orgkey" "github.com/ActiveState/cli/internal/subshell/bash" "github.com/ActiveState/cli/internal/subshell/cmd" "github.com/ActiveState/cli/internal/subshell/fish" @@ -123,7 +124,10 @@ func New(cfg sscommon.Configurable) SubShell { logging.Debug("Using binary: %s", path) subs.SetBinary(path) - err := subs.SetEnv(osutils.EnvSliceToMap(os.Environ())) + env := osutils.EnvSliceToMap(os.Environ()) + orgkey.SanitizeChildEnv(cfg, env) + + err := subs.SetEnv(env) if err != nil { // We cannot error here, but this error will resurface when activating a runtime, so we can // notify the user at that point. diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 95c2e0bf53..63431b74a7 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -15,6 +15,15 @@ func WithAuthToken(token string) SetOpt { return func(opts *Opts) { opts.AuthToken = token } } +// WithDecryptionKey supplies the organization AES-256 key (and its id) used to +// decrypt private artifacts during install. +func WithDecryptionKey(key []byte, keyID string) SetOpt { + return func(opts *Opts) { + opts.OrgKey = key + opts.OrgKeyID = keyID + } +} + func WithBuildlogFilePath(path string) SetOpt { return func(opts *Opts) { opts.BuildlogFilePath = path } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 7c6e210ae6..9c9a41c829 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -54,6 +54,12 @@ type Opts struct { // the server can authorize the stream. Empty for unauthenticated callers. AuthToken string + // OrgKey is the organization AES-256 key used to decrypt private artifacts + // during install, with OrgKeyID identifying which key it is. Both are empty + // when the runtime has no private ingredients. + OrgKey []byte + OrgKeyID string + FromArchive *fromArchive // Annotations are used strictly to pass information for the purposes of analytics