Last reviewed: 2026-09-26 Next review due: 2026-12-26 (quarterly — see Re-audit cadence)
This document describes how Bridgelet SDK protects sensitive data at rest, in particular the ephemeral Stellar secret keys the service is responsible for custodying between account creation and claim redemption.
Every ephemeral account's Stellar secret key is encrypted before it is written to accounts.secretKeyEncrypted and is only ever decrypted in-memory, for the duration of a claim redemption, immediately before it is handed to the signing/sweep path.
- Algorithm: AES-256-GCM (authenticated encryption — tamper-evident, unique IV per write).
- Implementation:
SecretEncryptionUtil. This is the single, shared implementation for encrypt/decrypt of secret material — it must never be reimplemented inline elsewhere. - Stored format:
aes256gcm:v1:<iv_hex>:<authTag_hex>:<ciphertext_hex>, oraes256gcm:v2:<keyId>:<iv_hex>:<authTag_hex>:<ciphertext_hex>when a key id is configured. Theaes256gcm:v1:/v2:prefix makes the format self-describing so a future format change fails loudly on an unrecognized version rather than silently mis-decoding. - Key length: 256-bit (32-byte) key, supplied as a 64-character hex string.
The encryption key is never stored alongside the encrypted data (i.e. never in the database, never committed to the repo).
- Production (recommended):
KmsKeyProvidersources the data-encryption key from AWS KMS via envelope encryption:- On startup, the service calls
GenerateDataKeyagainst a KMS Customer Master Key (KMS_KEY_ID). The plaintext data key is held in memory only, for the lifetime of the process, and is used to encrypt/decrypt secret rows viaSecretEncryptionUtil. The CMK itself never leaves AWS. - The encrypted data-key blob is persisted — via
KMS_ENCRYPTED_DATA_KEY, else the file atKMS_DATA_KEY_PATH— and unwrapped with KMSDecrypton the next start. This matters: regenerating the data key on every restart would make everysecretKeyEncryptedvalue written under the previous one undecryptable, permanently. - If a persisted blob exists but cannot be unwrapped (wrong CMK, rotated-away key, corrupted file), the provider refuses to generate a new data key and falls back to
ENCRYPTION_KEY, logging an error. Regenerating in that situation would destroy access while appearing healthy. - Configure with
KMS_ENABLED=true,KMS_KEY_ID=<arn-or-alias>,AWS_REGION=<region>. - For multi-instance deployments, replace the local file with a durable store (AWS Systems Manager Parameter Store / Secrets Manager) by populating
KMS_ENCRYPTED_DATA_KEYout of band. The plaintext is never written to disk either way. - Rotating the CMK requires re-wrapping (
decryptDataKey) and re-encrypting existing rows; it is not automatic.
- On startup, the service calls
- Fallback (non-production / local dev only): if
KMS_ENABLED=falseorKMS_KEY_IDis unset, the service falls back to a static key from theENCRYPTION_KEYenvironment variable (see.env.example). This path exists for local development and tests. Do not run production with real funds on theENCRYPTION_KEYfallback path — use KMS.
SecretRotationUtil provides the dual-key decrypt path, and KmsKeyProvider wires it in for both the KMS and fallback paths.
Two mechanisms, because two problems exist:
- Untagged rows (
v1, unprefixed). These name no key, so the only way to read one is to try the current key and then the previous one. SetENCRYPTION_KEY_PREVIOUSto keep old rows readable. - Key-id tagged rows (
v2). These name the key they need, so the right key is selected directly rather than by trial. SetENCRYPTION_KEY_IDto opt into tagged writes.
During a rotation:
- Set
ENCRYPTION_KEY_ID(new key id) andENCRYPTION_KEY_PREVIOUS(the outgoing key material). - Deploy. New writes are tagged
v2; reads resolve either format. - Re-encrypt existing rows (
npm run migrate:secrets), or simply let them age out. - Confirm the outgoing key id is no longer in use with
npm run audit:secrets(see below). - Unset
ENCRYPTION_KEY_PREVIOUSonce nothing needs it.
A v2 row whose key id is unknown to the loaded key ring fails loudly rather than being mis-decoded with the current key.
Prior to PR #193, this service persisted secret keys with Buffer.from(secret).toString('base64') — encoding, not encryption. That placeholder is no longer produced by any code path, but rows written before the fix may still hold a base64 value in non-production databases.
SecretEncryptionUtil.decrypt() refuses to decode a base64 row (it throws a descriptive error pointing at the migration tool) rather than silently treating it as ciphertext. To reclassify and re-encrypt any legacy rows (base64 placeholder, or unprefixed pre-v1 AES-GCM), run:
# Dry run (default) — reports what would change, writes nothing
npm run migrate:secrets
# Actual migration — requires both flags
npm run migrate:secrets -- --i-have-a-backup --executeSee the header comment in src/scripts/migrate-secrets.ts for full safety semantics (dry-run default, optimistic concurrency, audit log, halt-on-corrupt-row).
decrypt() also still accepts unprefixed pre-v1 AES-GCM rows, for databases partway through the migration. Those branches are only removable once nothing needs them, so there is a check that answers that question:
psql -At -c 'SELECT "secretKeyEncrypted" FROM accounts' > secrets.json
npm run audit:secrets -- ./secrets.jsonIt prints a per-format breakdown plus the key ids currently in use, and exits
non-zero while any legacy or corrupt row remains — so it can gate the cleanup
that deletes those branches from decrypt().
No production deployment with real funds should occur against a database that still has any legacy-base64 rows. Run the migration (or start from a fresh database) first.
src/common/crypto/encryption.util.spec.tscovers: round-trip correctness, unique ciphertext per call (random IV), rejection of a tampered ciphertext, rejection of the wrong key, descriptive rejection errors for legacy base64 and unsupported format versions, and theclassify()helper used by the migration script.src/common/crypto/kms-key.provider.spec.tscovers the KMS/fallback key-selection logic.src/scripts/migration-cli.spec.tscovers the migration CLI's flag parsing and audit logging.
Claim tokens are signed JWTs (app.jwtSecret). Only a SHA-256 hash of the token (claimTokenHash) is persisted; the raw token is returned to the caller exactly once, in the create response's claimUrl.
<<<<<<< HEAD
Because a token cannot be re-issued, rotating JWT_SECRET without a grace window invalidates every outstanding token at once and strands the funds behind them. JWT_SECRET_PREVIOUS keeps the outgoing secret accepted during a rotation; see docs/jwt-secret-rotation-runbook.md.
A webhook secret is a shared HMAC key: whoever holds it can forge deliveries your receiver will accept. It is:
- validated on input — at least 16 characters,
[A-Za-z0-9_-]only (CreateWebhookDto.secret,UpdateWebhookDto.secret); - encrypted at rest with the same
SecretEncryptionUtil+KmsKeyProviderenvelope used for account secret keys, and decrypted only at the moment a delivery is signed (WebhooksService); - write-only over the API —
WebhookResponseDtohas nosecretfield, so it cannot be read back throughGET/POST/PUT. A lost secret must be rotated, not recovered.
Rows written before encryption was introduced hold a plaintext secret; WebhooksService.readSecret() detects that and keeps using them, and they are re-encrypted the next time the secret is rotated.
SECURITY.md and SECURITY_AUDIT.md are point-in-time
snapshots and go stale if nobody revisits them. The following cadence applies.
- Quarterly (next due 2026-12-26): re-read both documents end to end
and update the
Last revieweddate above. The reviewer must, for every row inSECURITY_AUDIT.md, confirm the statedStatusis still accurate and that any linked issue is still tracked (open or closed) rather than silently dropped. - On any change to the crypto path —
src/common/crypto/**,SecretEncryptionUtil,KmsKeyProvider— re-review immediately rather than waiting for the quarterly pass. These files hold the keys that protect account secret keys, so a change here is itself a review trigger. - On any new sensitive data category being persisted, add a row to
SECURITY_AUDIT.mdin the same PR that introduces it. A new column holding secret material may not land without a corresponding audit row.
The Status column in SECURITY_AUDIT.md is only trustworthy if each finding
resolves to something the tracker knows about. The rules, enforced at review
time and recorded in
docs/security-audit-reconciliation.md:
- Every finding carries a
Status. - A finding may not be marked
Remediatedwithout a linked issue that was actually closed. - A finding marked
Gapmust have an open issue filed against it before the next quarterly pass. This is the check most likely to catch silent rot — aGapwith no issue number means nobody owns the work. - Findings that are intentionally accepted risk (rather than gaps) are marked as such with a rationale, so they are not re-litigated every quarter and not mistaken for unfixed bugs.
The one currently-known Gap is the webhook secret column, which is tracked
by issue #688. Until that closes, treat webhook secrets as plaintext at rest
and avoid treating them as protected with the same guarantees as account
secret keys.
If you discover a security issue in this repository, please do not open a public GitHub issue. Contact the maintainers directly so the issue can be triaged and fixed before disclosure.