Skip to content

Credentials & vault

The credentials subsystem gives every component an encrypted-at-rest local vault of named, versioned, opaque-byte secrets. It is offline-first: it runs entirely from the local vault and can optionally be seeded and refreshed from AWS Secrets Manager. The on-disk format is byte-identical across all four SDKs, so a vault written by one language opens in any other.

The subsystem is opt-in. The getCredentials() accessor returns nothing unless a credentials section is present in your component config (and, in Rust, unless the credentials cargo feature is also built). Keys you read and write are transparently namespaced by {ThingName}/{ComponentName}, so two components never collide.

Add a credentials section to your component config. The minimal form turns on a local-only vault with a file KeyProvider; this same JSON is read identically by all four SDKs.

{
"component": { "name": "com.example.MyComponent" },
"credentials": {
"vault": {
"path": "vault",
"keyProvider": { "type": "file" },
"keepVersions": 2
},
"audit": { "enabled": true }
}
}

Defaults if you omit fields: vault.path = vault, keyProvider.type = file (or env on KUBERNETES — see KeyProviders), keepVersions = 2, audit.enabled = true, and no central sync. The vault.path supports template variables such as {ThingName}.

In hierarchical-config deployments, shared provider settings such as central sync from mappings can live in an inherited hierarchy layer. The vault itself remains per component unless you deliberately configure a served credentials manager; inheriting settings does not create one shared on-disk vault file.

Rust has a double gate — the credentials config section and the matching cargo feature. Without the credentials cargo feature the gg.credentials() accessor is compiled out entirely: the method is absent, so a call site referencing it will not compile. The None return applies only when the feature is built but no credentials config section is present.

[dependencies]
edgecommons = { version = "*", features = ["credentials"] }
# add "credentials-aws" for kms / greengrass KeyProviders and central AWS Secrets Manager sync,
# and "credentials-pkcs11" for the pkcs11 (HSM/TPM) provider.

