Skip to content

Latest commit

 

History

History
858 lines (589 loc) · 53.4 KB

File metadata and controls

858 lines (589 loc) · 53.4 KB

Security Audit: ReMemory

This document is AI-assisted. It was generated by Claude (Opus 4.6) to provide a structured, reproducible evaluation of the ReMemory codebase. Every finding is backed by a command you can run or a code path you can read yourself. The AI's role is directing attention to the right places; your terminal is the source of truth.

Audit date: 2026-02-25 Commit: 80cebf2 Go version: 1.25.7 Intended audience: Security professionals and security-conscious users evaluating whether to trust this software


Table of Contents

  1. Is This Software Safe to Install and Run?
  2. Is This Software Secure for Its Purpose?
  3. Threat Model
  4. Deep Dives
  5. Where a Professional Reviewer Should Focus
  6. What This Audit Did Not Cover

Confidence Labels

Each finding is tagged with a confidence label indicating the type of evidence behind it:

  • Tool output — produced by running a specific command. You can reproduce it yourself.
  • Code pointer — based on reading the source code. The relevant file and line are cited so you can verify.
  • Structural observation — follows from the architecture or design, not a single line of code. Requires understanding the system as a whole.

These labels are honest about what an AI can and can't verify. Tool output is the strongest claim. Code pointers and structural observations require human judgment to confirm.


1. Is This Software Safe to Install and Run?

1.1 Dependency Vulnerability Scanning

