Skip to content

End-to-End Encryption & Zero-Knowledge Encryption ​

Secrets fetched through a Bella Baxter SDK are encrypted end-to-end between the server and your application — not just protected by TLS. Every SDK does this out of the box except Go, which needs a device key or EnableE2EE: true (see Per-SDK Reference). Zero-Knowledge Encryption (ZKE) extends this with a persistent device key for stronger audit trails and DEK lease caching.

What "zero-knowledge" does and does not mean here

Bella can read your secret values. The server decrypts them to serve a request, and it is the server that wraps the environment key.

What ZKE gives you is device identity: which machine read which secret, an audit trail that names it, and the ability to withdraw one machine without rotating anything else. It is not a claim that the platform is unable to read your secrets.

If you need that stronger property — encryption the server genuinely cannot reverse — it is separate work and not what this page describes. This also appears at the end of the page; it is repeated here because it belongs before you decide, not after.


Why E2EE on Top of TLS? ​

TLS protects secrets in transit against external eavesdroppers. E2EE adds a second layer:

  • TLS termination proxies (load balancers, API gateways, service meshes) see plaintext after decrypting TLS. E2EE ensures secrets remain opaque even at those points.
  • SDK transport encryption is independent of your infrastructure — it works the same whether you self-host on a bare VM or run behind a CDN.
  • Audit confidence — a fetch made with a registered device key is tied to that device on the audit trail, not just to a bearer token. See Registered devices.

Bella's E2EE uses ECDH key agreement (P-256) + AES-256-GCM — the same primitives used by Signal and TLS 1.3.


Two Modes ​

Default: Ephemeral E2EE ​

With no device key configured, the SDK generates a P-256 keypair of its own when the client is created, and presents its public half on every secrets request:

App                        Bella API
 │                              │
 │── X-E2E-Public-Key: <pub> ──▶│  Server generates shared secret via ECDH
 │                              │  Encrypts the response body with AES-GCM
 │◀─── encrypted body ─────────│
 │                              │
 │  SDK decrypts locally        │

The key lives as long as the client instance and is never stored or registered. The server's side of the exchange is fresh for every response. No configuration is needed; this is the default in every SDK except Go, which sends no key and receives plaintext over TLS unless you set EnableE2EE: true or supply a device key.

An ephemeral key is not a registered device, so under ZKE enforcement these reads are refused.

Optional: ZKE with Persistent Device Key ​

With ZKE, you supply a persistent PKCS#8 P-256 private key (your "device key"). The server uses the same key for every request from that device:

App                        Bella API
 │                              │
 │── X-E2E-Public-Key: <persistent pub> ──▶│
 │                              │  Wraps the per-environment DEK with your public key
 │◀─── X-Bella-Wrapped-Dek ────│  (in addition to the encrypted body)
 │◀─── encrypted body ─────────│
 │                              │
 │  SDK decrypts body AND       │
 │  caches the wrapped DEK      │

What you gain:

BenefitDetails
Device audit trailA fetch made with a registered device key carries that device's fingerprint on the audit row, so "which machine read this?" is answerable
DEK lease cachingThe wrapped DEK has a TTL; subsequent fetches reuse it without round-trips
RevocationWithdraw one machine without rotating API credentials — effective on that machine's next request

The wrapped DEK is released only to a registered device (or to the public key recorded against an API key at creation). An unregistered key still receives the secret values; it just does not receive the environment key. Treat X-Bella-Wrapped-Dek as optional — every SDK already does.


Getting Started with ZKE ​

1. Register this machine ​

sh
bella auth setup

This generates a P-256 keypair, stores the private half in an encrypted file at ~/.config/bella-cli/zke-private-key.dat with owner-only permissions, and registers the public half with Bella. It prints the device's fingerprint:

✅ Device registered.
   Fingerprint: SHA256:mF2v7Q9x0Zk1a2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P
   Label:       MacBook Pro
   Tenant:      acme

That fingerprint is what the console's Devices page and the audit trail show, so you can match a row to a machine by eye. The private key never leaves the machine — keep it as you would a private SSH key.

Registration is per tenant: the same laptop registers separately in each tenant it works in. The command is safe to re-run (it updates the label and nothing else), and --force generates a new device, leaving the previous one registered until you revoke it.

2. Hand the key to your SDK process ​

Launch the app through the CLI:

sh
bella sdk run -- <your app command>

bella sdk run sets BELLA_BAXTER_PRIVATE_KEY in the child process to this machine's device key, as PKCS#8 PEM. The file under ~/.config/bella-cli/ is encrypted, so do not cat it into the variable: the SDK would refuse the result. bella run does not inject the key and does not need to, because it hands the app the secret values themselves.

