I now have a comprehensive view of the entire codebase. Let me compile the security audit report.
Security Audit Report
Executive Summary
This audit covers @de-otio/crypto-envelope v0.3.0, a TypeScript authenticated-encryption library. The codebase demonstrates strong cryptographic discipline: proper CSPRNG usage, constant-time comparisons, verify-after-encrypt, key commitment, AAD binding, secure memory via sodium_malloc, and clear key separation through HKDF.
No Critical or High findings were identified. The library follows its stated threat model rigorously. The issues found are Medium-severity hardening concerns and Low-severity informational notes, none of which represent exploitable vulnerabilities in the current architecture.
Critical Findings
None.
High Findings
None.
Medium Findings
M1. pull_request_target in dependabot-handler.yml passes secrets to external reusable workflow
File: .github/workflows/dependabot-handler.yml:20-25
Severity: Medium
Description: The workflow uses pull_request_target (which runs in the context of the base branch with access to secrets) and passes secrets: inherit to an external reusable workflow (de-otio/.github/.github/workflows/dependabot-claude-review.yml@main). While gated by if: github.actor == 'dependabot[bot]', this combination is a known risky pattern. An attacker who compromises Dependabot's supply chain or tricks it into opening a malicious PR could potentially access repository secrets through the inherited workflow.
Attack Scenario: If the external reusable workflow at de-otio/.github is compromised (or its @main ref is force-pushed), the secrets inherited from this repository are exposed to the attacker's code running in the context of a Dependabot PR.
Fix: Pin the reusable workflow to a specific SHA rather than @main, and consider whether secrets: inherit can be narrowed to only the specific secrets needed:
uses: de-otio/.github/.github/workflows/dependabot-claude-review.yml@<sha>
M2. AES-GCM nonce budget off-by-one: allows counter value 2^32 + 1 before refusing
File: src/envelope-client.ts:185
Severity: Medium
Description: The check if (next > AES_GCM_HARD_CAP) allows exactly 2^32 encryptions (counter values 1 through 2^32) before refusing the next one. The NIST SP 800-38D Β§8.3 birthday bound analysis gives collision probability ~nΒ²/2^97 at n messages. At n=2^32, the collision probability is ~2^(64)/2^(97) = 2^(-33), which is the stated threshold. However, the constant is named AES_GCM_HARD_CAP = 2 ** 32 and the check is next > AES_GCM_HARD_CAP, meaning the 2^32-th encryption is permitted. This is technically correct per NIST (the bound is at 2^32, not before it), but the cap should arguably be >= to refuse the 2^32-th message and keep collision probability strictly below 2^(-33).
Attack Scenario: Not directly exploitable β the probability at exactly 2^32 messages is still astronomically low (~1 in 8 billion). This is a hardening concern, not a vulnerability.
Fix: Change to >= for defense-in-depth (refuse at 2^32 rather than after):
if (this.algorithm === 'AES-256-GCM' && next >= AES_GCM_HARD_CAP) {
M3. InMemoryMessageCounter resets on process restart β documented but dangerous for AES-GCM
File: src/message-counter.ts:41-73
Severity: Medium
Description: The default InMemoryMessageCounter resets to zero on every process restart. For AES-GCM with 96-bit random nonces, the birthday bound is cumulative across the lifetime of a key, not per-process. A consumer using the default counter in a serverless/Lambda environment could silently exceed the 2^32 cap across restarts without triggering the budget refusal.
The library documents this risk via a console.warn and comments, which is appropriate. However, the warning only fires once per process β a consumer who misses it in noisy logs may unknowingly weaken their nonce uniqueness guarantee.
Attack Scenario: A serverless function using AES-GCM with the default counter restarts frequently. Each restart resets the counter to 0. Over many restarts, the total number of encryptions across all invocations exceeds 2^32, but no individual process's counter ever hits the cap. Nonce collision probability accumulates silently.
Fix: Already documented. Consider additionally:
- Logging at
error level rather than warn for AES-GCM specifically
- Or throwing an error if
algorithm === 'AES-256-GCM' and no explicit messageCounter is supplied
M4. auto-release.yml grants contents: write and inherits secrets on Dependabot pushes
File: .github/workflows/auto-release.yml:23-30
Severity: Medium
Description: When Dependabot merges to main, this workflow calls an external reusable workflow (de-otio/.github/.github/workflows/dependabot-release-on-bump.yml@main) with secrets: inherit and contents: write permission. The @main ref is mutable β a compromise of the de-otio/.github repo could inject malicious release logic that executes with write access to this repository's contents and all its secrets.
Fix: Pin the reusable workflow reference to a commit SHA:
uses: de-otio/.github/.github/workflows/dependabot-release-on-bump.yml@<commit-sha>
Low Findings
L1. Decrypted plaintext returned as untyped Record<string, unknown> via JSON.parse
File: src/envelope/v1.ts:154
Severity: Low
Description: JSON.parse(DECODER.decode(plaintext)) returns an untyped object. If the encrypted payload was constructed by an attacker who has access to the encryption key (insider threat), the parsed JSON could contain __proto__ keys. However, since JSON.parse does not interpret __proto__ specially in modern V8 (it creates a regular property named "__proto__"), and the library's canonicalJson rejects non-plain objects on the encryption path, this is not exploitable in practice. The as Record<string, unknown> cast is accurate for any JSON object that went through canonicalJson at encrypt time.
Impact: None for properly encrypted envelopes. Informational only.
L2. Browser constantTimeEqual is best-effort, not contractually constant-time
File: src/internal/constant-time.browser.ts:20-27
Severity: Low
Description: The XOR-accumulate loop is the standard pattern but V8's JIT can theoretically optimize it in ways that break constant-time guarantees. This is clearly documented in the code comments. No browser API offers a true constant-time comparison.
Impact: On browsers, a sophisticated timing side-channel attacker with very high-resolution timing access might distinguish valid from invalid MACs. In practice, network jitter and WebCrypto API overhead make this infeasible remotely. The library correctly documents this as a known downgrade.
L3. Browser SecureBuffer cannot guarantee memory protection
File: src/secure-buffer.browser.ts:29-106
Severity: Low
Description: Browser environments cannot mlock memory or prevent GC relocation. Key material may be swapped to disk or copied during GC compaction. The library correctly requires an explicit { insecureMemory: true } acknowledgement flag, making this an informed opt-in rather than a silent downgrade.
Impact: Documented and defended by the acknowledgement gate. No fix needed β this is inherent to the browser platform.
L4. console.warn may expose library usage pattern in production logs
Files: src/message-counter.ts:67-70, src/passphrase.ts:132-135
Severity: Low
Description: The library emits console.warn messages that identify @de-otio/crypto-envelope and reveal which KDF/counter path is being used. In production environments with aggregated logging, this leaks information about the cryptographic configuration to anyone with log access.
Fix: Consider allowing consumers to suppress or redirect these warnings via a configuration option or environment variable, or use a structured logger that can be silenced.
L5. Claude Agent workflow grants broad permissions without IP/user allowlisting
File: .github/workflows/claude-agent.yml:29-33
Severity: Low
Description: The Claude agent workflow grants contents: write, pull-requests: write, and issues: write permissions. The trigger is any @claude mention in issues or PR comments. The allowed_non_write_users: "" setting means only users with write access can trigger it (empty string = no additional non-write users allowed). This is appropriately scoped for a project with a small trusted contributor set but should be reviewed if the contributor base grows.
Dependency Review
| Package |
Version |
Risk Assessment |
@noble/ciphers |
^2.2.0 |
Low risk. Audited pure-JS crypto by Paul Miller. No native code, no dependencies. Widely used and reviewed. |
@noble/hashes |
^2.2.0 |
Low risk. Same author and quality posture as @noble/ciphers. |
cborg |
^5.1.0 |
Low risk. CBOR encoder/decoder. No network access, pure data parsing. Small dependency surface. |
sodium-native |
^5.1.0 |
Low risk. Native binding to libsodium. Provides mlock/memzero. The native compilation is a supply-chain surface, but this is the standard Node.js libsodium binding. |
npm audit reports 0 known vulnerabilities across all dependencies.
The dependency graph is minimal (4 runtime deps), all from well-known, audited sources. No transitive dependency surprises. The project runs npm audit signatures in CI to verify package provenance.
Summary Table
| # |
Severity |
Finding |
File |
Status |
| M1 |
Medium |
pull_request_target + secrets: inherit to mutable external workflow ref |
.github/workflows/dependabot-handler.yml:20-25 |
Should fix |
| M2 |
Medium |
AES-GCM nonce cap allows exactly 2^32 encryptions (off-by-one hardening) |
src/envelope-client.ts:185 |
Should fix |
| M3 |
Medium |
InMemoryMessageCounter silently resets on restart for AES-GCM consumers |
src/message-counter.ts:41-73 |
Documented; consider strengthening |
| M4 |
Medium |
auto-release.yml passes secrets: inherit to mutable @main ref |
.github/workflows/auto-release.yml:23-30 |
Should fix |
| L1 |
Low |
JSON.parse on decrypted plaintext (no prototype pollution in practice) |
src/envelope/v1.ts:154 |
Informational |
| L2 |
Low |
Browser constant-time comparison is best-effort |
src/internal/constant-time.browser.ts:20-27 |
Informational |
| L3 |
Low |
Browser SecureBuffer cannot mlock (documented, gated by ack flag) |
src/secure-buffer.browser.ts |
Informational |
| L4 |
Low |
console.warn reveals crypto config in logs |
src/message-counter.ts:67, src/passphrase.ts:132 |
Informational |
| L5 |
Low |
Claude agent workflow broad permissions |
.github/workflows/claude-agent.yml:29-33 |
Informational |
Overall Assessment: This is a well-engineered cryptographic library with strong security posture. The core cryptographic implementation is sound β proper nonce generation from CSPRNG, constant-time comparisons, key commitment, AAD binding, verify-after-encrypt, and secure memory handling are all correctly implemented. The codebase contains zero instances of Math.random, eval, fetch, or hardcoded secrets. The most actionable findings (M1, M4) are CI/CD supply-chain hardening issues (pinning external workflow refs to SHAs rather than mutable branch names), not cryptographic weaknesses.
I now have a comprehensive view of the entire codebase. Let me compile the security audit report.
Security Audit Report
Executive Summary
This audit covers
@de-otio/crypto-envelopev0.3.0, a TypeScript authenticated-encryption library. The codebase demonstrates strong cryptographic discipline: proper CSPRNG usage, constant-time comparisons, verify-after-encrypt, key commitment, AAD binding, secure memory viasodium_malloc, and clear key separation through HKDF.No Critical or High findings were identified. The library follows its stated threat model rigorously. The issues found are Medium-severity hardening concerns and Low-severity informational notes, none of which represent exploitable vulnerabilities in the current architecture.
Critical Findings
None.
High Findings
None.
Medium Findings
M1.
pull_request_targetindependabot-handler.ymlpasses secrets to external reusable workflowFile:
.github/workflows/dependabot-handler.yml:20-25Severity: Medium
Description: The workflow uses
pull_request_target(which runs in the context of the base branch with access to secrets) and passessecrets: inheritto an external reusable workflow (de-otio/.github/.github/workflows/dependabot-claude-review.yml@main). While gated byif: github.actor == 'dependabot[bot]', this combination is a known risky pattern. An attacker who compromises Dependabot's supply chain or tricks it into opening a malicious PR could potentially access repository secrets through the inherited workflow.Attack Scenario: If the external reusable workflow at
de-otio/.githubis compromised (or its@mainref is force-pushed), the secrets inherited from this repository are exposed to the attacker's code running in the context of a Dependabot PR.Fix: Pin the reusable workflow to a specific SHA rather than
@main, and consider whethersecrets: inheritcan be narrowed to only the specific secrets needed:M2. AES-GCM nonce budget off-by-one: allows counter value
2^32 + 1before refusingFile:
src/envelope-client.ts:185Severity: Medium
Description: The check
if (next > AES_GCM_HARD_CAP)allows exactly2^32encryptions (counter values 1 through 2^32) before refusing the next one. The NIST SP 800-38D Β§8.3 birthday bound analysis gives collision probability ~nΒ²/2^97 at n messages. At n=2^32, the collision probability is ~2^(64)/2^(97) = 2^(-33), which is the stated threshold. However, the constant is namedAES_GCM_HARD_CAP = 2 ** 32and the check isnext > AES_GCM_HARD_CAP, meaning the 2^32-th encryption is permitted. This is technically correct per NIST (the bound is at 2^32, not before it), but the cap should arguably be>=to refuse the 2^32-th message and keep collision probability strictly below 2^(-33).Attack Scenario: Not directly exploitable β the probability at exactly 2^32 messages is still astronomically low (~1 in 8 billion). This is a hardening concern, not a vulnerability.
Fix: Change to
>=for defense-in-depth (refuse at 2^32 rather than after):M3.
InMemoryMessageCounterresets on process restart β documented but dangerous for AES-GCMFile:
src/message-counter.ts:41-73Severity: Medium
Description: The default
InMemoryMessageCounterresets to zero on every process restart. For AES-GCM with 96-bit random nonces, the birthday bound is cumulative across the lifetime of a key, not per-process. A consumer using the default counter in a serverless/Lambda environment could silently exceed the 2^32 cap across restarts without triggering the budget refusal.The library documents this risk via a
console.warnand comments, which is appropriate. However, the warning only fires once per process β a consumer who misses it in noisy logs may unknowingly weaken their nonce uniqueness guarantee.Attack Scenario: A serverless function using AES-GCM with the default counter restarts frequently. Each restart resets the counter to 0. Over many restarts, the total number of encryptions across all invocations exceeds 2^32, but no individual process's counter ever hits the cap. Nonce collision probability accumulates silently.
Fix: Already documented. Consider additionally:
errorlevel rather thanwarnfor AES-GCM specificallyalgorithm === 'AES-256-GCM'and no explicitmessageCounteris suppliedM4.
auto-release.ymlgrantscontents: writeand inherits secrets on Dependabot pushesFile:
.github/workflows/auto-release.yml:23-30Severity: Medium
Description: When Dependabot merges to
main, this workflow calls an external reusable workflow (de-otio/.github/.github/workflows/dependabot-release-on-bump.yml@main) withsecrets: inheritandcontents: writepermission. The@mainref is mutable β a compromise of thede-otio/.githubrepo could inject malicious release logic that executes with write access to this repository's contents and all its secrets.Fix: Pin the reusable workflow reference to a commit SHA:
Low Findings
L1. Decrypted plaintext returned as untyped
Record<string, unknown>viaJSON.parseFile:
src/envelope/v1.ts:154Severity: Low
Description:
JSON.parse(DECODER.decode(plaintext))returns an untyped object. If the encrypted payload was constructed by an attacker who has access to the encryption key (insider threat), the parsed JSON could contain__proto__keys. However, sinceJSON.parsedoes not interpret__proto__specially in modern V8 (it creates a regular property named"__proto__"), and the library'scanonicalJsonrejects non-plain objects on the encryption path, this is not exploitable in practice. Theas Record<string, unknown>cast is accurate for any JSON object that went throughcanonicalJsonat encrypt time.Impact: None for properly encrypted envelopes. Informational only.
L2. Browser
constantTimeEqualis best-effort, not contractually constant-timeFile:
src/internal/constant-time.browser.ts:20-27Severity: Low
Description: The XOR-accumulate loop is the standard pattern but V8's JIT can theoretically optimize it in ways that break constant-time guarantees. This is clearly documented in the code comments. No browser API offers a true constant-time comparison.
Impact: On browsers, a sophisticated timing side-channel attacker with very high-resolution timing access might distinguish valid from invalid MACs. In practice, network jitter and WebCrypto API overhead make this infeasible remotely. The library correctly documents this as a known downgrade.
L3. Browser
SecureBuffercannot guarantee memory protectionFile:
src/secure-buffer.browser.ts:29-106Severity: Low
Description: Browser environments cannot
mlockmemory or prevent GC relocation. Key material may be swapped to disk or copied during GC compaction. The library correctly requires an explicit{ insecureMemory: true }acknowledgement flag, making this an informed opt-in rather than a silent downgrade.Impact: Documented and defended by the acknowledgement gate. No fix needed β this is inherent to the browser platform.
L4.
console.warnmay expose library usage pattern in production logsFiles:
src/message-counter.ts:67-70,src/passphrase.ts:132-135Severity: Low
Description: The library emits
console.warnmessages that identify@de-otio/crypto-envelopeand reveal which KDF/counter path is being used. In production environments with aggregated logging, this leaks information about the cryptographic configuration to anyone with log access.Fix: Consider allowing consumers to suppress or redirect these warnings via a configuration option or environment variable, or use a structured logger that can be silenced.
L5. Claude Agent workflow grants broad permissions without IP/user allowlisting
File:
.github/workflows/claude-agent.yml:29-33Severity: Low
Description: The Claude agent workflow grants
contents: write,pull-requests: write, andissues: writepermissions. The trigger is any@claudemention in issues or PR comments. Theallowed_non_write_users: ""setting means only users with write access can trigger it (empty string = no additional non-write users allowed). This is appropriately scoped for a project with a small trusted contributor set but should be reviewed if the contributor base grows.Dependency Review
@noble/ciphers@noble/hashes@noble/ciphers.cborgsodium-nativemlock/memzero. The native compilation is a supply-chain surface, but this is the standard Node.js libsodium binding.npm auditreports 0 known vulnerabilities across all dependencies.The dependency graph is minimal (4 runtime deps), all from well-known, audited sources. No transitive dependency surprises. The project runs
npm audit signaturesin CI to verify package provenance.Summary Table
pull_request_target+secrets: inheritto mutable external workflow ref.github/workflows/dependabot-handler.yml:20-25src/envelope-client.ts:185InMemoryMessageCountersilently resets on restart for AES-GCM consumerssrc/message-counter.ts:41-73auto-release.ymlpassessecrets: inheritto mutable@mainref.github/workflows/auto-release.yml:23-30JSON.parseon decrypted plaintext (no prototype pollution in practice)src/envelope/v1.ts:154src/internal/constant-time.browser.ts:20-27SecureBuffercannot mlock (documented, gated by ack flag)src/secure-buffer.browser.tsconsole.warnreveals crypto config in logssrc/message-counter.ts:67,src/passphrase.ts:132.github/workflows/claude-agent.yml:29-33Overall Assessment: This is a well-engineered cryptographic library with strong security posture. The core cryptographic implementation is sound β proper nonce generation from CSPRNG, constant-time comparisons, key commitment, AAD binding, verify-after-encrypt, and secure memory handling are all correctly implemented. The codebase contains zero instances of
Math.random,eval,fetch, or hardcoded secrets. The most actionable findings (M1, M4) are CI/CD supply-chain hardening issues (pinning external workflow refs to SHAs rather than mutable branch names), not cryptographic weaknesses.