govulncheck (Go's official vulnerability database)

nix develop -c go run golang.org/x/vuln/cmd/govulncheck@latest ./...

What it checks: Scans your Go code and dependencies against the official Go vulnerability database. Only reports vulnerabilities in functions your code actually calls.

Output at time of audit:

No vulnerabilities found.

Zero vulnerabilities in called code, imported packages, or required modules.

Confidence: Tool output — run it yourself to verify.

gosec (Go security static analysis)

nix shell nixpkgs#gosec -c gosec -quiet -exclude-dir=.claude ./...

What it checks: Static analysis for common Go security issues (hardcoded credentials, path traversal, file permissions, integer overflow, etc.)

Output at time of audit: 62 findings across 57 files / 7,986 lines scanned, broken down by severity.

HIGH severity (9 findings):

G115 (CWE-190): Integer overflow conversion (7 instances)

Location Conversion Assessment
internal/core/archive.go:122,125 uint64 → int64 in ZIP size check Size is compared to MaxFileSize (100 MB), well within int64 range
internal/manifest/archive.go:412,415 uint64 → int64 in ZIP size check Same pattern, same mitigation
internal/manifest/archive.go:191,209 int64 → uint32 in os.FileMode() Code masks with &0777/&0666, clamping to valid permission bits
internal/core/tlock_common.go:58 uint64 → int64 in time.Duration() Round numbers are small positive integers (seconds since 2023)

G101 (CWE-798): Potential hardcoded credentials (2 instances)

Both in internal/cmd/demo.go:85-112 — intentional demo placeholder text, not real credentials.

MEDIUM severity (28 findings):

Category Count Assessment
G304 — File inclusion via variable 16 Expected: CLI reads user-specified paths. These are paths from command-line arguments or project config, not user input in a web context. Not a vulnerability.
G306 — File permissions 0644 7 Applies to project config, HTML output, manifest files. Share files use 0600. See Section 4.1.
G305 — Zip path traversal 1 Flagged at manifest/archive.go:397, but the code has a filepath.Clean-based traversal check at line 400. False positive — gosec doesn't recognize the guard.
G114 — No HTTP timeout 1 internal/serve/server.go:118http.ListenAndServe() without timeout configuration. The self-hosted server is a local-only convenience tool, but adding timeouts would be a hardening improvement.

LOW severity (25 findings):

Category Count Assessment
G104 — Errors unhandled 21 Mostly in HTTP response writing (w.Write()) and deferred file closes. Standard Go patterns — these errors can't meaningfully be handled.
G602 — Slice bounds 2 Magic byte checks in manifest/archive.go:477,488. Length is checked at the function entry point.

Confidence: Tool output — run it yourself.

syft + grype (SBOM generation + vulnerability scanning)

nix shell nixpkgs#syft -c syft dir:. -o table
nix shell nixpkgs#grype -c grype dir:.

What it checks: Generates a Software Bill of Materials (SBOM), then scans all identified packages against multiple vulnerability databases (NVD, GitHub Advisories, etc.)

Output at time of audit: 1 finding (likely false positive).

NAME                       INSTALLED  FIXED IN  TYPE           VULNERABILITY        SEVERITY
actions/download-artifact  v4         4.1.3     github-action  GHSA-cxww-7g56-2vh6  High

Grype flagged actions/download-artifact@v4 at .github/workflows/release.yml:158. However, @v4 is a floating major-version tag on GitHub Actions — it resolves to the latest 4.x release at runtime, which is well past the 4.1.3 fix. Grype interprets @v4 as "v4.0.0" without resolving the tag. This is a false positive. Either way, this is CI infrastructure only — it doesn't affect the software itself.

Confidence: Tool output (with caveat about grype's GitHub Action version resolution).

go vet + go mod verify

nix develop -c go vet ./...
nix develop -c go mod verify

Output at time of audit:

  • go vet: Clean — no issues found.
  • go mod verify: all modules verified — every dependency matches its recorded checksum in go.sum.

Confidence: Tool output.

1.2 Dependency Surface

Direct dependencies (8):

Dependency Version Purpose Touches sensitive data?
filippo.io/age v1.3.1 Encryption (scrypt + ChaCha20-Poly1305) Yes — encrypts/decrypts manifest
github.com/hashicorp/vault v1.21.3 Shamir's Secret Sharing (shamir subpackage only) Yes — splits/combines passphrase
github.com/drand/tlock v1.2.0 Time-lock encryption via drand Yes — adds time-lock layer to manifest
golang.org/x/text v0.34.0 Unicode normalization for BIP39 words Yes — word decoding touches share data
github.com/go-pdf/fpdf v0.9.0 PDF generation for bundle README Renders share words into PDF
github.com/skip2/go-qrcode v0.0.0-20200617 QR code generation for PDF Encodes compact share into QR
github.com/spf13/cobra v1.10.2 CLI framework No
gopkg.in/yaml.v3 v3.0.1 YAML parsing for project.yml Reads project config

npm dependencies (bundled into JS, not runtime):

Package Version Purpose In recovery path?
age-encryption 0.2.4 age decryption in browser Yes
shamir-secret-sharing 0.0.4 Shamir combine in browser (audited by Cure53 + Zellic) Yes
fflate 0.8.2 ZIP/gzip extraction in browser Yes
tarparser 0.0.5 Tar extraction in browser Yes
tlock-js 0.9.0 Time-lock encryption (maker.html only) No (creation only)
drand-client 1.4.2 Drand beacon access (source for tlock.ts adaptation) Indirectly — adapted, not imported

Key observation about transitive dependencies: The Go module graph contains ~470 transitive modules and ~1,008 dependency edges, dominated by hashicorp/vault@v1.21.3 which pulls in cloud SDKs (Azure, AWS, GCP), database drivers, Kubernetes clients, and dozens of HashiCorp libraries. Only the shamir subpackage is imported:

grep -r "hashicorp/vault" --include="*.go" . | grep -v _test.go | grep -v vendor | grep -v ".claude"
# Expected: only internal/core/shamir.go importing "github.com/hashicorp/vault/shamir"

The compiled binary uses a tiny fraction of this graph. The transitive modules are present in go.sum but not linked into the final binary. Still, this inflated graph adds noise to vulnerability scanners and increases the surface for supply chain attacks against the module proxy.

Confidence: Tool output + code pointer.

1.3 No Telemetry, No Phoning Home

# No os/exec usage (no shelling out)
grep -rn "os/exec" --include="*.go" . | grep -v _test.go
# Expected: no matches

# No telemetry or analytics
grep -ri "telemetry\|analytics\|tracking\|sentry\|datadog" --include="*.go" --include="*.ts" . --exclude-dir=node_modules
# Expected: no matches

# No math/rand (only crypto/rand for randomness)
grep -rn "math/rand" --include="*.go" . | grep -v _test.go
# Expected: no matches

The CLI makes zero network requests during seal and bundle operations. The only networking code is in:

  • internal/core/tlock.go — drand beacon fetching for tlock decryption (CLI recover command with tlock bundles)
  • internal/serve/ — the optional self-hosted server (rememory serve)

Enforced by tests, not just grep. The test suite blocks all network access at the transport level and fails if any unexpected connection is attempted:

  • Go tests: TestMain in internal/offline_test.go and internal/core/offline_test.go replaces http.DefaultTransport with a custom dialer that rejects all connections. It also overrides net.DefaultResolver with PreferGo: true so DNS lookups go through the same blocking dialer — even libraries that create their own http.Client can't sneak past. When REMEMORY_TEST_TLOCK=1 is set, only drand relay hosts are allowlisted; everything else still fails.

  • Playwright E2E tests: e2e/fixtures.ts intercepts all HTTP/HTTPS requests via page.route() and throws an error on any unexpected network call. Every browser test — recovery, creation, static pages — runs offline by default. Tests that legitimately need network access (localhost server tests, drand beacon tests) opt in with an explicit allowedHosts allowlist.

This means any accidental network call introduced by a code change or a dependency update would cause test failures in CI.

Confidence: Tool output + code pointer.

1.4 No Network in Recovery Tool (Offline Variant)

The browser-based recover.html has two JS variants built from the same source:

Variant Build flag Network calls When used
app.js __TLOCK__=false Zero Personalized non-tlock bundles
app-tlock.js __TLOCK__=true drand beacon fetch only Generic, tlock, and self-hosted variants
# Verify: no fetch/XHR in the offline variant's source
grep -n "fetch\|XMLHttpRequest\|sendBeacon" internal/html/assets/src/app.ts
# Expected: only behind __TLOCK__ guards

# Verify: the tlock variant only calls drand endpoints
grep -n "fetch" internal/html/assets/src/tlock.ts
# Line ~338: fetch(url) inside fetchBeacon() — only to drand API endpoints

Content Security Policy: Each generated recover.html includes a strict CSP via <meta> tag at recover.go:117-119:

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{{CSP_NONCE}}' 'wasm-unsafe-eval';
  style-src 'unsafe-inline';
  img-src blob: data:;
  connect-src <variant-specific>;
  form-action 'none';

The connect-src directive adapts to the variant:

  • Offline bundles: blob: only — no external connections possible
  • Tlock bundles: blob: + drand endpoint URLs (api.drand.sh, api2.drand.sh, api3.drand.sh, drand.cloudflare.com)
  • Self-hosted/static: adds 'self' for manifest fetching from its own server

Each HTML file gets a unique 128-bit random nonce via crypto/rand. Scripts without the correct nonce are blocked by the browser.

Code pointer: CSP construction at recover.go:106-119.

Confidence: Tool output + code pointer.

1.5 Ongoing Dependency Monitoring

Dependabot is enabled on this repository (.github/dependabot.yml) with weekly checks for:

  • Go modules (gomod) — catches vulnerable Go dependencies
  • GitHub Actions (github-actions) — catches compromised CI actions
  • npm (npm) — catches vulnerable JS dependencies

This provides continuous monitoring between point-in-time audits. Dependabot will automatically open PRs when vulnerabilities are detected in any of these ecosystems.

Confidence: Code pointer.


2. Is This Software Secure for Its Purpose?

This section covers the cryptographic composition and whether the Shamir/age construction holds. Tools reach their limit here — the real assurance comes from reading code.

2.1 Cryptographic Composition

The design composes established cryptographic primitives in layers:

  1. age (scrypt mode) encrypts the manifest archive with a random passphrase
  2. Shamir's Secret Sharing (HashiCorp Vault) splits the passphrase into shares
  3. tlock (optional) adds time-lock encryption as an inner layer inside age

The composition is sound if:

  • The passphrase has sufficient entropy (it does: 256 bits from crypto/rand)
  • The Shamir split operates on the raw passphrase bytes (it does in protocol v2)
  • Below-threshold shares reveal zero information about the passphrase (guaranteed by Shamir's information-theoretic security)
  • Share metadata doesn't leak information that weakens the guarantee (it doesn't — see Section 4.2)
  • The tlock layer is correctly composed (see Section 4.6)

No custom cryptographic primitives are used anywhere. All cryptography is composition of established libraries.

2.2 What the Code Does (High-Level)

Sealing (creation):

  1. Archive manifest/ directory into tar.gz (in memory)
  2. Generate 32 random bytes via crypto/rand → base64url encode → passphrase
  3. Optionally tlock-encrypt the tar.gz for a future drand round (inner layer)
  4. Encrypt (tlock container or raw tar.gz) with age (scrypt mode, passphrase)
  5. Split raw 32 bytes via Shamir into N shares with threshold K
  6. Verify reconstruction: combine K shares, re-derive passphrase, compare
  7. Write MANIFEST.age (encrypted), share files (0600), ZIP bundles

Recovery (reconstruction):

  1. Parse share files (PEM, compact, QR, or BIP39 words), verify checksums
  2. Validate compatibility (version, total, threshold must match)
  3. Check threshold met, no duplicate indices
  4. Combine shares via Shamir → recover raw bytes → base64url encode → passphrase
  5. Decrypt MANIFEST.age with recovered passphrase (age layer)
  6. If tlock container: decrypt inner tlock layer using drand beacon
  7. Extract tar.gz to output directory

Two recovery paths exist:

  • CLI: Go code in internal/cmd/recover.go
  • Browser: Native JavaScript in internal/html/assets/src/ (no WASM needed for recovery)

Confidence: Structural observation — verified by reading the code paths end-to-end.


3. Threat Model

3.1 An attacker who obtains fewer than threshold shares

Design promise: Zero information about the passphrase is revealed. This is Shamir's information-theoretic guarantee — it holds regardless of computational power.

What enforces it:

  • internal/core/shamir.go:14-25 — delegates to github.com/hashicorp/vault/shamir.Split(), which operates over GF(2^8).
  • The share data is a Shamir share — a point on a random polynomial. No additional information is embedded in the share data bytes.
  • The browser-side combine uses shamir-secret-sharing (audited by Cure53 and Zellic), which uses the same GF(2^8) share format as HashiCorp Vault.

What to verify: That share metadata doesn't leak information. See Section 4.2.

Confidence: Code pointer + structural observation. The information-theoretic guarantee follows from the mathematical properties of Shamir's Secret Sharing, not from the implementation. The implementation's job is to not add leakage — and it doesn't.

3.2 An attacker who compromises a single friend's bundle

Design promise: A single bundle reveals that friend's share (one point on the polynomial) and the encrypted manifest. Without threshold-1 additional shares, the passphrase cannot be reconstructed and the manifest cannot be decrypted.

What enforces it:

  • The encrypted manifest uses age with a 256-bit random passphrase. Brute-forcing scrypt with this entropy is computationally infeasible.
  • The share gives one Shamir point — information-theoretically insufficient to reconstruct the secret when K >= 2.
  • If tlock is enabled, an additional time-lock layer prevents decryption until the specified time, even with the correct passphrase.

What's in the bundle:

File Contents Reveals
README.txt / README.pdf Instructions, share in PEM format, share as BIP39 words, QR code The friend's single share + scheme parameters (N, K)
MANIFEST.age age-encrypted archive (optionally with tlock inner layer) Nothing (encrypted with 256-bit passphrase)
recover.html Self-contained recovery tool + personalization JSON Friend names/contact info (unless anonymous mode), the friend's pre-loaded share, and optionally the embedded manifest

Personalization data embedded in recover.html at bundle.go:91-99 includes:

  • The holder's name and share (necessary for recovery)
  • Other friends' names and contact info (necessary for coordinating recovery — empty in anonymous mode)
  • Threshold and total (necessary for recovery instructions)
  • Optionally the base64-encoded MANIFEST.age if <= 10 MiB (convenience for self-contained recovery)

It does not include other friends' shares. Each bundle is personalized with only its holder's share.

Code pointer: internal/bundle/bundle.go:60-144 for bundle generation.

Confidence: Code pointer + structural observation.

3.3 An attacker with access to the creator's machine after sealing

Design promise: After sealing, the creator's machine holds the encrypted manifest and all share files. An attacker with full disk access can read everything.

What this means:

  • The attacker has all N shares → they can reconstruct the passphrase → they can decrypt the manifest. This is by design. The seal operation creates shares that are meant to be distributed; until distribution, they're all in one place.
  • After distribution, the creator should delete the share files. The project doesn't automate this.

What remains on disk after sealing:

File Permissions Contains
output/MANIFEST.age 0644 Encrypted archive
output/shares/SHARE-*.txt 0600 Individual shares (PEM format)
output/bundles/bundle-*.zip 0644 Complete bundles for each friend
project.yml 0644 Friend names, SHA-256 of passphrase, share checksums

Note: project.yml stores sha256:<hash of passphrase> as a verification hash. This is a one-way hash of a 256-bit random value — offline brute force is infeasible.

Confidence: Code pointer.

3.4 A malicious or compromised dependency

Design promise: The code relies on established cryptographic libraries rather than custom primitives. Dependencies are pinned by go.sum checksums.

What enforces it:

  • go mod verify confirms all module checksums match their recorded values.
  • Only 3 Go dependencies touch sensitive data: filippo.io/age (encryption), github.com/hashicorp/vault/shamir (secret sharing), and github.com/drand/tlock (time-lock).
  • On the browser side, age-encryption (typage), shamir-secret-sharing (audited by Cure53 and Zellic), and fflate touch sensitive data.
  • Dependabot is enabled for weekly vulnerability monitoring of Go modules, GitHub Actions, and npm packages.

Residual risk: A compromised version of age, vault/shamir, or shamir-secret-sharing could exfiltrate secrets. This is mitigated by pinned versions, checksums, and Dependabot monitoring, but not eliminated. govulncheck provides ongoing monitoring for known Go vulnerabilities.

Confidence: Tool output + structural observation.

3.5 A friend who acts alone trying to recover the secret

Design promise: A single friend cannot recover the secret when threshold >= 2.

What enforces it:

Check point Location What it checks
Creation shamir.go:46 K >= 2
Creation shamir.go:49 K <= N
CLI recovery recover.go Version/total/threshold consistency; len(shares) >= threshold; no duplicate indices
WASM recovery wasm/recover.go len(shares) >= 2; version consistency; len(shares) >= threshold
JS recovery crypto/shamir.ts:18-19 shares.length < 2 throws error
Underlying library vault.Combine() / privyCombine() Requires at least 2 shares; produces garbage with fewer than K shares

Even without explicit threshold checks, Shamir reconstruction with fewer than K shares produces garbage, and the subsequent age decryption fails with "incorrect passphrase."

Confidence: Code pointer.


4. Deep Dives

4.1 Passphrase Lifecycle

Generation: internal/crypto/passphrase.go:26-39

raw = make([]byte, numBytes)             // 32 bytes (DefaultPassphraseBytes)
if _, err := rand.Read(raw); err != nil  // crypto/rand (OS CSPRNG)
passphrase = base64.RawURLEncoding.EncodeToString(raw)  // ~43 chars
  • Uses crypto/rand exclusively — verify: grep -rn "math/rand" --include="*.go" . | grep -v _test.go should return no matches.
  • 32 bytes = 256 bits of entropy. Minimum 16 bytes enforced at line 28.

Protocol versions:

  • V1 (deprecated): The base64url-encoded passphrase string was split via Shamir. This means the Shamir field elements were base64 characters (non-uniform distribution over GF(2^8)).
  • V2 (current): Raw 32 bytes are split via Shamir. The passphrase string is reconstructed by base64url-encoding the combined bytes. This maximizes entropy utilization.

Both versions are supported at recovery time. share.go:54-58 handles the version dispatch:

func RecoverPassphrase(recovered []byte, version int) string {
    if version >= 2 {
        return base64.RawURLEncoding.EncodeToString(recovered)
    }
    return string(recovered)
}

The browser-side equivalent is at crypto/shamir.ts:38-49.

Memory handling:

  • The passphrase is not zeroed from memory after use. This is a known limitation of Go — strings are immutable and Go provides no mlock/mprotect wrappers. The passphrase bytes persist in heap until garbage collected. This is standard for Go crypto code (age itself has the same property).
  • In the browser path, the passphrase exists as a JavaScript string until the page unloads. JavaScript provides no secure memory management.
  • No error messages include the passphrase — check all fmt.Errorf calls in seal.go, recover.go, and age.go. The error at age.go:54 returns "decrypting: ..." without the passphrase.

Confidence: Code pointer — the reader must read these functions and judge.

4.2 Share Format and Metadata Leakage

PEM format: internal/core/share.go:62-87

-----BEGIN REMEMORY SHARE-----
Version: 2
Index: 1
Total: 5
Threshold: 3
Holder: Alice
Created: 2026-02-25 15:04
Checksum: sha256:abc123...

<base64 encoded share data>
-----END REMEMORY SHARE-----

Headers and what they reveal:

Header Value Weakens below-threshold guarantee?
Version Protocol version (1 or 2) No — public parameter
Index Share number (1-N) No — required by Shamir (x-coordinate is in the data)
Total N (total shares) No — public scheme parameter
Threshold K (required shares) No — public scheme parameter
Holder Friend's name No — identifies holder, not secret
Created Timestamp No — operational metadata
Checksum SHA-256 of share data No — derived from share bytes, not from secret

None of these headers are derived from the secret passphrase. The checksum is a hash of the share data (a Shamir point on a random polynomial), not of the secret. Verify at share.go:47-48: HashBytes(data) hashes data (the share), not the original secret.

Compact format (QR codes): share.go:217-221RM2:1:5:3:<base64url>:<4-char checksum>. Same metadata exposure, same assessment.

Checksum verification uses constant-time comparison on the Go side: hash.go:24-26:

func VerifyHash(got, expected string) bool {
    return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}

The browser-side hash verification at crypto/hash.ts:31-33 uses standard equality (===), not constant-time comparison. The code includes a comment explaining this is intentional: both values are derived from public data (the share the user provided), so there's no secret to leak through timing.

Confidence: Code pointer.

4.3 WASM/JS Boundary (Creation Path)

Since the previous audit, the recovery path has moved from WASM to native JavaScript. WASM is now only used for maker.html (bundle creation in the browser). Recovery uses native JS crypto only.

Exposed WASM functions: internal/wasm/main_create.go:9-28 registers 10 functions:

Function Direction Validates?
rememoryParseShare string → share object Argument count; checksum verified in Go
rememoryCombineShares share array → passphrase Argument count; version consistency
rememoryDecryptManifest Uint8Array + string → Uint8Array Argument count
rememoryExtractArchive Uint8Array → file array Argument count; path traversal + size limits
rememoryExtractBundle Uint8Array → share + manifest Argument count; checksum verified
rememoryParseCompactShare string → share object Format + checksum validated
rememoryDecodeWords string array → data + index Checksum validated
rememoryCreateArchive file list → ZIP bytes Filename validation
rememoryCreateBundlesFromArchive archive + config → bundles Config validation
rememoryParseProjectYAML string → config YAML parsing

Data marshaling: Binary data crosses the boundary as Uint8Array using js.CopyBytesToGo() / js.CopyBytesToJS() — these are memory copies, not shared references. The passphrase is returned as a JavaScript string from combineSharesJS.

Code pointer: internal/wasm/js_wrappers.go — all 10 wrapper functions (~240 lines). Input validation occurs at argument count but not input size. dataLen := jsData.Get("length").Int() is used directly to allocate Go memory. This is acceptable because WASM runs in the user's own browser.

Confidence: Code pointer.

4.4 Tar/ZIP Extraction Security

Three extraction paths exist with overlapping security checks:

Go in-memory extraction (CLI + WASM): internal/core/archive.go:29-156

Defense Lines Mechanism
Path traversal (tar) 46,58 Regex rejects .. as path component
Path traversal (zip) 108,112 Same regex pattern
Non-regular files (tar) 63-64 Only tar.TypeReg extracted; directories, symlinks, devices skipped
Directories (zip) 117-118 Skipped via IsDir()
Per-file size limit 68,122 100 MB maximum (MaxFileSize)
Total size limit 72,126 1 GB maximum (MaxTotalSize)
LimitReader double-check 77,135 io.LimitReader + post-read size verification

Go file-based extraction (CLI recover): internal/manifest/archive.go:145-247

Defense Lines Mechanism
Path traversal 185 filepath.Clean(target) must have filepath.Clean(destDir) as prefix
Symlinks 228-230 Skipped with warning
Hard links 232-234 Skipped with warning
Permission masking 191,209 Directory &0777, file &0666
Size limits + LimitReader Same as in-memory Same 100 MB / 1 GB limits with LimitReader double-check

JavaScript/TypeScript extraction (browser recovery): internal/html/assets/src/crypto/archive.ts

Defense Lines Mechanism
Directories (tar) 18 Filter entry.type === 'file'
Directories (zip) 34 Skip entries ending with /
Empty archive 38-40 Throws if no files extracted
Path traversal Not explicitly checked. Relies on fflate/tarparser library behavior.
Size limits Not checked. No MaxFileSize/MaxTotalSize constants.

Notable gap: The JavaScript extraction path has no explicit path traversal checks or size limits. This is partially mitigated by:

  1. Archives are created by the Go tooling, which enforces limits at creation time
  2. Browser heap limits (typically 100-500 MB) provide a de facto ceiling
  3. Extraction is in-memory only — no filesystem writes occur in the browser
  4. The JS path extracts into an in-memory array, not into a directory structure where path traversal would matter

Still, a maliciously crafted archive could cause a browser tab to crash via memory exhaustion. This is a DoS against the person running recovery, not a data exfiltration risk.

Confidence: Code pointer.

4.5 Threshold Validation

At creation (Go): internal/core/shamir.go:44-56

  • K >= 2 (line 46)
  • K <= N (line 49)
  • N <= 255 (line 52)

At CLI recovery: Validates version/total/threshold consistency across shares, checks len(shares) >= threshold, and rejects duplicate indices.

At browser recovery (native JS): crypto/shamir.ts:18-19 enforces minimum 2 shares. The UI prevents the "Recover" button from activating until enough shares are added.

Note: vault.Combine() and privyCombine() both accept fewer than K shares and return garbage without error. The garbage passphrase then fails age decryption. This is not a vulnerability — it's a correct behavior for Shamir that's caught at the next layer (age produces an error on wrong passphrase).

Confidence: Code pointer.

4.6 Tlock (Time-Lock Encryption)

Tlock is an optional inner encryption layer that prevents decryption until a specified time in the future, using the drand randomness beacon network.

Go encryption (seal time): internal/core/tlock.go:29-40

Encryption is fully offline. An offlineNetwork implementation at lines 61-101 provides the drand public key and chain parameters from embedded constants — no HTTP calls. This delegates to the upstream github.com/drand/tlock library.

Go decryption (CLI recover): tlock.go:45-56

Decryption requires fetching the drand beacon signature for the round embedded in the ciphertext. connectDrand() at lines 105-116 tries each drand relay endpoint in order. If the round hasn't been reached yet, tlock.ErrTooEarly is returned.

Browser decryption (tlock.ts): internal/html/assets/src/tlock.ts

This is a custom adaptation of tlock-js 0.9.0 and drand-client 1.4.2 (both Apache-2.0 OR MIT). The file header at lines 1-48 documents exactly what was adapted and what changed:

  1. Buffer replaced with Uint8Array (browser compatibility)
  2. BLS signature verification uses bls12_381.verifyShortSignature() from @noble/curves (audited) instead of manual pairing
  3. parseCiphertext inlined into decryptOnG2

Key cryptographic operations:

  • IBE Decryption (decryptOnG2): Implements Identity-Based Encryption decryption using BLS12-381 pairing
  • Beacon Verification (verifyBeacon): Verifies drand beacon signatures using the correct DST (BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_)
  • Constant-time byte comparison (bytesEqual): Used for IBE verification step

Drand chain constants: internal/core/tlock_common.go:13-26

All constants are from the public drand quicknet chain (League of Entropy):

  • Chain hash: 52db9ba70e0cc0f6...
  • Period: 3 seconds
  • Genesis: 2023-08-23T11:22:47Z
  • Scheme: bls-unchained-g1-rfc9380

Tlock container format: internal/core/tlock_container.go

A ZIP archive containing:

  • tlock.json — metadata (version, method, round, unlock time, chain hash). All public, no secrets.
  • manifest.tlock.age — the tlock-encrypted ciphertext

Layering:

  1. Plaintext → tlock encrypt (for future drand round) → manifest.tlock.age
  2. Package as tlock container ZIP
  3. ZIP → age encrypt (with passphrase) → MANIFEST.age

This means an attacker needs both the passphrase (from K shares) AND the drand beacon (from the future) to decrypt.

Confidence: Code pointer — the tlock.ts adaptation is a significant code path that warrants professional review. See Section 5.

4.7 Dependency Deep-Dive: What Touches Sensitive Data

Go code paths that handle the passphrase:

Library Version What it does with the passphrase
crypto/rand (stdlib) Go 1.25.7 Generates the 32 random bytes
filippo.io/age v1.3.1 scrypt key derivation + ChaCha20-Poly1305 encryption
github.com/hashicorp/vault/shamir v1.21.3 GF(2^8) polynomial split/combine
github.com/drand/tlock v1.2.0 IBE encryption of manifest (passphrase not directly involved — tlock wraps the plaintext, then age wraps the tlock output)

Browser code paths:

Library Source What it does
age-encryption (npm) typage scrypt + ChaCha20-Poly1305 decryption
shamir-secret-sharing (npm) privy-io GF(2^8) Shamir combine (audited by Cure53 + Zellic)
@noble/curves (npm) paulmillr BLS12-381 pairing for tlock verification (audited)
@noble/hashes (npm) paulmillr SHA-256 for tlock (audited)
Web Crypto API (browser built-in) Browser SHA-256 for share checksums

age (v1.3.1): Created by Filippo Valsorda (former Go security lead). Has a formal specification. Uses scrypt for key derivation from passphrase, ChaCha20-Poly1305 for encryption. Widely used and audited.

vault/shamir (v1.21.3): Part of HashiCorp Vault. The shamir subpackage implements Shamir's Secret Sharing over GF(2^8). Battle-tested in production by thousands of organizations.

shamir-secret-sharing (npm 0.0.4): From privy-io. Uses the same GF(2^8) share format as HashiCorp Vault. Independently audited by Cure53 and Zellic.

@noble/curves + @noble/hashes: From Paul Miller. Audited and widely used in the JavaScript cryptography ecosystem.

Confidence: Structural observation.

4.8 XSS and HTML Injection

Server-side HTML generation: internal/html/recover.go:136-142

Personalization data (friend names, contact info, share content) is embedded in recover.html via json.Marshal(). Go's json.Marshal HTML-escapes <, >, & by default (as \u003c, \u003e, \u0026), preventing </script> injection.

CSP as defense-in-depth: Even if an XSS vector were found, the nonce-based CSP would prevent execution of injected scripts that don't carry the correct nonce.

What the reader should verify: Search for innerHTML assignments in app.ts that don't use proper escaping. The CSP is a safety net, but proper escaping is the primary defense.

Confidence: Code pointer.

4.9 Self-Hosted Server

The optional rememory serve command starts a local HTTP server at internal/serve/server.go.

Notable finding: server.go:118 uses http.ListenAndServe() without timeout configuration. gosec flagged this as G114 (CWE-676). The server is designed for local use, but adding read/write timeouts would be a straightforward hardening improvement.

The server serves the recovery page and optionally the manifest file. The recovery HTML includes 'self' in its CSP connect-src so it can fetch the manifest from the same server.

Confidence: Code pointer.


5. Where a Professional Reviewer Should Focus

Ranked by risk and time-to-review:

Priority 1: Tlock Browser Implementation (2-3 hours)

File Lines Why
internal/html/assets/src/tlock.ts ~460 Custom adaptation of tlock-js + drand-client. IBE decryption, BLS verification, beacon fetching. This is the highest-risk code in the project — it's adapted cryptography, not a library call.

What to look for: Correctness of the IBE decryption (decryptOnG2), proper BLS signature verification, correct DST string, correct field element serialization (fp/fp2/fp6/fp12ToBytes). Compare against the tlock-js and drand-client sources cited in the file header.

Priority 2: Cryptographic Core (1-2 hours)

File Lines Why
internal/crypto/passphrase.go 39 Passphrase generation — entropy source, byte count, encoding
internal/core/shamir.go 56 Shamir wrapper — parameter validation, delegation to vault
internal/core/age.go 85 age wrapper — empty passphrase check, scrypt mode usage
internal/core/share.go 328 Share format — metadata in headers, encoding/decoding, checksum
internal/core/hash.go 26 Constant-time hash comparison

What to look for: Passphrase leakage in error paths, correct delegation to libraries, parameter validation completeness, v1/v2 protocol handling.

Priority 3: Browser Crypto (Native JS) (1-2 hours)

File Lines Why
internal/html/assets/src/crypto/shamir.ts 75 Shamir combine + passphrase recovery (v1/v2)
internal/html/assets/src/crypto/share.ts 150 PEM and compact share parsing, checksum verification
internal/html/assets/src/crypto/age.ts 20 age decryption wrapper
internal/html/assets/src/crypto/archive.ts 107 Archive extraction (no size limits — see 4.4)
internal/html/assets/src/crypto/hash.ts 33 SHA-256 via Web Crypto, non-constant-time verify (intentional)

What to look for: Correctness of v1/v2 passphrase recovery in the browser, base64/base64url conversion correctness, share parsing edge cases, whether the non-constant-time verifyHash is truly safe (the comment at hash.ts:26-29 argues it is).

Priority 4: Archive Handling (1 hour)

File Lines Why
internal/core/archive.go 176 In-memory tar.gz + ZIP extraction with security checks
internal/manifest/archive.go ~500 File-based tar.gz/ZIP extraction with security checks

What to look for: Path traversal bypasses (especially the regex at archive.go:46 and the filepath.Clean prefix check at manifest/archive.go:185,400), symlink handling, size limit enforcement with LimitReader.

Priority 5: WASM/JS Boundary + HTML Generation (1 hour)

File Lines Why
internal/wasm/js_wrappers.go 239 Bridge between JS and Go — all data crossing the boundary
internal/html/recover.go 242 Personalization embedding, CSP construction, tlock variant selection
internal/bundle/bundle.go ~350 ZIP creation, per-friend personalization, manifest embedding

What to look for: Input validation gaps in WASM wrappers, json.Marshal XSS safety, CSP correctness, whether each bundle truly contains only its holder's share.


6. What This Audit Did Not Cover

For full transparency:

  • Cryptographic correctness of age, vault/shamir, and @noble/curves. These libraries are treated as trusted primitives. Their security properties are assumed from their specifications, audits, and track records.
  • Formal verification of the tlock.ts adaptation. The custom tlock browser implementation was reviewed for structural correctness against its documented sources, but not formally verified. This is the area most likely to benefit from professional review.
  • Side-channel attacks beyond timing (power analysis, EM emissions, cache timing). Go's crypto/subtle.ConstantTimeCompare is used for hash verification on the server side. Memory-resident passphrases could theoretically be extracted via side channels.
  • Memory safety of the Go runtime and WASM/JS environments. Go is memory-safe by design; WASM runs in a browser sandbox; JavaScript has GC-managed memory.
  • Supply chain attacks beyond checksum verification. go.sum and package-lock.json ensure integrity against the module proxy/npm registry, but don't protect against compromised upstream repositories at the time of initial pinning.
  • Browser-specific attacks (extensions, compromised browsers, shared machines). The recovery tool runs in whatever browser the user opens it in — it inherits that browser's security posture.
  • Physical security (secure erasure of printed shares, bundle USB drives, etc.)
  • drand network reliability. Tlock-enabled bundles depend on the drand quicknet chain remaining operational at recovery time. If the drand network shuts down, tlock decryption becomes impossible. The non-tlock recovery path (which is the default) has no such dependency.

Appendix A: Verification Commands

Run these from the repository root at commit 80cebf2:

# 1. No vulnerabilities in called code
nix develop -c go run golang.org/x/vuln/cmd/govulncheck@latest ./...

# 2. Static security analysis
nix shell nixpkgs#gosec -c gosec -quiet -exclude-dir=.claude ./...

# 3. Standard Go static analysis
nix develop -c go vet ./...

# 4. Dependency integrity
nix develop -c go mod verify

# 5. SBOM + vulnerability scan
nix shell nixpkgs#syft -c syft dir:. -o table
nix shell nixpkgs#grype -c grype dir:.

# 6. No os/exec
grep -rn "os/exec" --include="*.go" . | grep -v _test.go

# 7. No telemetry
grep -ri "telemetry\|analytics\|tracking" --include="*.go" --include="*.ts" . --exclude-dir=node_modules

# 8. No math/rand
grep -rn "math/rand" --include="*.go" . | grep -v _test.go

# 9. Passphrase entropy source
grep -n "crypto/rand" internal/crypto/passphrase.go

# 10. Passphrase entropy size
grep -n "DefaultPassphraseBytes" internal/crypto/passphrase.go

# 11. Constant-time hash comparison (Go side)
grep -n "ConstantTimeCompare" internal/core/hash.go

# 12. Share file permissions
grep -n "0600" internal/cmd/seal.go

# 13. Path traversal protection (in-memory)
grep -n "\\.\\." internal/core/archive.go

# 14. Path traversal protection (file-based)
grep -n "HasPrefix" internal/manifest/archive.go

# 15. Threshold minimum enforced
grep -n "threshold must be at least 2" internal/core/shamir.go

# 16. Symlinks rejected
grep -n -i "symlink\|TypeSymlink" internal/manifest/archive.go internal/core/archive.go

# 17. CSP nonce generation
grep -rn "crypto/rand" internal/html/csp.go

# 18. Vault import scope
grep -r "hashicorp/vault" --include="*.go" . | grep -v _test.go | grep -v ".claude"

# 19. Tlock drand endpoints
grep -n "drand" internal/core/tlock_common.go

# 20. JS crypto library audits
grep -n "audit" internal/html/assets/src/crypto/shamir.ts

# 21. Run the full test suite
nix develop -c make test

Appendix B: Changes Since Previous Audit

This audit updates the previous audit at commit 5f464d1 (2026-02-11). Key changes in the codebase since then:

Change Impact on Security
Recovery path moved from WASM to native JavaScript Recovery no longer requires WASM. recover.html uses native JS crypto: age-encryption, shamir-secret-sharing (audited), fflate, tarparser. This is a significant architecture change — see Priority 3 in Section 5.
Custom tlock.ts implementation for browser recovery Replaces tlock-js + drand-client in the recovery bundle with a custom adaptation. Uses @noble/curves (audited) for BLS verification. This is the highest-risk new code — see Priority 1 in Section 5.
Self-hosted server (rememory serve) New HTTP server for hosting recovery page. No timeouts configured (gosec G114). Local-only convenience tool.
Static hosting mode Recovery page can fetch manifest from same-origin server. CSP updated with 'self' for static/self-hosted variants.
tlock container format ZIP-based container wrapping tlock metadata and ciphertext. All metadata is public.
actions/download-artifact grype finding Likely false positive — grype reads @v4 as v4.0.0 but the floating tag resolves to latest 4.x (past the 4.1.3 fix). CI only.

The previous audit found zero critical vulnerabilities and recommended several hardening measures. All recommendations from the previous audit have been implemented. The current audit found zero critical vulnerabilities. The main area of new risk is the custom tlock.ts implementation, which warrants professional review.


Appendix C: References


This is a point-in-time artifact auditing commit 80cebf2. Code links use this commit hash for permanence. For mission-critical use, this document is a starting point — not a substitute for a professional security engagement.