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, "cacheTtlSecs": 300 }, "audit": { "enabled": true } }}Defaults if you omit fields: vault.path = vault, keyProvider.type = file (or env on
KUBERNETES — see KeyProviders), keepVersions = 2, cacheTtlSecs = 300,
audit.enabled = true, and no central sync. The vault.path supports template variables such as
{ThingName}.
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]ggcommons = { 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 ggcommons::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 GGCOMMONS_VAULT_KEK) |
kms / greengrass (aliases) |
AWS KMS via TES | kmsKeyId, region, endpointUrl |
pkcs11 |
HSM / TPM (wrap stays inside the token) | modulePath, tokenLabel, keyLabel, pinEnv |
{ "credentials": { "vault": { "keyProvider": { "type": "env", "envVar": "GGCOMMONS_VAULT_KEK" } } }}The env provider reads a base64-encoded 32-byte KEK from the named variable (default
GGCOMMONS_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 GGCOMMONS_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:GGCOMMONS_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. 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”Any config element can reference a vault secret instead of embedding it, so messaging/streaming
config never carries plaintext. 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. Resolution
errors if the secret (or field) is absent.
{ "streaming": { "sinks": { "kinesis": { "accessKeyId": { "$secret": "aws/uploader", "field": "accessKeyId" }, "secretAccessKey": { "$secret": "aws/uploader", "field": "secretAccessKey" } } } }}The SDK resolves these against the credential service. 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.ggcommons.credentials.audit |
| Python | ggcommons.credentials.audit |
| Rust | ggcommons::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.