From 8c1cfca208f07f57893231449b93cebf71d9adff Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:46:17 +0000 Subject: [PATCH] decrypt link data --- .../2026-03-20-smp-agent-web-spike.md | 2 +- smp-web/src/crypto/shortLink.ts | 30 ++++++++++++++++++- tests/SMPWebTests.hs | 30 ++++++++++++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md b/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md index 278d4f9f3..136d8fe52 100644 --- a/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md +++ b/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md @@ -66,7 +66,7 @@ Bottom-up, function-by-function. Each TypeScript function tested against its Has **Project location**: `simplexmq-2/smp-web/` **Tests**: `simplexmq-2/tests/SMPWebTests.hs` — reuses `callNode`/`jsOut`/`jsUint8` from XFTPWebTests (generalized, not copied) -**xftp-web**: npm dependency (encoding, crypto, padding imported directly) +**xftp-web**: npm dependency via `file:../xftp-web` (encoding, crypto, padding imported directly). Note: libsodium-wrappers-sumo is xftp-web's dependency; tests must init the same sodium instance that xftp-web's secretbox uses. If xftp-web is ever published to npm, libsodium should become a peerDependency. **File structure**: mirrors Haskell module hierarchy (see RFC section 2) **Pattern for each function**: diff --git a/smp-web/src/crypto/shortLink.ts b/smp-web/src/crypto/shortLink.ts index efdc2977f..e46ee4c1a 100644 --- a/smp-web/src/crypto/shortLink.ts +++ b/smp-web/src/crypto/shortLink.ts @@ -1,7 +1,9 @@ -// Short link key derivation. +// Short link key derivation and decryption. // Mirrors: Simplex.Messaging.Crypto.ShortLink import {hkdf} from "../crypto.js" +import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js" +import {Decoder, decodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js" const emptySalt = new Uint8Array(0) @@ -20,3 +22,29 @@ export function contactShortLinkKdf(linkKey: Uint8Array): {linkId: Uint8Array; s export function invShortLinkKdf(linkKey: Uint8Array): Uint8Array { return hkdf(emptySalt, linkKey, "SimpleXInvLink", 32) } + +// decryptLinkData (Crypto/ShortLink.hs:100-125) +// Decrypts both EncDataBytes blobs, strips signature prefix, returns raw data. +// Signature verification is skipped for spike. +export function decryptLinkData( + sbKey: Uint8Array, + encFixedData: Uint8Array, + encUserData: Uint8Array +): {fixedData: Uint8Array; userData: Uint8Array} { + return { + fixedData: decryptSigned(sbKey, encFixedData), + userData: decryptSigned(sbKey, encUserData), + } +} + +// EncDataBytes format: [nonce 24 bytes][ciphertext with prepended Poly1305 tag] +// After decrypt+unpad: [sig ByteString (1-byte len + 64 bytes)][data] +function decryptSigned(sbKey: Uint8Array, encData: Uint8Array): Uint8Array { + const nonce = encData.subarray(0, 24) + const ct = encData.subarray(24) + const plaintext = cbDecrypt(sbKey, nonce, ct) + // Skip signature: decodeBytes reads 1-byte length + that many bytes + const d = new Decoder(plaintext) + decodeBytes(d) // signature, discarded + return d.takeAll() +} diff --git a/tests/SMPWebTests.hs b/tests/SMPWebTests.hs index aab92e04e..b00828f1f 100644 --- a/tests/SMPWebTests.hs +++ b/tests/SMPWebTests.hs @@ -54,7 +54,11 @@ impAgentProto :: String impAgentProto = "import { connShortLinkStrP } from './dist/agent/protocol.js';" impCryptoShortLink :: String -impCryptoShortLink = "import { contactShortLinkKdf, invShortLinkKdf } from './dist/crypto/shortLink.js';" +impCryptoShortLink = "import { contactShortLinkKdf, invShortLinkKdf, decryptLinkData } from './dist/crypto/shortLink.js';" + +-- Init sodium from xftp-web's copy (same instance secretbox.ts uses) +impSodium :: String +impSodium = "import sodium from '@simplex-chat/xftp-web/node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js'; await sodium.ready;" jsStr :: B.ByteString -> String jsStr bs = "'" <> BC.unpack bs <> "'" @@ -176,6 +180,30 @@ smpWebTests = describe "SMP Web Client" $ do <> jsOut ("invShortLinkKdf(" <> jsUint8 (B.pack [50..81]) <> ")") tsResult `shouldBe` hsKey + describe "decryptLinkData" $ do + it "TypeScript decrypts Haskell-encrypted data" $ do + let sbKey = C.unsafeSbKey $ B.pack [1..32] + nonce = C.cbNonce $ B.pack [1..24] + -- Simulate encodeSign: smpEncode signature <> plaintext + fakeSig = B.pack [1..64] -- 64-byte "signature" + fixedPlain = "fixed-data-here" + userPlain = "user-data-here" + signedFixed = smpEncode fakeSig <> fixedPlain + signedUser = smpEncode fakeSig <> userPlain + case (,) <$> C.sbEncrypt sbKey nonce signedFixed 2008 + <*> C.sbEncrypt sbKey nonce signedUser 13784 of + Left e -> expectationFailure $ "encrypt failed: " <> show e + Right (ctFixed, ctUser) -> do + let encFixed = C.unCbNonce nonce <> ctFixed + encUser = C.unCbNonce nonce <> ctUser + tsResult <- callNode $ impSodium <> impCryptoShortLink + <> "const r = decryptLinkData(" + <> jsUint8 (C.unSbKey sbKey) <> "," + <> jsUint8 encFixed <> "," + <> jsUint8 encUser <> ");" + <> jsOut ("new Uint8Array([...r.fixedData, 0, ...r.userData])") + tsResult `shouldBe` (fixedPlain <> B.singleton 0 <> userPlain) + describe "agent/protocol" $ do describe "ConnShortLink" $ do it "parses simplex: contact link" $ do