That is the developer-login path, where the child also gets BELLA_BAXTER_ACCESS_TOKEN. The Go, Python, PHP, Ruby and Swift SDKs have no access-token mode: they accept only a bax- API key, so the token never reaches them and they read with the key bella sdk run finds instead (a stored key from bella login --api-key, or BELLA_BAXTER_API_KEY).

An SDK that uses an API key needs that key's own registered device. An API key's only device is the public key recorded against it when it was created (or attached later from the console), not the developer's device from bella auth setup. With a bax- key and no BELLA_BAXTER_PRIVATE_KEY already exported, bella sdk run asks the server, signing as that key and presenting this machine's device key, whether the two are the same. Only on a confirmed yes does it set BELLA_BAXTER_PRIVATE_KEY; the server decides, not the CLI. Otherwise nothing is injected:

  • Tenant requires registered devices and this machine's device is not the key's: sdk run prints one warning naming this machine's device fingerprint and still starts the child, whose secret reads will be refused. Create the API key with this machine's device public key (or attach it to a key that has none), or export BELLA_BAXTER_PRIVATE_KEY as the private key of the key's own device.
  • The server could not be asked (network error, 5xx, 401): sdk run prints a dim note that the device key was not injected because the server could not confirm it belongs to this API key, and starts the child.
  • No device key on this machine, or a tenant that does not require registered devices: nothing is asked and nothing is printed.

In CI with keyless sign-in, you store no key. bella auth oidc makes a throwaway device key for each sign-in and binds its public half to the API key it issues. On GitHub Actions it also exports the private half, masked, as BELLA_BAXTER_PRIVATE_KEY, so later steps and SDK processes read it with no setup. Where it is not exported (GitLab, other platforms), bella run and bella sdk run sign in and keep the key in memory. The key is useful only with its API key, which expires with the trust domain's token lifetime. See Tenants that require registered devices.

Anywhere else (a container, a long-lived host), set BELLA_BAXTER_PRIVATE_KEY to a PKCS#8 P-256 private key in PEM, or pass it through the SDK's option (see the Per-SDK Reference). Its public half must be registered: as a device, or as the publicKey recorded on the API key the process uses.

All SDK framework integrations read this variable automatically. No code changes required for Django, FastAPI, Flask, Rails, Laravel, Spring Boot, ASP.NET Core, etc.

3. Optionally receive the wrapped DEK ​

If you want to log or cache the DEK lease, add a callback. Example in TypeScript:

typescript
const client = await createBaxterClient({
  baxterUrl: process.env.BELLA_BAXTER_URL!,
  apiKey: process.env.BELLA_BAXTER_API_KEY!,
  onWrappedDekReceived(project, env, wrappedDek, leaseExpires) {
    // leaseExpires is a Date — the DEK is valid until then
    console.log(`DEK for ${project}/${env} cached until ${leaseExpires}`)
  },
})

Registered devices ​

A device is one machine's public key, registered by a person. Registration is what makes the setting below mean something: before it existed, "ZKE enforcement" accepted any key a client happened to send, including one generated seconds earlier by a machine nobody had ever set up.

sh
bella auth devices list           # your devices in this tenant
bella auth devices list --all     # every device in the tenant (Owner/Admin)
bella auth devices revoke <id|fingerprint>
bella auth devices revoke <id|fingerprint> --rotate   # also put its projects on new keys

The console shows the same list under Settings → Devices.

Revocation is effective on that machine's next request — there is no cache to wait out. What it does not do is un-share knowledge: a key already handed to that machine still decrypts the values it decrypted before. Revoking offers to rotate the keys of every project the device received keys for, so the old keys open no current value. Rotation does not make the device forget what it already read, so rotate those secrets as well. See What revoking a device does, and rotating its keys.

A machine credential is a device too: an API key registers its public key when the key is created (publicKey on createApiKey). A key created without one is refused where enforcement is on, and the console marks it "no registered key".


ZKE enforcement ​

A tenant Owner can require a registered device for every secret read and for every credential the platform issues (Settings → Encryption → ZKE enforcement). With it on:

  • a CLI or SDK presenting a registered device key reads and obtains credentials normally;
  • anything else — no key, a malformed key, an unregistered key, a revoked device, or a device whose owner has left the tenant — is refused with 403 and told to run bella auth setup;
  • the console is exempt (a browser session holds no device key);
  • an assistant connected over MCP reads only through an API key with a registered public key;
  • a SPIFFE workload's lease reads metadata and hashes but never values: see SPIFFE and zero-knowledge encryption.

What "credential" covers ​

Five classes, governed identically on every surface — the console, the CLI, the SDKs and the assistant:

