Skip to content

πŸ›‘οΈ Weekly Security Audit β€” 2026-06-08Β #32

@github-actions

Description

@github-actions

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:

  1. Logging at error level rather than warn for AES-GCM specifically
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions