diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-06 16:34:47 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-06 16:34:47 +0200 |
| commit | 16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c (patch) | |
| tree | 7ac7c321cacd62795cc23939bebdc9c48975377e | |
| parent | 4fd6b4fefa7243f68fae7e24df3da46ee1a8e356 (diff) | |
| download | nymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.tar.gz nymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.tar.xz nymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.zip | |
Guard key material in RAM with memguard, add Markdown preview for message field
Reader now keeps the private key bytes and the per-submission ECDH/HKDF
secrets in memguard.LockedBuffer (mlock + guaranteed wipe) instead of a
manual zero loop. Added a regression test for the decrypt path.
Message field gets a self-hosted Markdown toggle preview (no external
library): headers, bold/italic, inline code, code blocks, blockquotes,
lists, links. Output is HTML-escaped and link schemes are whitelisted
before rendering. Submitted content is still the raw Markdown source,
unchanged on the wire.
| -rw-r--r-- | cmd/nymdrop-reader/main.go | 43 | ||||
| -rw-r--r-- | cmd/nymdrop-reader/memguard_verify_test.go | 65 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | static/crypto.js | 7 | ||||
| -rw-r--r-- | static/index.html | 9 | ||||
| -rw-r--r-- | static/markdown.js | 149 | ||||
| -rw-r--r-- | static/style.css | 73 |
8 files changed, 343 insertions, 19 deletions
diff --git a/cmd/nymdrop-reader/main.go b/cmd/nymdrop-reader/main.go index 248972e..e2632cb 100644 --- a/cmd/nymdrop-reader/main.go +++ b/cmd/nymdrop-reader/main.go @@ -25,6 +25,7 @@ import ( "syscall" "time" + "github.com/awnumar/memguard" "golang.org/x/net/websocket" ) @@ -73,6 +74,10 @@ func main() { signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig fmt.Println("\nShutting down.") + // Safety net: wipes any locked buffer still alive (e.g. one in flight in + // receiveLoop when the signal arrived) on top of the deferred Destroy + // calls that cover the normal path. + memguard.Purge() } // ── key management ─────────────────────────────────────────────────────────── @@ -87,7 +92,12 @@ func loadOrGenKey(keyFile, homeDir string) *ecdh.PrivateKey { if err != nil { fatalf("privkey decode: %v", err) } - priv, err := ecdh.X25519().NewPrivateKey(raw) + // raw is wiped as soon as it's copied into locked, non-swappable memory; + // ecdh.NewPrivateKey takes its own copy, so the guarded buffer can be + // destroyed right after construction. + keyBuf := memguard.NewBufferFromBytes(raw) + priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes()) + keyBuf.Destroy() if err != nil { fatalf("privkey load: %v", err) } @@ -102,16 +112,21 @@ func loadOrGenKey(keyFile, homeDir string) *ecdh.PrivateKey { if _, err := rand.Read(rawPriv); err != nil { fatalf("keygen rand: %v", err) } - priv, err := ecdh.X25519().NewPrivateKey(rawPriv) + keyBuf := memguard.NewBufferFromBytes(rawPriv) // wipes rawPriv on copy + priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes()) if err != nil { + keyBuf.Destroy() fatalf("keygen: %v", err) } if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil { + keyBuf.Destroy() fatalf("mkdir keydir: %v", err) } - if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(rawPriv)+"\n"), 0600); err != nil { + if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(keyBuf.Bytes())+"\n"), 0600); err != nil { + keyBuf.Destroy() fatalf("write privkey: %v", err) } + keyBuf.Destroy() pubHex := hex.EncodeToString(priv.PublicKey().Bytes()) fmt.Printf("Generated new private key → %s\n", keyFile) fmt.Printf("Public key (deploy in nymdrop-server): %s\n\n", pubHex) @@ -344,18 +359,22 @@ func decryptMessage(dataB64 string, privKey *ecdh.PrivateKey) (string, error) { if err != nil { return "", fmt.Errorf("ecdh: %w", err) } - defer zeroBytes(sharedSecret) + // Locked, non-swappable memory for the two secrets derived per submission; + // Destroy zeroes and unlocks instead of relying on a plain overwrite loop. + sharedBuf := memguard.NewBufferFromBytes(sharedSecret) + defer sharedBuf.Destroy() // HKDF-SHA-256 - aesKey := make([]byte, 32) - hkdfR := hkdf.New(sha256.New, sharedSecret, make([]byte, 32), []byte("nymdrop-v1")) - if _, err := io.ReadFull(hkdfR, aesKey); err != nil { + aesKeyRaw := make([]byte, 32) + hkdfR := hkdf.New(sha256.New, sharedBuf.Bytes(), make([]byte, 32), []byte("nymdrop-v1")) + if _, err := io.ReadFull(hkdfR, aesKeyRaw); err != nil { return "", fmt.Errorf("hkdf: %w", err) } - defer zeroBytes(aesKey) + aesBuf := memguard.NewBufferFromBytes(aesKeyRaw) + defer aesBuf.Destroy() // AES-GCM-256 decrypt - block, err := aes.NewCipher(aesKey) + block, err := aes.NewCipher(aesBuf.Bytes()) if err != nil { return "", fmt.Errorf("aes: %w", err) } @@ -448,12 +467,6 @@ func saveAttachments(outDir, ts, fileSection string) { // ── helpers ─────────────────────────────────────────────────────────────────── -func zeroBytes(b []byte) { - for i := range b { - b[i] = 0 - } -} - func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "nymdrop-reader: "+format+"\n", args...) os.Exit(1) diff --git a/cmd/nymdrop-reader/memguard_verify_test.go b/cmd/nymdrop-reader/memguard_verify_test.go new file mode 100644 index 0000000..3ab5fce --- /dev/null +++ b/cmd/nymdrop-reader/memguard_verify_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "testing" + + "golang.org/x/crypto/hkdf" +) + +// Verifies the decryptMessage path still produces the correct plaintext after +// wrapping the ECDH/HKDF secrets in memguard.LockedBuffer, using the exact +// wire format the browser client (static/crypto.js) and relay produce. +func TestDecryptMessageAfterMemguard(t *testing.T) { + readerPriv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + ephPriv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + shared, err := ephPriv.ECDH(readerPriv.PublicKey()) + if err != nil { + t.Fatal(err) + } + aesKey := make([]byte, 32) + hkdfR := hkdf.New(sha256.New, shared, make([]byte, 32), []byte("nymdrop-v1")) + if _, err := io.ReadFull(hkdfR, aesKey); err != nil { + t.Fatal(err) + } + block, err := aes.NewCipher(aesKey) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + t.Fatal(err) + } + want := "E2E memguard verification message" + ciphertext := gcm.Seal(nil, iv, []byte(want), nil) + + packet := append(append(append([]byte{}, ephPriv.PublicKey().Bytes()...), iv...), ciphertext...) + noncePrefix := hex.EncodeToString(make([]byte, 16)) + ":" + dataB64 := base64.StdEncoding.EncodeToString(append([]byte(noncePrefix), packet...)) + + got, err := decryptMessage(dataB64, readerPriv) + if err != nil { + t.Fatalf("decryptMessage: %v", err) + } + if got != want { + t.Fatalf("plaintext mismatch: got %q want %q", got, want) + } +} @@ -3,6 +3,12 @@ module nymdrop go 1.26.2 require ( - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect + github.com/awnumar/memguard v0.23.0 + golang.org/x/crypto v0.51.0 + golang.org/x/net v0.54.0 +) + +require ( + github.com/awnumar/memcall v0.4.0 // indirect + golang.org/x/sys v0.44.0 // indirect ) @@ -1,4 +1,10 @@ +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/static/crypto.js b/static/crypto.js index f1cc6c9..e196826 100644 --- a/static/crypto.js +++ b/static/crypto.js @@ -161,6 +161,13 @@ document.getElementById("drop-form").addEventListener("submit", async (e) => { if (resp.ok) { document.getElementById("drop-form").reset(); document.getElementById("filename").textContent = ""; + const preview = document.getElementById("message-preview"); + const previewToggle = document.getElementById("preview-toggle"); + if (preview && previewToggle && !preview.hidden) { + preview.hidden = true; + document.getElementById("message").hidden = false; + previewToggle.textContent = "Preview"; + } status.textContent = "Delivered. No record of this submission exists on the server."; } else { status.className = "error"; diff --git a/static/index.html b/static/index.html index 33e5868..abf2e8c 100644 --- a/static/index.html +++ b/static/index.html @@ -27,8 +27,12 @@ </div> <form id="drop-form"> - <label for="message">Message</label> - <textarea id="message" placeholder="Write your message here. It will be encrypted before leaving your browser."></textarea> + <div class="message-header"> + <label for="message">Message</label> + <button type="button" id="preview-toggle" class="preview-toggle">Preview</button> + </div> + <textarea id="message" placeholder="Write your message here (Markdown supported). It will be encrypted before leaving your browser."></textarea> + <div id="message-preview" class="md-preview" hidden></div> <div class="file-section"> <label>Attachment (optional)</label> @@ -54,6 +58,7 @@ </div> <div class="footer">NYMDROP · ZERO KNOWLEDGE · ZERO LOGS</div> <script src="/crypto.js"></script> +<script src="/markdown.js"></script> <script src="/app.js"></script> </body> </html> diff --git a/static/markdown.js b/static/markdown.js new file mode 100644 index 0000000..bad1a6d --- /dev/null +++ b/static/markdown.js @@ -0,0 +1,149 @@ +// NymDrop — minimal self-hosted Markdown preview for the message field. +// No external library: a small, auditable subset is enough for a +// submission form, and it avoids pulling a third-party JS dependency into +// a page whose whole point is minimizing trust in client-side code. +// +// The rendered HTML never leaves the browser — the encrypted submission +// still carries the raw Markdown source text untouched (see app.js). This +// is purely a "what will this look like" aid for the person writing it. + +function mdEscapeHtml(str) { + return str + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Only allow link schemes that can't execute script in the rendered preview. +function mdSafeHref(url) { + if (/^(https?:|mailto:)/i.test(url)) return mdEscapeHtml(url); + return "#"; +} + +function mdInline(text) { + let out = mdEscapeHtml(text); + out = out.replace(/`([^`]+)`/g, "<code>$1</code>"); + out = out.replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (m, a, b) => `<strong>${a || b}</strong>`); + out = out.replace(/\*([^*]+)\*|_([^_]+)_/g, (m, a, b) => `<em>${a || b}</em>`); + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => + `<a href="${mdSafeHref(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`); + return out; +} + +function renderMarkdown(source) { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const html = []; + let i = 0; + let para = []; + + function flushPara() { + if (para.length) { + html.push("<p>" + para.map(mdInline).join("<br>") + "</p>"); + para = []; + } + } + + while (i < lines.length) { + const line = lines[i]; + + if (/^```/.test(line)) { + flushPara(); + const code = []; + i++; + while (i < lines.length && !/^```/.test(lines[i])) { + code.push(lines[i]); + i++; + } + html.push("<pre><code>" + mdEscapeHtml(code.join("\n")) + "</code></pre>"); + i++; + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.*)$/); + if (heading) { + flushPara(); + const level = heading[1].length; + html.push(`<h${level}>${mdInline(heading[2])}</h${level}>`); + i++; + continue; + } + + if (/^(---|\*\*\*)\s*$/.test(line)) { + flushPara(); + html.push("<hr>"); + i++; + continue; + } + + if (/^>\s?/.test(line)) { + flushPara(); + const quote = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + quote.push(lines[i].replace(/^>\s?/, "")); + i++; + } + html.push("<blockquote>" + renderMarkdown(quote.join("\n")) + "</blockquote>"); + continue; + } + + if (/^[-*]\s+/.test(line)) { + flushPara(); + const items = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i])) { + items.push(`<li>${mdInline(lines[i].replace(/^[-*]\s+/, ""))}</li>`); + i++; + } + html.push("<ul>" + items.join("") + "</ul>"); + continue; + } + + if (/^\d+\.\s+/.test(line)) { + flushPara(); + const items = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { + items.push(`<li>${mdInline(lines[i].replace(/^\d+\.\s+/, ""))}</li>`); + i++; + } + html.push("<ol>" + items.join("") + "</ol>"); + continue; + } + + if (line.trim() === "") { + flushPara(); + i++; + continue; + } + + para.push(line); + i++; + } + flushPara(); + + return html.join("\n"); +} + +(function() { + const textarea = document.getElementById("message"); + const preview = document.getElementById("message-preview"); + const toggle = document.getElementById("preview-toggle"); + if (!textarea || !preview || !toggle) return; + + let showingPreview = false; + + toggle.addEventListener("click", function() { + showingPreview = !showingPreview; + if (showingPreview) { + preview.innerHTML = renderMarkdown(textarea.value) || + '<p class="md-empty">Nothing to preview yet.</p>'; + textarea.hidden = true; + preview.hidden = false; + toggle.textContent = "Edit"; + } else { + preview.hidden = true; + textarea.hidden = false; + toggle.textContent = "Preview"; + } + }); +})(); diff --git a/static/style.css b/static/style.css index d463876..a2cb765 100644 --- a/static/style.css +++ b/static/style.css @@ -193,6 +193,79 @@ textarea { textarea:focus { border-color: var(--accent-green); box-shadow: 0 0 0 2px var(--badge-green-bg); } +.message-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.message-header label { margin-bottom: 0.4rem; } + +.preview-toggle { + background: var(--file-label-bg); + border: 1px solid var(--field-border); + border-radius: 6px; + color: var(--label); + font-family: 'Courier New', Courier, monospace; + font-size: 0.68rem; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.25rem 0.6rem; + margin-bottom: 0.5rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} + +.preview-toggle:hover { border-color: var(--accent-blue-text); color: var(--accent-blue-text); } + +.md-preview { + width: 100%; + min-height: 160px; + max-height: 400px; + overflow-y: auto; + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: 6px; + color: var(--text); + font-size: 0.88rem; + line-height: 1.6; + padding: 0.9rem; + margin-bottom: 1.4rem; + box-sizing: border-box; +} + +.md-preview .md-empty { color: var(--tagline); font-style: italic; } +.md-preview h1, .md-preview h2, .md-preview h3 { color: var(--heading); margin: 0.6rem 0 0.4rem; line-height: 1.3; } +.md-preview h1 { font-size: 1.2rem; } +.md-preview h2 { font-size: 1.08rem; } +.md-preview h3 { font-size: 0.98rem; } +.md-preview p { margin-bottom: 0.7rem; } +.md-preview p:last-child { margin-bottom: 0; } +.md-preview ul, .md-preview ol { margin: 0 0 0.7rem 1.4rem; } +.md-preview blockquote { + border-left: 3px solid var(--field-border); + color: var(--tagline); + margin: 0 0 0.7rem; + padding: 0.1rem 0 0.1rem 0.8rem; +} +.md-preview code { + background: var(--file-label-bg); + border-radius: 3px; + padding: 0.1rem 0.3rem; + font-size: 0.85em; +} +.md-preview pre { + background: var(--file-label-bg); + border: 1px solid var(--field-border); + border-radius: 6px; + padding: 0.7rem; + overflow-x: auto; + margin-bottom: 0.7rem; +} +.md-preview pre code { background: none; padding: 0; } +.md-preview a { color: var(--accent-blue-text); } +.md-preview hr { border: none; border-top: 1px solid var(--field-border); margin: 0.8rem 0; } + .file-section { margin-bottom: 1.8rem; } .file-label { |