CredentialObtained by
second-factor codegetTotpCode, get_totp_code
SSH certificatesignSshKey, sign_ssh_key, bella ssh sign
access tokenissueEnvironmentToken, bella_issue_token
TLS certificateissuePkiCertificate
database credentialgenerateDynamicCredentials

The last two joined the set after spec 039 shipped: both hand back live secret material — a private key, and a database password — and both were reachable from a machine that had never registered anything. Nothing about them was ever meant to be outside enforcement; they were missing from the declared inventory, which is why the guard tests passed over them.

Creating an access key is not in this set. It is an administrative act gated by its own permissions, and it is the act through which a machine's device key is first recorded — requiring a device to register a device would lock every new machine out permanently.

Migration note. With enforcement on, bella ssh sign and bella ssh connect stop working from a machine that has no registered device. Run bella auth setup on the machines that need them. An access key created without a recorded public key is likewise refused for credentials — as it already was for secret reads.

Before enabling it, the console shows how many members and API keys would be locked out.


Backward Compatibility ​

ZKE is opt-in unless the tenant turns on enforcement. If no device key is supplied (no BELLA_BAXTER_PRIVATE_KEY and no key option):

  • every SDK except Go uses ephemeral E2EE, as it always has;
  • Go reads over plain TLS, as it always has, unless EnableE2EE: true is set;
  • no code changes or configuration are needed, and existing applications keep working, unless the tenant enforces ZKE: then every secrets read without a registered key is refused with 403.

If a device key is supplied, every SDK presents it and decrypts with it. A key that is set but cannot be read is an error (see the Per-SDK Reference for when it is raised). No SDK silently replaces it with an ephemeral key, because that key would not be registered and every read would then fail with a 403 whose cause is invisible from inside the application.


What ZKE Does NOT Change ​

  • The server still authorizes every request via your API key — ZKE is a transport and device-identity layer, not an auth bypass, and enforcement narrows who may read rather than widening it.
  • Secret values are decrypted by the SDK before being returned to your app code in both modes — your application always receives plaintext strings.
  • TLS is still required — E2EE and ZKE complement TLS, they don't replace it.
  • The platform can still read your secrets. Bella decrypts values server-side to serve them, and the environment key is wrapped by the server. ZKE gives you device identity, an attributable audit trail and revocation; it is not a claim that Bella cannot see your secrets. Anything that says otherwise is wrong.

Per-SDK Reference ​

SDKE2EE with no key configuredReads BELLA_BAXTER_PRIVATE_KEYKey formats acceptedOptions fieldCallback parameter
JavaScript / TypeScriptYes, ephemeral key per client (init())Yes, in Node (process.env)PKCS#8 PEM or base64 DERprivateKeyonWrappedDekReceived(project, env, wrappedDek, leaseExpires)
.NET (AddBellaSecrets)Yes, ephemeral key per clientYesPKCS#8 PEM or base64 DERoptions.PrivateKey— (logged internally)
PythonYes, ephemeral key per clientYesEC PEM (PKCS#8 or SEC1)private_key=on_wrapped_dek_received(project, env, wrapped_dek, lease_expires)
GoNo, plaintext over TLS unless EnableE2EE: true (then an ephemeral key per client)YesPKCS#8 PEM or base64 DEROptions.PrivateKeyPEM (opt out: DisableE2EE)Options.OnWrappedDEK(project, env, wrappedDEK, leaseExpires)
RubyYes, ephemeral key per clientYesEC PEM (PKCS#8 or SEC1)private_key:on_wrapped_dek_received:
PHPYes, ephemeral key per clientYesEC PEM (PKCS#8 or SEC1)$options->privateKey$options->onWrappedDekReceived
SwiftYes, ephemeral key per clientYes, on macOS and Linux (iOS/tvOS have no process environment)PKCS#8 PEM or base64 DERBellaClientOptions(privateKey:), from BellaClient.loadPrivateKey(pkcs8Pem:)onWrappedDekReceived:
JavaYes, ephemeral key per clientYesPKCS#8 PEM or base64 DER.privateKeyPem().onWrappedDekReceived()
DartYes, ephemeral key per clientOnly through BellaClient.fromEnv()PKCS#8 PEM or base64 DER (bellaPrivateKeyFromEnvValue)BellaClientOptions(privateKey:) (DER bytes)onWrappedDekReceived:

In every SDK a key that is set but unreadable is an error, raised when the client is created. The one exception is a Dart key passed as bytes in BellaClientOptions, which is parsed on the first request. No SDK falls back to an ephemeral key. The key must be a P-256 EC key.

→ See each SDK's page for a full code example.

Released under the ELv2 License.