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.
Enabling the vault
Section titled “Enabling the vault”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: enable the cargo feature
Section titled “Rust: enable the cargo feature”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.
Getting the service
Section titled “Getting the service”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` sectionCredentialService creds = gg.getCredentials();if (creds == null) return;# returns CredentialService, or None when there is no `credentials` sectioncreds = gg.get_credentials()if creds is None: return// returns Option<Arc<dyn CredentialService>> — None when there is no `credentials` section.// (This accessor only exists when the `credentials` feature is enabled; without it this call// would not compile, so reaching here implies the feature is built.)let Some(creds) = gg.credentials() else { return Ok(()); };// returns CredentialService | undefined when there is no `credentials` sectionconst creds = gg.credentials();if (!creds) return;Reading a secret
Section titled “Reading a secret”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; "" = everythingList<String> history = creds.versions("db/password");s = creds.get("db/password")if s is not None: logger.info("bytes=%d source=%s", len(s.bytes()), s.source)
# Decode straight to a value:raw = creds.get_bytes("db/password") # Optional[bytes]pw = creds.get_string("db/password") # Optional[str]cfg = creds.get_json("svc/config") # parsed JSON, or None
# A specific version, existence, listing, and version history:v1 = creds.get_version("db/password", "1")present = creds.exists("db/password")all_ = creds.list("") # prefix filter; "" = everythinghistory = creds.versions("db/password")if let Some(s) = creds.get("db/password")? { tracing::info!(bytes = s.bytes().len(), source = %s.source, "loaded");}
// Decode straight to a value:let raw = creds.get_bytes("db/password")?; // Option<Zeroizing<Vec<u8>>>let pw = creds.get_string("db/password")?; // Option<String>let cfg = creds.get_json("svc/config")?; // Option<serde_json::Value>
// A specific version, existence, listing, and version history:let v1 = creds.get_version("db/password", "1")?;let present = creds.exists("db/password")?;let all = creds.list("")?; // prefix filter; "" = everythinglet history = creds.versions("db/password")?;Every method returns Result<..>; reads pick up cross-process vault changes before returning.
const s = creds.get("db/password");if (s) logger.info(`bytes=${s.bytes().length} source=${s.source}`);
// Decode straight to a value:const raw = creds.getBytes("db/password"); // Buffer | undefinedconst pw = creds.getString("db/password"); // string | undefinedconst cfg = creds.getJson("svc/config"); // unknown | undefined
// A specific version, existence, listing, and version history:const v1 = creds.getVersion("db/password", "1");const present = creds.exists("db/password");const all = creds.list(""); // prefix filter; "" = everythingconst history = creds.versions("db/password");Reads are synchronous (Node is single-threaded); only refresh() is async.
Typed views
Section titled “Typed views”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.ba = creds.get_basic_auth("svc/login")if ba is not None: connect(ba.username, ba.password)
aws = creds.get_aws_credentials("aws/uploader")tls = creds.get_tls_bundle("mtls/broker")sasl = creds.get_kafka_sasl("kafka/ingest")# dataclass fields are snake_case: aws.access_key_id, tls.cert_pem, sasl.mechanismif let Some(ba) = creds.get_basic_auth("svc/login")? { connect(&ba.username, &ba.password);}let aws = creds.get_aws_credentials("aws/uploader")?;let tls = creds.get_tls_bundle("mtls/broker")?;let sasl = creds.get_kafka_sasl("kafka/ingest")?;// struct fields are snake_case: aws.access_key_id, tls.cert_pem, sasl.mechanismconst ba = creds.getBasicAuth("svc/login");if (ba) connect(ba.username, ba.password);
const aws = creds.getAwsCredentials("aws/uploader");const tls = creds.getTlsBundle("mtls/broker");const sasl = creds.getKafkaSasl("kafka/ingest");// interface fields are camelCase: aws.accessKeyId, tls.certPem, sasl.mechanismWriting a secret
Section titled “Writing a secret”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.
creds.put("db/password", b"s3cr3t") # put(name, value, **opts)
# with metadata — accepted kwargs: ttl_secs, labels, content_type, source, central_version_idversion = creds.put("api/token", token, ttl_secs=3600, content_type="text/plain")Python takes **opts keyword arguments rather than a PutOptions object; an unknown keyword raises
TypeError.
use edgecommons::credentials::PutOptions;
creds.put("db/password", b"s3cr3t", PutOptions::default())?;
let version = creds.put( "api/token", &token, PutOptions { ttl_secs: Some(3600), content_type: Some("text/plain".into()), ..Default::default() },)?;PutOptions is a struct with public fields (ttl_secs: Option<u64>, labels, content_type,
source, central_version_id).
creds.put("db/password", Buffer.from("s3cr3t")); // put(name, Buffer, opts?)
const version = creds.put("api/token", token, { ttlSecs: 3600, contentType: "text/plain",});PutOptions is an interface (ttlSecs?, labels?, contentType?, source?, centralVersionId?).
Delete a secret (all versions) with delete(name), which returns whether anything was removed.
KeyProviders
Section titled “KeyProviders”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.
# A 32-byte KEK, base64-encoded, in the default variable name:export EDGECOMMONS_VAULT_KEK="$(head -c 32 /dev/urandom | base64)"# A 32-byte KEK, base64-encoded, in the default variable name:$bytes = New-Object byte[] 32[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)$env:EDGECOMMONS_VAULT_KEK = [Convert]::ToBase64String($bytes)Central sync from AWS Secrets Manager
Section titled “Central sync from AWS Secrets Manager”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 completescreds.refresh() # synchronouscreds.refresh()?; // synchronous; returns Result<()>Central sync requires the credentials-aws cargo feature; without it, configuring
awsSecretsManager returns an error at open time.
await creds.refresh(); // async — returns Promise<void>An unknown central.type is rejected at startup in every language.
$secret references in config
Section titled “$secret references in config”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.
The audit log
Section titled “The audit log”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 |
Secret redaction
Section titled “Secret redaction”A Secret redacts its value from the default string form in every language, so accidentally logging
the object does not leak the secret:
- Java —
toString()rendersSecret{name=.., version=.., bytes=<N redacted>}. - Python —
__repr__redacts. - Rust — a custom
Debugredacts, and the bytes live in aZeroizingbuffer wiped on drop. - TypeScript — both
toString()andtoJSON()redact, soJSON.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.
See also
Section titled “See also”- API reference: Credentials — the full method-by-method surface.
- The
credentialssection of the config schema — every field, default, and validation rule.