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
- Is This Software Safe to Install and Run?
- Is This Software Secure for Its Purpose?
- Threat Model
- Deep Dives
- Where a Professional Reviewer Should Focus
- What This Audit Did Not Cover
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.
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.
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:118 — http.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.
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).
nix develop -c go vet ./...
nix develop -c go mod verifyOutput at time of audit:
go vet: Clean — no issues found.go mod verify:all modules verified— every dependency matches its recorded checksum ingo.sum.
Confidence: Tool output.
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.
# 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 matchesThe 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 (CLIrecovercommand 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:
TestMainininternal/offline_test.goandinternal/core/offline_test.goreplaceshttp.DefaultTransportwith a custom dialer that rejects all connections. It also overridesnet.DefaultResolverwithPreferGo: trueso DNS lookups go through the same blocking dialer — even libraries that create their ownhttp.Clientcan't sneak past. WhenREMEMORY_TEST_TLOCK=1is set, only drand relay hosts are allowlisted; everything else still fails. -
Playwright E2E tests:
e2e/fixtures.tsintercepts all HTTP/HTTPS requests viapage.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 explicitallowedHostsallowlist.
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.
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 endpointsContent 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.
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.
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.
The design composes established cryptographic primitives in layers:
- age (scrypt mode) encrypts the manifest archive with a random passphrase
- Shamir's Secret Sharing (HashiCorp Vault) splits the passphrase into shares
- 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.
Sealing (creation):
- Archive
manifest/directory into tar.gz (in memory) - Generate 32 random bytes via
crypto/rand→ base64url encode → passphrase - Optionally tlock-encrypt the tar.gz for a future drand round (inner layer)
- Encrypt (tlock container or raw tar.gz) with age (scrypt mode, passphrase)
- Split raw 32 bytes via Shamir into N shares with threshold K
- Verify reconstruction: combine K shares, re-derive passphrase, compare
- Write MANIFEST.age (encrypted), share files (0600), ZIP bundles
Recovery (reconstruction):
- Parse share files (PEM, compact, QR, or BIP39 words), verify checksums
- Validate compatibility (version, total, threshold must match)
- Check threshold met, no duplicate indices
- Combine shares via Shamir → recover raw bytes → base64url encode → passphrase
- Decrypt MANIFEST.age with recovered passphrase (age layer)
- If tlock container: decrypt inner tlock layer using drand beacon
- 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.
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 togithub.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.
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.
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.
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 verifyconfirms 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), andgithub.com/drand/tlock(time-lock). - On the browser side,
age-encryption(typage),shamir-secret-sharing(audited by Cure53 and Zellic), andfflatetouch 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.
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.
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/randexclusively — verify:grep -rn "math/rand" --include="*.go" . | grep -v _test.goshould 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/mprotectwrappers. 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.Errorfcalls in seal.go, recover.go, and age.go. The error atage.go:54returns "decrypting: ..." without the passphrase.
Confidence: Code pointer — the reader must read these functions and judge.
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-221 — RM2: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.
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.
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:
- Archives are created by the Go tooling, which enforces limits at creation time
- Browser heap limits (typically 100-500 MB) provide a de facto ceiling
- Extraction is in-memory only — no filesystem writes occur in the browser
- 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.
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.
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:
Bufferreplaced withUint8Array(browser compatibility)- BLS signature verification uses
bls12_381.verifyShortSignature()from@noble/curves(audited) instead of manual pairing parseCiphertextinlined intodecryptOnG2
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:
- Plaintext → tlock encrypt (for future drand round) →
manifest.tlock.age - Package as tlock container ZIP
- 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.
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.
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.
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.
Ranked by risk and time-to-review:
| 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.
| 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.
| 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).
| 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.
| 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.
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.ConstantTimeCompareis 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.sumandpackage-lock.jsonensure 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.
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 testThis 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.
- age encryption — GitHub
- age specification (C2SP)
- HashiCorp Vault shamir package
- Shamir's Secret Sharing — Wikipedia
- shamir-secret-sharing audit (Cure53)
- shamir-secret-sharing audit (Zellic)
- drand — League of Entropy
- tlock — time-lock encryption
- @noble/curves — audited JS crypto
- govulncheck — Go vulnerability scanner
- gosec — Go security static analysis
- syft — SBOM generator
- grype — vulnerability scanner
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.