Skip to content

fix(auth): rewrite memo encryption to spec, interop with steem-js#16

Merged
ety001 merged 1 commit into
masterfrom
fix/memo-ecdh-steemjs-interop
Jul 20, 2026
Merged

fix(auth): rewrite memo encryption to spec, interop with steem-js#16
ety001 merged 1 commit into
masterfrom
fix/memo-ecdh-steemjs-interop

Conversation

@ety001

@ety001 ety001 commented Jul 18, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the [高] memo encryption finding in steem-audit/projects/steemutil/audit-reports/2026-07-code-scan.md: deriveSharedSecret was SHA-256(privBytes ‖ pubBytes), not ECDH — so it was not symmetric, and the real recipient could never decrypt a memo addressed to them. The old tests passed only because they used the same WIF for both encrypt and decrypt (self-to-self), hiding the bug.

The audit report said "just swap to real ECDH", but in fact four layers were wrong and had to be rewritten to reach interoperability: key derivation (SHA-512, not SHA-256), plaintext framing (writeVString varint prefix), checksum, and the entire wire format (steem-js canonical EncryptedMemo layout, not the previous custom 1-byte-length-prefixed format).

Fix

Rewritten to the canonical algorithm, verified byte-for-byte against steem-js next @ d3945f1 (src/memo + src/auth/ecc/src/aes):

x  = ECDH(priv, pub)          # secp256k1 shared X coordinate
S  = SHA512(x)                # 64 bytes
ek = SHA512(uint64_le(nonce) ‖ S)
key = ek[0:32];  iv = ek[32:48]
check = uint32_le(SHA256(ek)[0:4])
plaintext = varint32(len) ‖ utf8(message)   # bytebuffer writeVString
ciphertext = AES-256-CBC(key, iv, PKCS7(plaintext))

wire = from(33) ‖ to(33) ‖ nonce(uint64 LE) ‖ check(uint32 LE) ‖ varint32(len(ct)) ‖ ct
  • ECDH via secp256k1.GenerateSharedSecret (decred dcrec/secp256k1/v4, already a dep). No type conversion needed: btcec.PrivateKey is a type alias for secp256k1.PrivateKey.
  • Public Encode/Decode signatures unchanged (consumed by steemgosdk). Added EncodeWithNonce (deterministic tests) and UniqueNonce (mirrors steem-js Aes.uniqueNonce: time<<32 ‖ random32).

Verification

Test vectors were generated by running steem-js locally with fixed (senderWif, recipientPub, memo, nonce); the generator script was temporary and removed (steem-js tree left clean).

  • TestEncodeByteEqualityWithSteemJS — Go EncodeWithNonce output is identical to steem-js memo.encode for the same inputs.
  • TestCrossKeyEncryption — sender encrypts, recipient decrypts (the path the bug broke), both directions.
  • TestDecodeSteemJSMemo — Go decodes a memo produced by steem-js.
  • TestDecodeRejectsWrongKey / TestDecodeRejectsTamperedMemo — fail-closed.
  • TestWireFormatLayout — hand-decodes the wire and re-serializes for byte equality.
go test ./...   # all green
go vet ./...    # clean

Refs: steem-audit/projects/steemutil/audit-reports/2026-07-code-scan.md

The memo encryption had two compounding defects that together made it
neither secure nor interoperable:

1. deriveSharedSecret computed SHA-256(privBytes || pubBytes), which is
   not an ECDH key agreement — it is not symmetric. The sender derived
   SHA-256(privS||pubR) while the recipient derived SHA-256(privR||pubS),
   so the real recipient could never decrypt a memo addressed to them.
   The existing tests passed only because they used the same WIF for
   both encrypt and decrypt (self-to-self), hiding the asymmetry.

2. The wire format, key derivation, checksum, and plaintext framing were
   all custom and did not match the canonical Steem memo layout, so even
   with a correct key agreement the output could not interoperate with
   steem-js memo.encode / memo.decode.

Rewrite auth/memo to the canonical algorithm verified against steem-js
(next @ d3945f1):

  x  = ECDH(priv, pub)          # secp256k1 shared X coordinate
  S  = SHA512(x)                # 64 bytes
  ek = SHA512(uint64_le(nonce) || S)
  key = ek[0:32];  iv = ek[32:48]
  check = uint32_le(SHA256(ek)[0:4])
  plaintext = varint32(len) || utf8(message)   # bytebuffer writeVString
  ciphertext = AES-256-CBC(key, iv, PKCS7(plaintext))

  wire = from(33) || to(33) || nonce(uint64 LE) || check(uint32 LE) ||
         varint32(len(ct)) || ct

ECDH uses secp256k1.GenerateSharedSecret from decred dcrec/secp256k1/v4
(already a dependency); no type conversion is needed because
btcec.PrivateKey is a type alias for secp256k1.PrivateKey.

The public Encode/Decode signatures are unchanged (consumed by
steemgosdk); EncodeWithNonce is added for deterministic tests, and
UniqueNonce mirrors steem-js Aes.uniqueNonce (time<<32 | random32).

Tests now include a vector generated from steem-js asserting byte-for-
byte equality of Go EncodeWithNonce vs steem-js memo.encode, cross-key
encryption (sender encrypts, recipient decrypts — the path the bug
broke), steem-js-memo decoding, tamper/wrong-key rejection, and a
hand-decoded wire-layout check.

Refs: steem-audit/projects/steemutil/audit-reports/2026-07-code-scan.md (finding [高])
@ety001
ety001 merged commit 3efcd9c into master Jul 20, 2026
3 checks passed
@ety001
ety001 deleted the fix/memo-ecdh-steemjs-interop branch July 20, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant