VYPR
Medium severityNVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

nebula-mesh: Decrypted CA private key persists in heap after signing

CVE-2026-48025

Description

internal/pki/resolver.go:36-64 constructs a CAManager with the plaintext ed25519.PrivateKey after unwrapping via the master key; internal/pki/ca.go:13-16 stores it. Callers at internal/api/enroll.go:116, internal/api/updates.go:297, and internal/api/mobile_bundle.go:40 use the manager for one Sign() and drop the reference on function return — but the underlying slice contents are not wiped before release.

The keystore package's contract (internal/keystore/keystore.go doc: *"Callers MUST zeroise the returned plaintext DEK as soon as it is no longer needed"*) is not met by the CAManager consumer. Decrypted CA private keys persist in process heap until Go's GC scavenges the underlying slice — minutes to hours under load, indefinitely on idle servers.

Affected

All released versions up to v0.3.6.

Threat model

Memory-read access: core dump, ptrace, kernel swap to disk, container/VM snapshot, OOM-debug bundle, side-channel via shared cache lines. Not a remote-network vulnerability, but defeats the master-key + envelope-encryption design's promise of "private key never lingers".

Suggested fix

Add a Wipe() method on CAManager:

// internal/pki/ca.go
func (m *CAManager) Wipe() {
    if m == nil {
        return
    }
    keystore.Zeroize(m.caKey)
}

At each call site (enroll.go:116, updates.go:297, mobile_bundle.go:40, and any new caller), defer caMgr.Wipe() immediately after the Resolve() call. Pattern mirrors the existing defer keystore.Zeroize(dek) discipline in the keystore package.

Optional follow-up: wrap m.Sign() to zeroize after each call, removing the contract on callers — but the defer pattern is sufficient as a minimum.

Affected products

1

Patches

1
bca1d5914fba

fix(pki): zeroize decrypted CA key when manager construction fails (#181) (#184)

https://github.com/forgekeep/nebula-meshEvsyukov DenisJun 1, 2026via ghsa-ref
2 files changed · +81 1
  • internal/pki/resolver.go+15 1 modified
    @@ -60,5 +60,19 @@ func (r *CAResolver) LoadByID(ctx context.Context, caID string) (*CAManager, err
     	if err != nil {
     		return nil, fmt.Errorf("decrypt CA %s key: %w", caID, err)
     	}
    -	return LoadCAFromMaterial([]byte(c.CertPEM), ed25519.PrivateKey(keyBytes))
    +	return loadCAOrZeroize([]byte(c.CertPEM), keyBytes)
    +}
    +
    +// loadCAOrZeroize builds a CAManager from freshly-decrypted key material,
    +// transferring ownership of keyBytes to the manager on success (the caller
    +// later clears it via CAManager.Wipe). If construction fails — e.g. the cert
    +// is unparseable or not a CA — the plaintext key never reaches a manager, so
    +// it is zeroized here rather than left on the heap for the GC (#181).
    +func loadCAOrZeroize(certPEM, keyBytes []byte) (*CAManager, error) {
    +	mgr, err := LoadCAFromMaterial(certPEM, ed25519.PrivateKey(keyBytes))
    +	if err != nil {
    +		keystore.Zeroize(keyBytes)
    +		return nil, err
    +	}
    +	return mgr, nil
     }
    
  • internal/pki/zeroize_on_error_test.go+66 0 added
    @@ -0,0 +1,66 @@
    +package pki
    +
    +import (
    +	"bytes"
    +	"crypto/ed25519"
    +	"testing"
    +	"time"
    +)
    +
    +// TestLoadCAOrZeroize_WipesKeyOnError verifies that when CAManager construction
    +// fails after the key has already been decrypted, the plaintext key bytes are
    +// zeroized rather than left on the heap (#181).
    +func TestLoadCAOrZeroize_WipesKeyOnError(t *testing.T) {
    +	key := bytes.Repeat([]byte{0xAB}, ed25519.PrivateKeySize)
    +
    +	mgr, err := loadCAOrZeroize([]byte("-----BEGIN GARBAGE-----\nnope\n-----END GARBAGE-----\n"), key)
    +	if err == nil {
    +		t.Fatal("expected error for unparseable cert PEM")
    +	}
    +	if mgr != nil {
    +		t.Fatalf("expected nil manager on error, got %v", mgr)
    +	}
    +	for i, b := range key {
    +		if b != 0 {
    +			t.Fatalf("key not zeroized on error path: byte %d = %#x", i, b)
    +		}
    +	}
    +}
    +
    +// TestLoadCAOrZeroize_PreservesKeyOnSuccess verifies the success path transfers
    +// the key to the manager intact — the fix must NOT wipe a live signing key.
    +func TestLoadCAOrZeroize_PreservesKeyOnSuccess(t *testing.T) {
    +	src, err := NewCA("zeroize-test", time.Hour)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	certPEM, err := src.CACertPEM()
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	key := append(ed25519.PrivateKey(nil), src.RawKey()...) // copy so we own it
    +
    +	mgr, err := loadCAOrZeroize(certPEM, key)
    +	if err != nil {
    +		t.Fatalf("unexpected error on valid material: %v", err)
    +	}
    +	if mgr == nil {
    +		t.Fatal("expected a manager on success")
    +	}
    +	if allZero(key) {
    +		t.Fatal("success path wiped the key — would break signing")
    +	}
    +	// The manager must hold the same (aliased) key, ready to sign.
    +	if !bytes.Equal(mgr.RawKey(), key) {
    +		t.Fatal("manager key does not match input key")
    +	}
    +}
    +
    +func allZero(b []byte) bool {
    +	for _, x := range b {
    +		if x != 0 {
    +			return false
    +		}
    +	}
    +	return true
    +}
    

Vulnerability mechanics

Root cause

"The decrypted CA private key is not zeroized after use, persisting in memory until garbage collection."

Attack vector

An attacker with memory-read access, such as via a core dump or ptrace, can obtain the plaintext CA private key. This bypasses the intended security of master key and envelope encryption, as the private key lingers in process memory longer than expected [ref_id=2]. This is not a remote-network vulnerability, but rather a local privilege escalation or information disclosure.

Affected code

The vulnerability lies in the `internal/pki/resolver.go` file, specifically within the `LoadByID` function where a `CAManager` is constructed with a plaintext `ed25519.PrivateKey`. The issue persists because callers in `internal/api/enroll.go`, `internal/api/updates.go`, and `internal/api/mobile_bundle.go` do not zeroize the underlying slice contents after use, violating the `keystore` package's contract [ref_id=2].

What the fix does

The patch introduces a `loadCAOrZeroize` helper function that ensures the decrypted CA private key is zeroized if `LoadCAFromMaterial` fails [patch_id=5504845]. This prevents the plaintext key from being left on the heap in error scenarios. The advisory also suggests adding a `Wipe()` method to `CAManager` and using `defer caMgr.Wipe()` at call sites to explicitly clear the key after use, mirroring existing secure practices in the keystore package [ref_id=2].

Preconditions

  • inputAttacker must have memory-read access to the process's heap.

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.