diff options
Diffstat (limited to 'cmd/nymdrop-reader')
| -rw-r--r-- | cmd/nymdrop-reader/main.go | 43 | ||||
| -rw-r--r-- | cmd/nymdrop-reader/memguard_verify_test.go | 65 |
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) + } +} |