Java, Python, and TypeScript have no compile-time gate; their optional providers (KMS, PKCS#11) load their dependencies lazily at runtime.

The accessor returns the service when configured, and a per-language “absent” value when there is no credentials section. Always guard for absent before using it.

// returns CredentialService, or null when there is no `credentials` section
CredentialService creds = gg.getCredentials();
if (creds == null) return;

get(name) returns the latest version as a Secret (or absent). The Secret exposes its bytes plus metadata (name, version, createdMs, source, contentType, labels). Convenience getters — getBytes / getString / getJson — skip straight to the decoded value.

Optional<Secret> s = creds.get("db/password");
s.ifPresent(v -> LOGGER.info("bytes={} source={}", v.bytes().length, v.source()));
// Decode straight to a value:
Optional<byte[]> raw = creds.getBytes("db/password");
Optional<String> pw = creds.getString("db/password");
Optional<JsonElement> cfg = creds.getJson("svc/config");
// A specific version, existence, listing, and version history:
Optional<Secret> v1 = creds.getVersion("db/password", "1");
boolean present = creds.exists("db/password");
List<SecretMeta> all = creds.list(""); // prefix filter; "" = everything
List<String> history = creds.versions("db/password");

Four convenience views parse a secret whose value is canonical JSON into a typed object. They are present-or-absent for a missing secret, and throw on the wrong shape (a CredentialException in Java, CredentialError in Python/TypeScript, a serde deserialization error in Rust).

View Canonical JSON keys
AWS credentials accessKeyId, secretAccessKey, sessionToken?, expiry?
Basic auth username, password
TLS bundle certPem, keyPem, caPem?
Kafka SASL mechanism? (default PLAIN), username, password
Optional<BasicAuth> ba = creds.getBasicAuth("svc/login");
ba.ifPresent(a -> connect(a.username(), a.password()));
Optional<AwsCredentials> aws = creds.getAwsCredentials("aws/uploader");
Optional<TlsBundle> tls = creds.getTlsBundle("mtls/broker");
Optional<KafkaSasl> sasl = creds.getKafkaSasl("kafka/ingest");
// records expose accessKeyId(), certPem(), mechanism(), etc.

put(name, value) stores bytes and returns the new version id. It keeps the last keepVersions versions (default 2). Optional metadata — ttlSecs, labels, contentType — is passed as a PutOptions object in Java/Rust/TypeScript, and as keyword arguments in Python.

creds.put("db/password", "s3cr3t".getBytes(StandardCharsets.UTF_8));
creds.putString("db/password", "s3cr3t"); // Java-only string convenience
// with metadata:
String version = creds.put(
"api/token",
token,
new PutOptions().ttlSecs(3600).contentType("text/plain"));

PutOptions is a mutable fluent class (ttlSecs / labels / contentType setters; public source / centralVersionId fields). putString exists only in Java.

Delete a secret (all versions) with delete(name), which returns whether anything was removed.

The vault encrypts each secret’s data-encryption key (DEK) under a key-encryption key (KEK) held by a KeyProvider, selected by vault.keyProvider.type. The effective type is explicit type ▸ platform default ▸ "file", and the platform default is env on KUBERNETES and file everywhere else.

type KEK custodian Key fields
file (default) A local 32-byte keyfile keyPath
env A base64 KEK from an env var / mounted Secret — the offline software KEK, default on KUBERNETES envVar (default EDGECOMMONS_VAULT_KEK)
kms / greengrass (aliases) AWS KMS via TES kmsKeyId, region, endpointUrl
pkcs11 HSM / TPM (wrap stays inside the token) modulePath, tokenLabel, keyLabel, and pinEnv or pin

For pkcs11, supply the token’s User PIN either inline as pin or — preferably — via pinEnv, the name of an env var that holds it. Exactly one of the two is required (omitting both errors at open time), and pinEnv takes precedence when both are set.

{
"credentials": {
"vault": {
"keyProvider": { "type": "env", "envVar": "EDGECOMMONS_VAULT_KEK" }
}
}
}

The env provider reads a base64-encoded 32-byte KEK from the named variable (default EDGECOMMONS_VAULT_KEK), tolerates a trailing newline, and errors on unset/empty/bad-base64/wrong length. It is crypto-identical to file given the same raw key — a vault wrapped with env opens under a file provider with the same KEK and vice versa.

Terminal window
# A 32-byte KEK, base64-encoded, in the default variable name:
export EDGECOMMONS_VAULT_KEK="$(head -c 32 /dev/urandom | base64)"

Set credentials.central.type to awsSecretsManager to seed and refresh the local vault from AWS Secrets Manager. With type = none (the default) the vault is local-only. region selects the AWS region; endpointUrl is an optional override for the Secrets Manager endpoint — point it at LocalStack/floci or a VPC endpoint, and omit it for real AWS. Listed secrets are pulled on startup (bootstrapOnStart, default true) and every refreshIntervalSecs (default 300). Each sync.secrets entry is a bare name (whose central id is the device-namespaced path) or { "name": ..., "from": ... } to pull from an explicit or fleet-shared central id.

{
"credentials": {
"central": {
"type": "awsSecretsManager",
"region": "us-east-1",
"refreshIntervalSecs": 300,
"bootstrapOnStart": true,
"sync": {
"secrets": [
"db/password",
{ "name": "shared/ca", "from": "fleet/prod/ca-bundle" }
]
}
}
}
}

Call refresh() to force an immediate pull (it is a no-op when central sync is not configured).

creds.refresh(); // synchronous; blocks until the pull completes

An unknown central.type is rejected at startup in every language.

A config value can reference a vault secret instead of embedding it, so config files carry no plaintext credentials. Use {"$secret":"name"} to substitute the secret’s whole value as a string, or {"$secret":"name","field":"k"} to substitute field k of the secret’s JSON value (the field must itself be a string). Resolution fails fast if the secret — or the named field — is absent.

Where it applies. $secret references are resolved for the streaming config section only, automatically at streaming start-up (after template-variable substitution, and only when a vault is configured). Messaging and the other config sections are not passed through the resolver, so a $secret placed outside streaming is left untouched as a literal object — don’t rely on it there.

A valid use is a sink credential the sink actually reads — for example a Kafka sink’s SASL password, supplied through the sink’s pass-through properties map:

{
"streaming": {
"streams": [
{
"name": "events",
"sink": {
"type": "kafka",
"bootstrapServers": "broker:9092",
"topic": "telemetry",
"properties": {
"security.protocol": "SASL_SSL",
"sasl.mechanism": "PLAIN",
"sasl.username": "uploader",
"sasl.password": { "$secret": "kafka/uploader", "field": "password" }
}
},
"buffer": { "type": "disk", "path": "/var/lib/edgecommons/streams/events" }
}
]
}
}

Resolution detail: in Java and TypeScript the resolver returns a new (deep-copied) structure and never mutates the input; in Python and Rust it mutates the passed structure in place.

Every get / getVersion / put / delete is recorded to a dedicated audit logger — operation, name, version, source, and outcome, but never the value. The audit log is on by default; disable it with credentials.audit.enabled = false.

{ "credentials": { "audit": { "enabled": false } } }

The default sink writes to a per-language logger so you can route or filter it independently:

Language Audit logger / target
Java com.mbreissi.edgecommons.credentials.audit
Python edgecommons.credentials.audit
Rust edgecommons::credentials::audit (tracing target)
TypeScript the LogAuditSink logger

A Secret redacts its value from the default string form in every language, so accidentally logging the object does not leak the secret:

  • JavatoString() renders Secret{name=.., version=.., bytes=<N redacted>}.
  • Python__repr__ redacts.
  • Rust — a custom Debug redacts, and the bytes live in a Zeroizing buffer wiped on drop.
  • TypeScript — both toString() and toJSON() redact, so JSON.stringify(secret) is safe too.

This protects only the default representations. Calling secret.bytes() (or getString etc.) and logging the result yourself still leaks — don’t.