summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-06 16:34:47 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-06 16:34:47 +0200
commit16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c (patch)
tree7ac7c321cacd62795cc23939bebdc9c48975377e
parent4fd6b4fefa7243f68fae7e24df3da46ee1a8e356 (diff)
downloadnymdrop-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.go43
-rw-r--r--cmd/nymdrop-reader/memguard_verify_test.go65
-rw-r--r--go.mod10
-rw-r--r--go.sum6
-rw-r--r--static/crypto.js7
-rw-r--r--static/index.html9
-rw-r--r--static/markdown.js149
-rw-r--r--static/style.css73
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)
+ }
+}
diff --git a/go.mod b/go.mod
index 2401581..1a89b6f 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 6698941..d9183bd 100644
--- a/go.sum
+++ b/go.sum
@@ -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 &nbsp;·&nbsp; ZERO KNOWLEDGE &nbsp;·&nbsp; 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, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
+
+// 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 {