diff --git a/bin/cli.ts b/bin/cli.ts index b8d8b09..6fb5af2 100755 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -4,27 +4,25 @@ import { Option } from "commander"; import { commander, logger } from "substreams-sink"; import { action } from "../index.js"; import pkg from "../package.json" assert { type: "json" }; +import { keyPair } from "../src/auth/ed25519.js"; import { ping } from "../src/ping.js"; -import { keyPair } from "../src/signMessage.js"; export interface WebhookRunOptions extends commander.RunOptions { webhookUrl: string; secretKey: string; disablePing: boolean; + expiryTime: number; } +const expirationOption = new Option("--expiry-time ", "Time before a transmission becomes invalid (in seconds)").env("EXPIRY_TIME").default(40) + // Run Webhook Sink const program = commander.program(pkg); const command = commander.run(program, pkg); -command.addOption( - new Option("--webhook-url ", "Webhook URL to send POST").makeOptionMandatory().env("WEBHOOK_URL"), -); -command.addOption( - new Option("--secret-key ", "TweetNaCl Secret-key to sign POST data payload") - .makeOptionMandatory() - .env("SECRET_KEY"), -); +command.addOption(new Option("--webhook-url ", "Webhook URL to send POST").makeOptionMandatory().env("WEBHOOK_URL")); +command.addOption(new Option("--secret-key ", "TweetNaCl Secret-key to sign POST data payload").makeOptionMandatory().env("SECRET_KEY")); command.addOption(new Option("--disable-ping", "Disable ping on init").env("DISABLE_PING").default(false)); +command.addOption(expirationOption); command.action(action); program @@ -40,14 +38,11 @@ program .command("ping") .description("Ping Webhook URL") .addOption(new Option("--webhook-url ", "Webhook URL to send POST").makeOptionMandatory().env("WEBHOOK_URL")) - .addOption( - new Option("--secret-key ", "TweetNaCl Secret-key to sign POST data payload") - .makeOptionMandatory() - .env("SECRET_KEY"), - ) - .action(async (options) => { + .addOption(new Option("--secret-key ", "TweetNaCl Secret-key to sign POST data payload").makeOptionMandatory().env("SECRET_KEY")) + .addOption(expirationOption) + .action(async (options: WebhookRunOptions) => { logger.settings.type = "hidden"; - const response = await ping(options.webhookUrl, options.secretKey); + const response = await ping(options.webhookUrl, options.secretKey, options.expiryTime); if (response) console.log("✅ OK"); else console.log("⁉️ ERROR"); }); diff --git a/examples/bun/http.ts b/examples/bun/http.ts index 43f5e70..d4819a2 100644 --- a/examples/bun/http.ts +++ b/examples/bun/http.ts @@ -11,27 +11,26 @@ export default { port: PORT, async fetch(request) { // get headers and body from POST request - const timestamp = request.headers.get("x-signature-timestamp"); const signature = request.headers.get("x-signature-ed25519"); + const expiry = request.headers.get("x-signature-ed25519-expiry"); + const publicKey = request.headers.get("x-signature-ed25519-public-key"); + const body = await request.text(); - if (!timestamp) - return new Response("missing required timestamp in headers", { - status: 400, - }); - if (!signature) - return new Response("missing required signature in headers", { - status: 400, - }); + if (!signature) return new Response("missing required signature in headers", { status: 400 }); + if (!expiry) return new Response("missing required expiry in headers", { status: 400 }); + if (!publicKey) return new Response("missing required public key in headers", { status: 400 }); if (!body) return new Response("missing body", { status: 400 }); + if (publicKey !== PUBLIC_KEY) return new Response("unknown public key", { status: 401 }); + // validate signature using public key const isVerified = nacl.sign.detached.verify( - Buffer.from(timestamp + body), + Buffer.from(body), Buffer.from(signature, "hex"), - Buffer.from(PUBLIC_KEY, "hex"), + Buffer.from(publicKey, "hex") ); - console.log({ isVerified, timestamp, signature }); + console.log({ isVerified, signature }); console.log(body); if (!isVerified) { diff --git a/examples/deno/http.ts b/examples/deno/http.ts index 9e09d96..fa9c66f 100644 --- a/examples/deno/http.ts +++ b/examples/deno/http.ts @@ -1,36 +1,31 @@ -import nacl from "npm:tweetnacl"; import "https://deno.land/std@0.190.0/dotenv/load.ts"; import { encode } from "https://deno.land/std@0.190.0/encoding/hex.ts"; import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import nacl from "npm:tweetnacl"; const PORT = Deno.env.get("PORT"); const PUBLIC_KEY = Deno.env.get("PUBLIC_KEY"); const handler = async (request: Request) => { // get headers and body from POST request - const timestamp = request.headers.get("x-signature-timestamp"); const signature = request.headers.get("x-signature-ed25519"); + const expiry = request.headers.get("x-signature-ed25519-expiry"); + const publicKey = request.headers.get("x-signature-ed25519-public-key"); + const body = await request.text(); - if (!timestamp) - return new Response("missing required timestamp in headers", { - status: 400, - }); - if (!signature) - return new Response("missing required signature in headers", { - status: 400, - }); + if (!signature) return new Response("missing required signature in headers", { status: 400 }); + if (!expiry) return new Response("missing required expiry in headers", { status: 400 }); + if (!publicKey) return new Response("missing required public key in headers", { status: 400 }); if (!body) return new Response("missing body", { status: 400 }); + if (publicKey !== PUBLIC_KEY) return new Response("unknown public key", { status: 401 }); + // TO-DO: 🚨 FIX CODE BELOW 🚨 // validate signature using public key - const isVerified = nacl.sign.detached.verify( - new TextEncoder().encode(timestamp + body), - encode(signature), - encode(PUBLIC_KEY), - ); + const isVerified = nacl.sign.detached.verify(encode(body), encode(signature), encode(PUBLIC_KEY)); - console.dir({ timestamp, signature, isVerified }); + console.dir({ signature, isVerified }); console.dir(body); if (!isVerified) { return new Response("invalid request signature", { status: 401 }); diff --git a/examples/express/http.js b/examples/express/http.js index 250ced5..25221eb 100644 --- a/examples/express/http.js +++ b/examples/express/http.js @@ -10,22 +10,27 @@ app.use(express.text({ type: "application/json" })); app.use(async (req, res) => { // get headers and body from POST request - const timestamp = req.headers["x-signature-timestamp"]; - const signature = req.headers["x-signature-ed25519"]; - const body = req.body; + const signature = request.headers.get("x-signature-ed25519"); + const expiry = request.headers.get("x-signature-ed25519-expiry"); + const publicKey = request.headers.get("x-signature-ed25519-public-key"); - if (!timestamp) return res.send("missing required timestamp in headers").status(400); - if (!signature) return res.send("missing required signature in headers").status(400); - if (!body) return res.send("missing body").status(400); + const body = await request.text(); + + if (!signature) return new Response("missing required signature in headers", { status: 400 }); + if (!expiry) return new Response("missing required expiry in headers", { status: 400 }); + if (!publicKey) return new Response("missing required public key in headers", { status: 400 }); + if (!body) return new Response("missing body", { status: 400 }); + + if (publicKey !== PUBLIC_KEY) return new Response("unknown public key", { status: 401 }); // validate signature using public key const isVerified = nacl.sign.detached.verify( - Buffer.from(timestamp + body), + Buffer.from(body), Buffer.from(signature, "hex"), - Buffer.from(PUBLIC_KEY, "hex"), + Buffer.from(PUBLIC_KEY, "hex") ); - console.dir({ timestamp, signature, isVerified }); + console.dir({ signature, isVerified }); console.dir(body); if (!isVerified) { return res.send("invalid request signature").status(401); diff --git a/examples/http/ping-isVerified.http b/examples/http/ping-isVerified.http index e6fc799..bb9744e 100644 --- a/examples/http/ping-isVerified.http +++ b/examples/http/ping-isVerified.http @@ -1,6 +1,7 @@ POST http://localhost:3000 HTTP/1.1 content-type: application/json -x-signature-ed25519: ce31903c09e8f059df392aeccb5c5be2fd6fc317be17149eba60c6c7dc420c328490f316379a28b59bdf2506772ddbed35abf951ce7c84121279de27161e9b06 -x-signature-timestamp: 1686871414 +x-signature-ed25519: d26299022b13c25e4889191cdb6f4ab8fa30a524bca44b1742bedeeabb145ca99790ba09467f1365f870aee1236ec8682cdc3690eda4c8266cff512447d7270b +x-signature-ed25519-expiry: 2524626000000 +x-signature-ed25519-public-key: a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9 {"message":"PING"} \ No newline at end of file diff --git a/examples/http/ping-not-isVerified.http b/examples/http/ping-not-isVerified.http index 1e802aa..45fbc18 100644 --- a/examples/http/ping-not-isVerified.http +++ b/examples/http/ping-not-isVerified.http @@ -1,6 +1,8 @@ POST http://localhost:3000 HTTP/1.1 content-type: application/json x-signature-ed25519: 32c4f322a21ac05e7c9b7374bb702ccd834e56aeebe8320048440833f2e18358014a5790302fbe3ead8c956cdf2b05c9181b787c55c3e40dc6dbc3ab2cfe730f -x-signature-timestamp: 1686871505 +x-signature-ed25519-expiry: 2524626000000 +x-signature-ed25519-public-key: a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9 + {"message":"PING"} \ No newline at end of file diff --git a/examples/http/post-special-characters.http b/examples/http/post-special-characters.http deleted file mode 100644 index f69f487..0000000 --- a/examples/http/post-special-characters.http +++ /dev/null @@ -1,6 +0,0 @@ -POST http://localhost:3000 HTTP/1.1 -content-type: application/json -x-signature-ed25519: b5b0823e1c9de82349205561752661e94ff7b81cc37638d4bd41a22050d707363962e5d89660bbcdea61e0f1cc7e80f401f7831f66efc30e658b1a5de5a66f09 -x-signature-timestamp: 1689766626 - -{"cursor":"GDPtv-0FK42VSVFg8XmmYqWwLpc_DFhqUw7sKBBGhIH2pnaX2ZSkUmUnORjTk6Gi2ETuQ1v93t3NQiwvpsFUtYfule036SFuQXwkk4rnree5fPKnPQ1PcrhnW-jfZNzTUT7SZA_yeLAH4d_nbPeLY0swMMN0fWOwjDcCp9RReKJAuXE1lW6pJ87W2a2Sp9cS--JzQ-OplSiqUWYrKE4POcvWZPGauz5xY3Fl","clock":{"timestamp":"2023-07-19T11:37:06.500Z","number":256592171,"id":"0f4b492b4ba5338b5a581a020fbdd149f4fec40f778039fcf41e47d33542a648"},"manifest":{"substreamsEndpoint":"https://wax.firehose.eosnation.io:9001","moduleName":"map_transfers","moduleHash":"85d87a1e5aea3ff7e545fed84a1498b90b75ab27"},"data":{"items":[{"trxId":"954886d24c95910b1928046af92cf3f8e0d4c9555dbeb3cd454f962444059e6f","actionOrdinal":2,"contract":"alien.worlds","action":"transfer","symcode":"TLM","from":"m.federation","to":"qca4i.c.wam","quantity":"0.7005 TLM","memo":"ALIEN WORLDS - Mined Trilium","precision":4,"amount":"7005","value":0.7005},{"trxId":"3f7148c4cd7dd64d016b202eff12ca645f4aeabe35f0660b2a631303953fa7ba","actionOrdinal":2,"contract":"alien.worlds","action":"transfer","symcode":"TLM","from":"m.federation","to":"axvoq.c.wam","quantity":"10.3492 TLM","memo":"ALIEN WORLDS - Mined Trilium","precision":4,"amount":"103492","value":10.3492},{"trxId":"1ec285320f7b6ddf63678e75f503f63711c10a1a7a96fc019e147f14527f5489","actionOrdinal":4,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"extremegamer","to":"eosio.stake","quantity":"180.20000000 WAX","memo":"stake bandwidth","precision":8,"amount":"18020000000","value":180.2},{"trxId":"1ec285320f7b6ddf63678e75f503f63711c10a1a7a96fc019e147f14527f5489","actionOrdinal":7,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"extremegamer","to":"eosio.stake","quantity":"180.20000000 WAX","memo":"stake bandwidth","precision":8,"amount":"18020000000","value":180.2},{"trxId":"1ec285320f7b6ddf63678e75f503f63711c10a1a7a96fc019e147f14527f5489","actionOrdinal":10,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"extremegamer","to":"eosio.stake","quantity":"180.20000000 WAX","memo":"stake bandwidth","precision":8,"amount":"18020000000","value":180.2},{"trxId":"9711ab8f9f58a2fcb8a59973106ec392555faf2ce7710779b2dcaba1acc1c121","actionOrdinal":1,"contract":"farmerstoken","action":"transfer","symcode":"FWW","from":"2xiaoiiiiiii","to":"p1hj2.wam","quantity":"78.6903 FWW","memo":"(蛮龙自助托管1.0.567,微信:cqml17,telegram:https://t.me/+1DiBsv2_SCM4ODZl,Download:https://cdn.chosan.cn/static/game-asist/%E8%9B%AE%E9%BE%99%E8%87%AA%E5%8A%A9%E6%89%98%E7%AE%A1%20Setup%200.1.198.exe)批量cp3a3x9mk7:电锯#9596#本次产出WOOD共3","precision":4,"amount":"786903","value":78.6903},{"trxId":"92e91f257c7924daf09004bdea7faf4552a4f895c18f9a4e2c079f9f81cc21a4","actionOrdinal":1,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"marovinchi12","to":"alcordexmain","quantity":"96.71280000 WAX","memo":"120891.0000 LOOT@warsaken","precision":8,"amount":"9671280000","value":96.7128}]}} \ No newline at end of file diff --git a/examples/http/post.http b/examples/http/post.http index e8d1947..f1ff9cb 100644 --- a/examples/http/post.http +++ b/examples/http/post.http @@ -1,6 +1,7 @@ POST http://localhost:3000 HTTP/1.1 content-type: application/json -x-signature-ed25519: 6ec208fc250059fdb0fa543e01339ee3c6967da6fc7b6bf86dcd8217fa2e130ce2e17a5258fcf9bbe415de223d00eaee2f6949ef3a44594b42e7fb1a53481802 -x-signature-timestamp: 1696733583 +x-signature-ed25519: d26299022b13c25e4889191cdb6f4ab8fa30a524bca44b1742bedeeabb145ca99790ba09467f1365f870aee1236ec8682cdc3690eda4c8266cff512447d7270b +x-signature-ed25519-expiry: 2524626000000 +x-signature-ed25519-public-key: a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9 {"status":200,"cursor":"3ErAq5aeVa2E561uHfBu6qWwLpcyAlJrUAPhKxFLhtnz9HLH3JikBTQmaRqEkKz52RO4HQuk2I3EFi8p88JXtNa8kb4y6XdtRH5-loC_qLHscPOmawkSIu9kDrmJYdLfUzjSagj7c7tRsdLlPKaLY0BkY850fTOwizxW8IYFJqNAv3Mykm2ucMfVgf6fooJArbYgFuyinCzyBz16Kk4LO8TQZ_bN7jx1","session":{"traceId":"06eb726db08090e476eb2dbeff72f1bb","resolvedStartBlock":48458405},"clock":{"timestamp":"2023-10-08T02:53:03.000Z","number":48458410,"id":"3b54021525ec17d05946cfa86b92ab12787fb6f4fe25b59ac5380db39cd6ac73"},"manifest":{"substreamsEndpoint":"https://polygon.substreams.pinax.network:9000","moduleName":"map_block_stats","type":"subtivity.v1.BlockStats","moduleHash":"0a363b2a63aadb76a525208f1973531d3616fbae","chain":"polygon"},"data":{"transactionTraces":"36","traceCalls":"212","uaw":["d6b1cca00889daa9adc1d6e76b9a120086a13aab","675fe893a74815a35f867a12cbdd0637b7d7d6d4","42b07d313de7a38dc5cea48e326e545450cc4322","8ed47843e5030b6f06e6f204fcf2725378bb837a","9ced478d8d6fcaad332d9abf30415c8e48ac8079","21c3de23d98caddc406e3d31b25e807addf33633","2f59cde588b6d3661e8792632844f511d5e2da02","84a611b71254f5fccb1e5a619ad723cad8a03638","7ba865f70e32c9f46f67e33fe06139c8c31a2fad","18264397296fd982e432b4cd4942295c5bca50f8","258cfdaeee1b411bbb63a48cb030faed6720bb15","207cf8cdaec06610d7f9c92fec513e70520ce655","f746fb75a9c1d0f1c9799e434aea2aef90f7aa22","d3961bdbf7ad806b8e870a1cfbf7e54b5247020e","314c9a7a79ec28835ae68bcf5c0fd696141f85b4","2802fa14557b4f1afdf94af082b18c37d5786a2e","74eb675ed60a6f332e156c5a9ac376ee8d4d905d","5543ff441d3b0fcce59aa08eb52f15d27294af21","a1ab1c841898fe94900d00d9312ba954e4f81501","3dd12eb5ae0f1a106fb358c8b99830ab5690a7a2","51fafb35f31c434066267fc86ea24d8424115d2a","8709264ba5b56be8750193dad1a99f8b9d6ad3d6","c2b5f79a5768893b8087667b391c1381c502ab5c","85d8d0fc4e5a1f6dc823ee4baf486758a2fcb19c","7537cb7b7e8083ff8e68cb5c0ca18553ab54946f","d0a8cb58efcee1caee48f3c357074862ca8210dc"]}} \ No newline at end of file diff --git a/examples/node/http.ts b/examples/node/http.ts index 5329da3..e4c91d5 100644 --- a/examples/node/http.ts +++ b/examples/node/http.ts @@ -1,5 +1,5 @@ -import * as http from "node:http"; import "dotenv/config"; +import * as http from "node:http"; import nacl from "tweetnacl"; const PORT = process.env.PORT ?? 3000; @@ -24,22 +24,27 @@ server.on("request", async (req, res) => { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); // get headers and body from POST request - const timestamp = String(req.headers["x-signature-timestamp"]); const signature = String(req.headers["x-signature-ed25519"]); + const expiry = String(req.headers["x-signature-ed25519-expiry"]); + const publicKey = String(req.headers["x-signature-ed25519-public-key"]); + const body = await rawBody(req); - if (!timestamp) return res.writeHead(400).end("missing required timestamp in headers"); - if (!signature) return res.writeHead(400).end("missing required signature in headers"); - if (!body) return res.writeHead(400).end("missing body"); + if (!signature) return new Response("missing required signature in headers", { status: 400 }); + if (!expiry) return new Response("missing required expiry in headers", { status: 400 }); + if (!publicKey) return new Response("missing required public key in headers", { status: 400 }); + if (!body) return new Response("missing body", { status: 400 }); + + if (publicKey !== PUBLIC_KEY) return new Response("unknown public key", { status: 401 }); // validate signature using public key const isVerified = nacl.sign.detached.verify( - Buffer.from(timestamp + body), + Buffer.from(body), Buffer.from(signature, "hex"), - Buffer.from(PUBLIC_KEY, "hex"), + Buffer.from(PUBLIC_KEY, "hex") ); - console.dir({ timestamp, signature, isVerified }); + console.dir({ signature, isVerified }); console.dir(body); if (!isVerified) { return res.writeHead(401).end("invalid request signature"); diff --git a/index.ts b/index.ts index 2bdb353..3a9d2ef 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,6 @@ import PQueue from "p-queue"; import { http, logger, setup } from "substreams-sink"; import { postWebhook } from "./src/postWebhook.js"; -import { signMessage } from "./src/signMessage.js"; import type { SessionInit } from "@substreams/core/proto"; import type { WebhookRunOptions } from "./bin/cli.js"; @@ -9,6 +8,8 @@ import { banner } from "./src/banner.js"; import { toText } from "./src/http.js"; import { ping } from "./src/ping.js"; +export * from "./src/auth/index.js"; + export async function action(options: WebhookRunOptions) { // Block Emitter const { emitter, moduleHash } = await setup(options); @@ -18,7 +19,7 @@ export async function action(options: WebhookRunOptions) { // Ping URL to check if it's valid if (!options.disablePing) { - if (!(await ping(options.webhookUrl, options.secretKey))) { + if (!(await ping(options.webhookUrl, options.secretKey, options.expiryTime))) { logger.error("exiting from invalid PING response"); process.exit(1); } @@ -53,14 +54,12 @@ export async function action(options: WebhookRunOptions) { moduleHash, }, }; - // Sign body - const seconds = Number(clock.timestamp.seconds); + const body = JSON.stringify({ ...metadata, data }); - const signature = signMessage(seconds, body, options.secretKey); // Queue POST queue.add(async () => { - const response = await postWebhook(options.webhookUrl, body, signature, seconds); + const response = await postWebhook(options.webhookUrl, body, options.secretKey, options.expiryTime); logger.info("POST", response, metadata); }); }); diff --git a/package.json b/package.json index 8284d48..b21610b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.6.2", + "version": "0.7.0", "name": "substreams-sink-webhook", "description": "Substreams Sink Webhook", "type": "module", @@ -19,7 +19,7 @@ "test": "bun test", "prepublishOnly": "tsc", "build": "bun build --compile ./index.ts --outfile substreams-sink-webhook", - "bench": "bun ./src/*.bench.ts" + "bench": "bun ./src/**/*.bench.ts" }, "dependencies": { "p-queue": "latest", diff --git a/src/auth/cached.ts b/src/auth/cached.ts new file mode 100644 index 0000000..9590b29 --- /dev/null +++ b/src/auth/cached.ts @@ -0,0 +1,56 @@ +import { sign, verify } from "./ed25519.js"; + +// Keep in memory the latest generated signature for every secret key. +// We do not regenerate them if they are still valid. +const latestSignatures = new Map>(); + +export function cachedSign(...args: Parameters): ReturnType { + const [secretKey, durationInSecs] = args; + + // Do not recalculate a signature it the latest one expires in less than 40% of the expiryTime + let latestSignature = latestSignatures.get(secretKey); + if (!latestSignature || generatedSignatureIsExpired(latestSignature.expirationTime, durationInSecs)) { + latestSignature = sign(...args); + latestSignatures.set(secretKey, latestSignature); + } + + return latestSignature; +} + +function generatedSignatureIsExpired(expirationTime: number, signatureDurationInSecs: number) { + return expirationTime - new Date().getTime() <= 0.4 * signatureDurationInSecs * 1000; +} + +// Keep in memory which signatures are currently valid, and at what time they become invalid. +// This allows to skip the ed25519 validation process each time and only compare the expiration time. +const validSignatures = new Map(); + +export function cachedVerify(...args: Parameters): ReturnType { + const [signature, expiry] = args; + + // Quick return if the signature is already known + const cachedSignatureExpiry = validSignatures.get(signature); + if (cachedSignatureExpiry !== undefined) { + if (receivedSignatureIsExpired(cachedSignatureExpiry)) { + return new Error("signature is expired"); + } + + return true; + } + + // Cleanup expired values from cache + for (const [signature, expiry] of validSignatures) { + if (receivedSignatureIsExpired(expiry)) { + validSignatures.delete(signature); + } + } + + // If it is a new signature, process it normally + const result = verify(...args); + validSignatures.set(signature, expiry); + return result; +} + +function receivedSignatureIsExpired(expirationTime: number): boolean { + return new Date().getTime() >= expirationTime; +} diff --git a/src/auth/ed25519.bench.ts b/src/auth/ed25519.bench.ts new file mode 100644 index 0000000..ce4a036 --- /dev/null +++ b/src/auth/ed25519.bench.ts @@ -0,0 +1,20 @@ +import { bench, group, run } from "mitata"; +import { cachedSign, cachedVerify } from "./cached.js"; +import { sign, verify } from "./ed25519.js"; + +const secretKey = + "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; +const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; +const { signature, expirationTime } = sign(secretKey, 60); + +group("sign", () => { + bench("sign - cache disabled", () => sign(secretKey, 60)); + bench("sign - cache enabled", () => cachedSign(secretKey, 60)); +}); + +group("verify", () => { + bench("verify - cache disabled", () => verify(signature, expirationTime, publicKey)); + bench("verify - cache enabled", () => cachedVerify(signature, expirationTime, publicKey)); +}); + +await run({ avg: true, json: false, colors: true, min_max: true, collect: false, percentiles: false }); diff --git a/src/auth/ed25519.ts b/src/auth/ed25519.ts new file mode 100644 index 0000000..61a2929 --- /dev/null +++ b/src/auth/ed25519.ts @@ -0,0 +1,38 @@ +import nacl from "tweetnacl"; + +export function sign(secretKey: string, durationInSecs: number) { + const publicKey = secretKey.substring(nacl.sign.secretKeyLength); + const expirationTime = new Date().getTime() + durationInSecs * 1000; + + const payload = JSON.stringify({ exp: expirationTime, id: publicKey }); + const signedBuffer = nacl.sign.detached(Buffer.from(payload), Buffer.from(secretKey, "hex")); + + return { signature: Buffer.from(signedBuffer).toString("hex"), expirationTime, publicKey }; +} + +export function verify(signature: string, expiry: number, publicKey: string): Error | true { + if (new Date().getTime() >= expiry) { + return new Error("signature has expired"); + } + + const payload = JSON.stringify({ exp: expiry, id: publicKey }); + const isVerified = nacl.sign.detached.verify( + Buffer.from(payload), + Buffer.from(signature, "hex"), + Buffer.from(publicKey, "hex"), + ); + + if (!isVerified) { + return new Error("invalid signature"); + } + + return true; +} + +export function keyPair() { + const { secretKey, publicKey } = nacl.sign.keyPair(); + return { + secretKey: Buffer.from(secretKey).toString("hex"), + publicKey: Buffer.from(publicKey).toString("hex"), + }; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..413f94f --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./cached.js"; +export * from "./ed25519.js"; diff --git a/src/auth/sign.test.ts b/src/auth/sign.test.ts new file mode 100644 index 0000000..9930467 --- /dev/null +++ b/src/auth/sign.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, setSystemTime, spyOn, test } from "bun:test"; +import { cachedSign } from "./cached.js"; +import * as auth from "./ed25519.js"; + +const secretKey = + "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; + +beforeEach(() => { + setSystemTime(); +}); + +// This test will be invalid from January 1, 2050 +test("sign", () => { + const expectedSignature = + "d26299022b13c25e4889191cdb6f4ab8fa30a524bca44b1742bedeeabb145ca99790ba09467f1365f870aee1236ec8682cdc3690eda4c8266cff512447d7270b"; + + // Make the token expire in 2050 by modifying the current time + setSystemTime(new Date(2050, 0, 1)); + const { signature, expirationTime, publicKey } = auth.sign(secretKey, 0); + setSystemTime(); + + expect(signature).toBe(expectedSignature); + expect(auth.verify(signature, expirationTime, publicKey)).toBeTrue(); +}); + +test("sign cache", () => { + setSystemTime(new Date("2000-01-01T00:00:00.000Z")); + + const refreshSignatureSpy = spyOn(auth, "sign"); + + cachedSign(secretKey, 60); + expect(refreshSignatureSpy).toHaveBeenCalledTimes(1); + + // Requesting the signature in the first 60% of the time window will not regenerate it (0.6*60s = 36s) + setSystemTime(new Date("2000-01-01T00:00:35.000Z")); + cachedSign(secretKey, 60); + expect(refreshSignatureSpy).toHaveBeenCalledTimes(1); + + // Requesting the signature after the first 60% of the time window will regenerate it + setSystemTime(new Date("2000-01-01T00:00:36.000Z")); + cachedSign(secretKey, 60); + expect(refreshSignatureSpy).toHaveBeenCalledTimes(2); +}); diff --git a/src/auth/verify.test.ts b/src/auth/verify.test.ts new file mode 100644 index 0000000..767bf42 --- /dev/null +++ b/src/auth/verify.test.ts @@ -0,0 +1,58 @@ +import { expect, setSystemTime, spyOn, test } from "bun:test"; +import { cachedVerify } from "./cached.js"; +import * as auth from "./ed25519.js"; + +const secretKey = + "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; +const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; + +// This test will be invalid from January 1, 2050 +test("verify", () => { + const invalidPublicKey = "36657c7498f2ff2e9a520dcfbdad4e7c1e5354a75623165e28f6577a45a9eec3"; + + const expiry = new Date(2050, 0, 1); + const expired = new Date(2000, 0, 1); + + const tests = [ + { key: publicKey, expiry: expiry, expected: true }, + { key: publicKey, expiry: expired, expected: "signature has expired" }, + { key: invalidPublicKey, expiry: expiry, expected: "invalid signature" }, + { key: invalidPublicKey, expiry: expired, expected: "signature has expired" }, + ]; + + for (const test of tests) { + setSystemTime(test.expiry); + const { signature } = auth.sign(secretKey, 0); + + setSystemTime(); + if (typeof test.expected === "boolean") { + expect(auth.verify(signature, test.expiry.getTime(), test.key)).toBe(test.expected); + } else { + expect(() => auth.verify(signature, test.expiry.getTime(), test.key)).toThrow(test.expected); + } + } +}); + +test("verify cache", () => { + setSystemTime(new Date("2000-01-01T00:00:00.000Z")); + + const { signature, expirationTime, publicKey } = auth.sign(secretKey, 60); + const verifyMessageSpy = spyOn(auth, "verify"); + + expect(cachedVerify(signature, expirationTime, publicKey)).toBeTrue(); + expect(verifyMessageSpy).toHaveBeenCalledTimes(1); + + // This signature is already known, we do not need to revalidate it + expect(cachedVerify(signature, expirationTime, publicKey)).toBeTrue(); + expect(verifyMessageSpy).toHaveBeenCalledTimes(1); + + // This signature expires in 1s, but it is still valid. We do not need to revalide it. + setSystemTime(new Date("2000-01-01T00:00:59.000Z")); + expect(cachedVerify(signature, expirationTime, publicKey)).toBeTrue(); + expect(verifyMessageSpy).toHaveBeenCalledTimes(1); + + // This signature is expired, it should be removed from the cache. + setSystemTime(new Date("2000-01-01T00:01:00.000Z")); + expect(cachedVerify(signature, expirationTime, publicKey)).toBeInstanceOf(Error); + expect(verifyMessageSpy).toHaveBeenCalledTimes(1); +}); diff --git a/src/ping.ts b/src/ping.ts index 8b630b0..57aee4f 100644 --- a/src/ping.ts +++ b/src/ping.ts @@ -1,24 +1,20 @@ +import { keyPair } from "./auth/ed25519.js"; import { postWebhook } from "./postWebhook.js"; -import { keyPair, signMessage } from "./signMessage.js"; -export async function ping(url: string, secretKey: string) { +export async function ping(url: string, secretKey: string, expiryTime: number) { const body = JSON.stringify({ message: "PING" }); - const timestamp = Math.floor(Date.now().valueOf() / 1000); - const signature = signMessage(timestamp, body, secretKey); const invalidSecretKey = keyPair().secretKey; - const invalidSignature = signMessage(timestamp, body, invalidSecretKey); // send valid signature (must respond with 200) try { - await postWebhook(url, body, signature, timestamp, { maximumAttempts: 0 }); + await postWebhook(url, body, secretKey, expiryTime, { maximumAttempts: 0 }); } catch (_e) { return false; } + // send invalid signature (must NOT respond with 200) try { - await postWebhook(url, body, invalidSignature, timestamp, { - maximumAttempts: 0, - }); + await postWebhook(url, body, invalidSecretKey, expiryTime, { maximumAttempts: 0 }); return false; } catch (_e) { return true; diff --git a/src/postWebhook.ts b/src/postWebhook.ts index f179025..8db959d 100644 --- a/src/postWebhook.ts +++ b/src/postWebhook.ts @@ -1,4 +1,5 @@ import { logger } from "substreams-sink"; +import { cachedSign } from "./auth/cached.js"; function awaitSetTimeout(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -11,8 +12,8 @@ interface PostWebhookOptions { export async function postWebhook( url: string, body: string, - signature: string, - timestamp: number, + secretKey: string, + expiryTime: number, options: PostWebhookOptions = {}, ) { // Retry Policy @@ -27,40 +28,43 @@ export async function postWebhook( logger.error("invalid response", { url }); throw new Error("invalid response"); } + if (attempts > maximumAttempts) { logger.error("Maximum attempts exceeded", { url }); throw new Error("Maximum attempts exceeded"); } + if (attempts) { let milliseconds = initialInterval * backoffCoefficient ** attempts; if (milliseconds > maximumInterval) milliseconds = maximumInterval; logger.warn(`delay ${milliseconds}`, { attempts, url }); await awaitSetTimeout(milliseconds); } + try { + const { signature, expirationTime, publicKey } = cachedSign(secretKey, expiryTime); + const response = await fetch(url, { body, method: "POST", headers: { "content-type": "application/json", "x-signature-ed25519": signature, - "x-signature-timestamp": String(timestamp), + "x-signature-ed25519-expiry": expirationTime.toString(), + "x-signature-ed25519-public-key": publicKey, }, }); + const status = response.status; if (status !== 200) { attempts++; - logger.warn(`Unexpected status code ${status}`, { - url, - timestamp, - body, - }); + logger.warn(`Unexpected status code ${status}`, { url, body }); continue; } return { url, status }; } catch (e: any) { const error = e.cause; - logger.error("Unexpected error", { url, timestamp, body, error }); + logger.error("Unexpected error", { url, body, error }); attempts++; } } diff --git a/src/signMessage.bench.ts b/src/signMessage.bench.ts deleted file mode 100644 index f7ed0f7..0000000 --- a/src/signMessage.bench.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { bench, group, run } from "mitata"; -import { signMessage, verify } from "./signMessage.js"; - -const secretKey = - "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; -const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; -const body = JSON.stringify({ - cursor: - "gBCLb0z81lU8vbvZVzJkEaWwLpc_DFhqVQ3jLxVJgYH2pSTFicymUzd9bx2GlKH51RboGgmo19eZRX588ZED7YW8y7FhuSM6EHh4wNzo87Dne6KjPQlIIOhjC-iJMNncUT7SYgz9f7UI5N_nb6XZMxMyMZEuK2blizdZqoZXIfAVsHthkjz6cJ6Bga_A-YtEq-AnEuf1xn6lDzF1Lx4LOc_RNqGe6z4nN3Rq", - clock: { - timestamp: "2023-06-15T04:21:58.000Z", - number: 250665484, - id: "0ef0da0cf870f489833ac498da073acadf895d22f3dce68483aa43cac1d27b17", - }, - manifest: { - chain: "wax", - moduleName: "map_transfers", - moduleHash: "6aa24e6aa34db4a4faf55c69c6f612aeb06053c2", - }, - data: { - items: [ - { - trxId: "dd93c64db8ff91cfac74e731fd518548aa831be3d833e6a1fefeac69d2ddd138", - actionOrdinal: 2, - contract: "eosio.token", - action: "transfer", - symcode: "WAX", - from: "banxawallet1", - to: "atomicmarket", - quantity: "1340.00000000 WAX", - memo: "deposit", - precision: 8, - amount: "134000000000", - value: 1340, - }, - { - trxId: "dd93c64db8ff91cfac74e731fd518548aa831be3d833e6a1fefeac69d2ddd138", - actionOrdinal: 7, - contract: "eosio.token", - action: "transfer", - symcode: "WAX", - from: "atomicmarket", - to: "jft4m.c.wam", - quantity: "1206.00000000 WAX", - memo: "AtomicMarket Sale Payout - ID #129675349", - precision: 8, - amount: "120600000000", - value: 1206, - }, - ], - }, -}); -const timestamp = 1686802918; -const sig = - "d7b6b6b76ffb3ad58337d3082bcbeef39de1c2c4cd19f9d24955974358bb85e4bbdde31d055f60b1035750b4ca07e4e4c1398924106352577509b077ddd85802"; -const msg = Buffer.from(timestamp + body); - -group("signMessage", () => { - bench("signMessage", () => signMessage(timestamp, body, secretKey)); - bench("verify", () => verify(msg, sig, publicKey)); -}); - -await run(); diff --git a/src/signMessage.spec.ts b/src/signMessage.spec.ts deleted file mode 100644 index eec5d92..0000000 --- a/src/signMessage.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { test } from "bun:test"; -import assert from "node:assert"; -import { signMessage, verify } from "./signMessage.js"; - -test("signMessage", () => { - const secretKey = - "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; - const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; - const body = - '{"cursor":"gBCLb0z81lU8vbvZVzJkEaWwLpc_DFhqVQ3jLxVJgYH2pSTFicymUzd9bx2GlKH51RboGgmo19eZRX588ZED7YW8y7FhuSM6EHh4wNzo87Dne6KjPQlIIOhjC-iJMNncUT7SYgz9f7UI5N_nb6XZMxMyMZEuK2blizdZqoZXIfAVsHthkjz6cJ6Bga_A-YtEq-AnEuf1xn6lDzF1Lx4LOc_RNqGe6z4nN3Rq","clock":{"timestamp":"2023-06-15T04:21:58.000Z","number":250665484,"id":"0ef0da0cf870f489833ac498da073acadf895d22f3dce68483aa43cac1d27b17"},"manifest":{"chain":"wax","moduleName":"map_transfers","moduleHash":"6aa24e6aa34db4a4faf55c69c6f612aeb06053c2"},"data":{"items":[{"trxId":"dd93c64db8ff91cfac74e731fd518548aa831be3d833e6a1fefeac69d2ddd138","actionOrdinal":2,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"banxawallet1","to":"atomicmarket","quantity":"1340.00000000 WAX","memo":"deposit","precision":8,"amount":"134000000000","value":1340},{"trxId":"dd93c64db8ff91cfac74e731fd518548aa831be3d833e6a1fefeac69d2ddd138","actionOrdinal":7,"contract":"eosio.token","action":"transfer","symcode":"WAX","from":"atomicmarket","to":"jft4m.c.wam","quantity":"1206.00000000 WAX","memo":"AtomicMarket Sale Payout - ID #129675349","precision":8,"amount":"120600000000","value":1206}]}}'; - const timestamp = 1686802918; - const sig = signMessage(timestamp, body, secretKey); - assert.equal( - sig, - "a2e1437d2b32774418f46365d4dccb4509be5469ed24ba0d1707ce4ca76dd7fbe0b01597d9c91391fba5316e917d4dca3134a6c1f2c283d708c02cd33d5b080d", - ); - const msg = Buffer.from(timestamp + body); - assert.equal(verify(msg, sig, publicKey), true); -}); - -test("verify", () => { - const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; - const invalidPublicKey = "36657c7498f2ff2e9a520dcfbdad4e7c1e5354a75623165e28f6577a45a9eec3"; - const body = '{"message":"PING"}'; - const sig = - "d7b6b6b76ffb3ad58337d3082bcbeef39de1c2c4cd19f9d24955974358bb85e4bbdde31d055f60b1035750b4ca07e4e4c1398924106352577509b077ddd85802"; - const timestamp = 1686865337; - const msg = Buffer.from(timestamp + body); - - assert.equal(verify(msg, sig, publicKey), true); - assert.equal(verify(msg, sig, invalidPublicKey), false); -}); - -test("signMessage - special characters", () => { - const secretKey = - "3faae992336ea6599fbee55bb2605f1a1297c7288b860725cdfc8794413559dba3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; - const publicKey = "a3cb7366ee8ca77225b4d41772e270e4e831d171d1de71d91707c42e7ba82cc9"; - const body = - "(蛮龙自助托管1.0.567,微信:cqml17,telegram:https://t.me/+1DiBsv2_SCM4ODZl,Download:https://cdn.chosan.cn/static/game-asist/%E8%9B%AE%E9%BE%99%E8%87%AA%E5%8A%A9%E6%89%98%E7%AE%A1%20Setup%200.1.198.exe)批量cp3a3x9mk7:电锯#9596#本次产出WOOD共3"; - const timestamp = 1686865337; - const sig = signMessage(timestamp, body, secretKey); - assert.equal( - sig, - "58033ab867ff3be7eaba373a50ea8a21b716ef5b0cbab8663e48e82ad6694eec17281c132ccde4dbe61ff19e2263513e265a2da90de8748e7c70c818d489cc04", - ); - const msg = Buffer.from(timestamp + body); - assert.equal(verify(msg, sig, publicKey), true); -}); diff --git a/src/signMessage.ts b/src/signMessage.ts deleted file mode 100644 index 7b7153f..0000000 --- a/src/signMessage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import nacl from "tweetnacl"; - -export function signMessage(timestamp: number, body: string, secretKey: string) { - const msg = Buffer.from(timestamp + body); - const signed = nacl.sign.detached(msg, Buffer.from(secretKey, "hex")); - return Buffer.from(signed).toString("hex"); -} - -export function keyPair() { - const { secretKey, publicKey } = nacl.sign.keyPair(); - return { - secretKey: Buffer.from(secretKey).toString("hex"), - publicKey: Buffer.from(publicKey).toString("hex"), - }; -} - -export function fromSecretKey(secretKey: string) { - const from = nacl.sign.keyPair.fromSecretKey(Buffer.from(secretKey, "hex")); - return { - secretKey: Buffer.from(from.secretKey).toString("hex"), - publicKey: Buffer.from(from.publicKey).toString("hex"), - }; -} - -export function verify(msg: Buffer, sig: string, publicKey: string) { - return nacl.sign.detached.verify(msg, Buffer.from(sig, "hex"), Buffer.from(publicKey, "hex")); -}