summaryrefslogtreecommitdiffstats
path: root/cmd/nymdrop-reader
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/nymdrop-reader')
-rw-r--r--cmd/nymdrop-reader/main.go43
-rw-r--r--cmd/nymdrop-reader/memguard_verify_test.go65
2 files changed, 93 insertions, 15 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)
+ }
+}