diff --git a/Makefile b/Makefile index fa66201e5e1..a755d0ad160 100644 --- a/Makefile +++ b/Makefile @@ -160,3 +160,6 @@ codegen: gen-test-jwt: yarn gen-test-jwt + +boltcard: + bun run ./apps/boltcard/index.ts diff --git a/apps/boltcard/.gitattributes b/apps/boltcard/.gitattributes new file mode 100644 index 00000000000..81c05ed14e8 --- /dev/null +++ b/apps/boltcard/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb diff --git a/apps/boltcard/aes.spec.ts b/apps/boltcard/aes.spec.ts new file mode 100644 index 00000000000..3d500a7631a --- /dev/null +++ b/apps/boltcard/aes.spec.ts @@ -0,0 +1,24 @@ +const aesCmac = require("node-aes-cmac").aesCmac + +describe("aes", () => { + test(`testing signature`, async () => { + const c = Buffer.from("E19CCB1FED8892CE", "hex") + const aes_cmac_key = Buffer.from("b45775776cb224c75bcde7ca3704e933", "hex") + + const sv2 = Buffer.from([ + 60, 195, 0, 1, 0, 128, 4, 153, 108, 106, 146, 105, 128, 3, 0, 0, + ]) + + const options = { returnAsBuffer: true } + const cmac1 = aesCmac(aes_cmac_key, sv2, options) + const cmac2 = aesCmac(cmac1, new Buffer(""), options) + + const halfMac = Buffer.alloc(cmac2.length / 2) + for (let i = 1; i < cmac2.length; i += 2) { + halfMac[i >> 1] = cmac2[i] + } + + console.log({ c, aes_cmac_key, sv2, cmac1, cmac2 }) + expect(Buffer.compare(halfMac, c)).toEqual(0) + }) +}) diff --git a/apps/boltcard/aes.ts b/apps/boltcard/aes.ts new file mode 100644 index 00000000000..a821143e121 --- /dev/null +++ b/apps/boltcard/aes.ts @@ -0,0 +1,86 @@ +import crypto from "crypto" + +// import { AesCmac } from "aes-cmac" +const aesCmac = require("node-aes-cmac").aesCmac + +const aesjs = require("aes-js") + +class DecryptionError extends Error { + constructor(err: Error) { + super(err.message) + } +} + +class UnknownError extends Error {} + +export function aesDecrypt(key: Buffer, data: Buffer): Buffer | DecryptionError { + try { + const aesCtr = new aesjs.ModeOfOperation.cbc(key) + const decryptedBytes = aesCtr.decrypt(data) + return decryptedBytes + } catch (err) { + console.log(err) + if (err instanceof Error) return new DecryptionError(err) + return new UnknownError() + } +} + +export function checkSignature( + uid: Uint8Array, + ctr: Uint8Array, + k2CmacKey: Buffer, + cmac: Buffer, +): boolean { + const sv2 = Buffer.from([ + 0x3c, + 0xc3, + 0x00, + 0x01, + 0x00, + 0x80, + uid[0], + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + ctr[0], + ctr[1], + ctr[2], + ]) + + console.log({ sv2 }) + + let calculatedCmac + + try { + calculatedCmac = getSunMAC(k2CmacKey, sv2) + } catch (error) { + console.error(error) + throw new Error("issue with cMac") + } + + console.log({ calculatedCmac, cmac }) + + // Compare the result + return Buffer.compare(calculatedCmac, cmac) === 0 +} + +function getSunMAC(key: Buffer, sv2: Buffer): Buffer { + // const aesCmac = new AesCmac(key) + // const cmac = Buffer.from(await aesCmac.calculate(sv2)) + + const options = { returnAsBuffer: true } + const fullCmacComputed = aesCmac(key, sv2, options) + + // const cmac = aesCmac(key, sv2, options) + console.log({ fullCmacComputed }) + + const result = Buffer.alloc(fullCmacComputed.length / 2) + for (let i = 1; i < fullCmacComputed.length; i += 2) { + result[i >> 1] = fullCmacComputed[i] + } + + return result +} diff --git a/apps/boltcard/bats/e2e-test.bats b/apps/boltcard/bats/e2e-test.bats new file mode 100644 index 00000000000..3339729045c --- /dev/null +++ b/apps/boltcard/bats/e2e-test.bats @@ -0,0 +1,29 @@ + +@test "auth: create card" { + RESPONSE=$(curl -s "http://localhost:3000/createboltcard") + CALLBACK_URL=$(echo $RESPONSE | jq -r '.url') + + # Making the follow-up curl request + RESPONSE=$(curl -s "${CALLBACK_URL}") + echo "$RESPONSE" + [[ $(echo $RESPONSE | jq -r '.PROTOCOL_NAME') == "create_bolt_card_response" ]] || exit 1 +} + + +@test "auth: create payment and follow up" { + + P_VALUE="4E2E289D945A66BB13377A728884E867" + C_VALUE="E19CCB1FED8892CE" + + RESPONSE=$(curl -s "http://localhost:3000/ln?p=${P_VALUE}&c=${C_VALUE}") + echo "$RESPONSE" + + CALLBACK_URL=$(echo $RESPONSE | jq -r '.callback') + K1_VALUE=$(echo $RESPONSE | jq -r '.k1') + + echo "CALLBACK_URL: $CALLBACK_URL" + echo "K1_VALUE: $K1_VALUE" + + # Making the follow-up curl request + curl -s "${CALLBACK_URL}?k1=${K1_VALUE}" +} diff --git a/apps/boltcard/bun.lockb b/apps/boltcard/bun.lockb new file mode 100755 index 00000000000..1adae7090c8 Binary files /dev/null and b/apps/boltcard/bun.lockb differ diff --git a/apps/boltcard/callback.ts b/apps/boltcard/callback.ts new file mode 100644 index 00000000000..363f187fd74 --- /dev/null +++ b/apps/boltcard/callback.ts @@ -0,0 +1,31 @@ +import express from "express" + +import { boltcardRouter } from "./router" +import { fetchByK1 } from "./knex" + +boltcardRouter.get("/callback", async (req: express.Request, res: express.Response) => { + const k1 = req?.query?.k1 + const pr = req?.query?.pr + + console.log({ k1, pr }) + + if (!k1 || !pr) { + res.status(400).send({ status: "ERROR", reason: "missing k1 or pr" }) + return + } + + if (typeof k1 !== "string" || typeof pr !== "string") { + res.status(400).send({ status: "ERROR", reason: "invalid k1 or pr" }) + return + } + + const payment = await fetchByK1(k1) + console.log(payment) + // fetch user from k1 + // payInvoice(pr) + + res.json({ status: "OK" }) +}) + +const callback = "dummy" +export { callback } diff --git a/apps/boltcard/config.ts b/apps/boltcard/config.ts new file mode 100644 index 00000000000..03eb0d416dc --- /dev/null +++ b/apps/boltcard/config.ts @@ -0,0 +1,5 @@ +export const serverUrl = process.env.SERVER_URL ?? "http://localhost:3000" + +const AES_DECRYPT_KEY = process.env.AES_DECRYPT_KEY ?? "0c3b25d92b38ae443229dd59ad34b85d" + +export const aesDecryptKey = Buffer.from(AES_DECRYPT_KEY, "hex") diff --git a/apps/boltcard/decoder.spec.ts b/apps/boltcard/decoder.spec.ts new file mode 100644 index 00000000000..200e4965863 --- /dev/null +++ b/apps/boltcard/decoder.spec.ts @@ -0,0 +1,66 @@ +import { aesDecrypt, checkSignature } from "./aes" +import { decryptedPToUidCtr } from "./decoder" + +const aesjs = require("aes-js") + +const values = [ + { + p: aesjs.utils.hex.toBytes("4E2E289D945A66BB13377A728884E867"), + c: Buffer.from("E19CCB1FED8892CE", "hex"), + aes_decrypt_key: aesjs.utils.hex.toBytes("0c3b25d92b38ae443229dd59ad34b85d"), + aes_cmac_key: Buffer.from("b45775776cb224c75bcde7ca3704e933", "hex"), + decrypted_uid: "04996c6a926980", + decrypted_ctr: "030000", + decoded_ctr: 3, + }, + // { + // p: aesjs.utils.hex.toBytes("00F48C4F8E386DED06BCDC78FA92E2FE"), + // c: Buffer.from("66B4826EA4C155B4", "hex"), + // aes_decrypt_key: aesjs.utils.hex.toBytes("0c3b25d92b38ae443229dd59ad34b85d"), + // aes_cmac_key: Buffer.from("b45775776cb224c75bcde7ca3704e933", "hex"), + // decrypted_uid: "04996c6a926980", + // decrypted_ctr: "050000", + // decoded_ctr: 5, + // }, + // { + // p: aesjs.utils.hex.toBytes("0DBF3C59B59B0638D60B5842A997D4D1"), + // c: aesjs.utils.hex.toBytes("66B4826EA4C155B4"), + // aes_decrypt_key: aesjs.utils.hex.toBytes("0c3b25d92b38ae443229dd59ad34b85d"), + // aes_cmac_key: aesjs.utils.hex.toBytes("b45775776cb224c75bcde7ca3704e933"), + // decrypted_uid: "04996c6a926980", + // decrypted_ctr: "070000", + // decoded_ctr: 7, + // }, +] + +describe("crypto", () => { + values.forEach( + ({ + p, + c, + aes_decrypt_key, + aes_cmac_key, + decrypted_uid, + decrypted_ctr, + decoded_ctr, + }) => { + test(`testing ${aesjs.utils.hex.fromBytes(p)}`, async () => { + const decryptedP = aesDecrypt(aes_decrypt_key, p) + if (decryptedP instanceof Error) { + throw decryptedP + } + + const { uid, uidRaw, ctr, ctrRawInverseBytes } = decryptedPToUidCtr(decryptedP) + + expect(uid).toEqual(decrypted_uid) + expect(ctr).toEqual(decoded_ctr) + // expect(ctrRawInverseBytes).toEqual(decrypted_ctr) + + console.log({ uidRaw, ctrRawInverseBytes, aes_cmac_key, c }) + + const cmacVerified = checkSignature(uidRaw, ctrRawInverseBytes, aes_cmac_key, c) + expect(cmacVerified).toEqual(true) + }) + }, + ) +}) diff --git a/apps/boltcard/decoder.ts b/apps/boltcard/decoder.ts new file mode 100644 index 00000000000..19c666d222a --- /dev/null +++ b/apps/boltcard/decoder.ts @@ -0,0 +1,23 @@ +const aesjs = require("aes-js") + +export const decryptedPToUidCtr = ( + decryptedP: Uint8Array, +): { uid: string; uidRaw: Uint8Array; ctr: number; ctrRawInverseBytes: Uint8Array } => { + if (decryptedP[0] !== 0xc7) { + throw new Error("data not starting with 0xC7") + } + + const uidRaw = decryptedP.slice(1, 8) + const uid = aesjs.utils.hex.fromBytes(uidRaw) + + const ctrRawInverseBytes = decryptedP.slice(8, 11) + const ctr = + (ctrRawInverseBytes[2] << 16) | (ctrRawInverseBytes[1] << 8) | ctrRawInverseBytes[0] + + return { + uid, + uidRaw, + ctr, + ctrRawInverseBytes, + } +} diff --git a/apps/boltcard/docker-compose.yml b/apps/boltcard/docker-compose.yml new file mode 100644 index 00000000000..0b8b6263ee9 --- /dev/null +++ b/apps/boltcard/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3" +services: + boltcard-pg: + image: postgres:14.1 + ports: + - "5436:5432" + expose: + - "5432" + environment: + - POSTGRES_USER=dbuser + - POSTGRES_PASSWORD=secret + - POSTGRES_DB=default diff --git a/apps/boltcard/index.ts b/apps/boltcard/index.ts new file mode 100644 index 00000000000..5d975255f86 --- /dev/null +++ b/apps/boltcard/index.ts @@ -0,0 +1,33 @@ +// server.js + +import express from "express" +import bodyParser from "body-parser" + +import { boltcardRouter } from "./router" + +// loading router +import { lnurlw } from "./lnurlw" +import { callback } from "./callback" +import { createboltcard } from "./new" +import { createTable } from "./knex" + +lnurlw +callback +createboltcard + +await createTable() + +const app = express() +const PORT = 3000 + +// Middleware to parse POST requests +app.use(bodyParser.json()) +app.use(bodyParser.urlencoded({ extended: true })) + +// Use the router +app.use(boltcardRouter) + +// Start the server +app.listen(PORT, () => { + console.log(`Server started on http://localhost:${PORT}`) +}) diff --git a/apps/boltcard/knex.ts b/apps/boltcard/knex.ts new file mode 100644 index 00000000000..4c52ca70064 --- /dev/null +++ b/apps/boltcard/knex.ts @@ -0,0 +1,136 @@ +const options = { + client: "pg", + connection: "postgres://dbuser:secret@localhost:5436/default?sslmode=disable", +} + +const knex = require("knex")(options) + +export async function createTable() { + const hasPaymentTable = await knex.schema.hasTable("Payment") + + if (!hasPaymentTable) { + await knex.schema.createTable("Payment", (table) => { + table.string("k1", 255).notNullable().index() + + // index on card.id? + table.string("cardId").notNullable() + table.boolean("paid").notNullable().defaultTo(false) + table.timestamp("created_at").defaultTo(knex.fn.now()) + }) + + console.log("Payment table created successfully!") + } else { + console.log("Payment table already exists, skipping table creation.") + } + + const hasCardTable = await knex.schema.hasTable("Card") + + if (!hasCardTable) { + await knex.schema.createTable("Card", (table) => { + table.string("id").notNullable().index().unique() + + // if a card is resetted, the uid would stay the same + table.string("uid").notNullable().index() + table.uuid("accountId").notNullable().index() + table.boolean("enabled").notNullable().defaultTo(true) + + table.string("k0AuthKey").notNullable() + table.string("k2CmacKey").notNullable() + table.string("k3").notNullable() + table.string("k4").notNullable() + }) + console.log("Card table created successfully!") + } else { + console.log("Card table already exists, skipping table creation.") + } + + const hasCardInitTable = await knex.schema.hasTable("CardInit") + + if (!hasCardInitTable) { + await knex.schema.createTable("CardInit", (table) => { + table.string("oneTimeCode").notNullable().index().unique() + table.timestamp("created_at").defaultTo(knex.fn.now()) + table.string("status").defaultTo("init") // init, fetched, used + + table.string("k0AuthKey").notNullable() + table.string("k2CmacKey").notNullable() + table.string("k3").notNullable() + table.string("k4").notNullable() + }) + console.log("CardInit table created successfully!") + } else { + console.log("CardInit table already exists, skipping table creation.") + } +} + +export async function insertk1({ k1, cardId }: { k1: string; cardId: string }) { + await knex("Payment").insert({ k1, cardId }) + console.log("k1 inserted successfully!") +} + +export async function fetchByK1(k1: string) { + const result = await knex("Payment").where("k1", k1).first() + return result +} + +export async function fetchByUid(uid: string) { + const result = await knex("Card").where("uid", uid).first() + return result +} + +export async function fetchByCardId(cardId: string) { + const result = await knex("Card").where("id", cardId).first() + return result +} + +interface CardInput { + oneTimeCode: string + k0AuthKey: string + k2CmacKey: string + k3: string + k4: string +} + +export async function createCardInit(cardData: CardInput) { + try { + const { oneTimeCode, k0AuthKey, k2CmacKey, k3, k4 } = cardData + + const result = await knex("CardInit").insert({ + oneTimeCode, + k0AuthKey, + k2CmacKey, + k3, + k4, + }) + + return result + } catch (error) { + if (error instanceof Error) console.error(`Error creating card: ${error.message}`) + throw error + } +} + +export async function fetchByOneTimeCode(oneTimeCode: string) { + const result = await knex("CardInit").where("oneTimeCode", oneTimeCode).first() + + if (result) { + await knex("CardInit").where("oneTimeCode", oneTimeCode).update({ status: "fetched" }) + } + + return result +} + +export async function fetchAllWithStatusFetched() { + const results = await knex("CardInit").where("status", "fetched").select() + return results +} + +// async function main() { +// await createTable() +// await insertk1() +// } + +// main().catch((error) => { +// console.error("Error:", error) +// process.exit(1) +// }) diff --git a/apps/boltcard/lnurlw.ts b/apps/boltcard/lnurlw.ts new file mode 100644 index 00000000000..a9c64cf0d66 --- /dev/null +++ b/apps/boltcard/lnurlw.ts @@ -0,0 +1,105 @@ +import { randomBytes } from "crypto" + +import express from "express" + +import { aesDecrypt } from "./aes" +import { aesDecryptKey, serverUrl } from "./config" +import { decryptedPToUidCtr } from "./decoder" +import { fetchAllWithStatusFetched, fetchByUid, insertk1 } from "./knex" +import { boltcardRouter } from "./router" + +function generateSecureRandomString(length: number): string { + return randomBytes(Math.ceil(length / 2)) + .toString("hex") + .slice(0, length) +} + +const maybeSetupCard = async ({ + uid, + ctr, + ba_c, +}: { + uid: string + ctr: number + ba_c: Buffer +}) => { + const cardInits = await fetchAllWithStatusFetched() + + for (const cardInit of cardInits) { + console.log({ cardInit }, "cardInit") + const k2 = cardInit.k2CmacKey + + console.log(k2) + } +} + +boltcardRouter.get("/ln", async (req: express.Request, res: express.Response) => { + const raw_p = req?.query?.p + const raw_c = req?.query?.c + + if (!raw_p || !raw_c) { + res.status(400).send({ status: "ERROR", reason: "missing p or c" }) + return + } + + if (raw_p?.length !== 32 || raw_c?.length !== 16) { + res.status(400).send({ status: "ERROR", reason: "invalid p or c" }) + return + } + + if (typeof raw_p !== "string" || typeof raw_c !== "string") { + res.status(400).send({ status: "ERROR", reason: "invalid p or c" }) + return + } + + const ba_p = Buffer.from(raw_p, "hex") + const ba_c = Buffer.from(raw_c, "hex") + + console.log({ ba_p, ba_c }) + + const decryptedP = aesDecrypt(aesDecryptKey, ba_p) + if (decryptedP instanceof Error) { + res.status(400).send({ status: "ERROR", reason: "impossible to decrypt P" }) + return + } + + // TODO error management + const { uid, uidRaw, ctr } = decryptedPToUidCtr(decryptedP) + + console.log({ + uid, + uidRaw, + ctr, + }) + + const card = await fetchByUid(uid) + console.log({ card }, "card") + + if (!card) { + maybeSetupCard() + + res.status(400).send({ status: "ERROR", reason: "card not found" }) + return + } + + if (!card.enabled) { + res.status(400).send({ status: "ERROR", reason: "card disabled" }) + return + } + + const k1 = generateSecureRandomString(32) + + await insertk1({ k1, cardId: card.id }) + + res.json({ + tag: "withdrawRequest", + callback: serverUrl + "/callback", + k1, + defaultDescription: "payment for a blink card", + minWithdrawable: 1000, + maxWithdrawable: 100000000000, + }) +}) + +const lnurlw = "dummy" +export { lnurlw } diff --git a/apps/boltcard/new.ts b/apps/boltcard/new.ts new file mode 100644 index 00000000000..6e41fb6ac37 --- /dev/null +++ b/apps/boltcard/new.ts @@ -0,0 +1,118 @@ +import { randomBytes } from "crypto" + +import express from "express" + +import { boltcardRouter } from "./router" +import { aesDecryptKey, serverUrl } from "./config" +import { createCardInit, fetchByOneTimeCode } from "./knex" + +function randomHex(): string { + try { + const bytes: Buffer = randomBytes(16) + return bytes.toString("hex") + } catch (error) { + if (error instanceof Error) { + console.warn(error.message) + throw error + } + } +} + +// curl -s http://localhost:3000/createboltcard + +boltcardRouter.get( + "/createboltcard", + async (req: express.Request, res: express.Response) => { + const oneTimeCode = randomHex() + const k0AuthKey = randomHex() + const k2CmacKey = randomHex() + const k3 = randomHex() + const k4 = randomHex() + + const result = await createCardInit({ + oneTimeCode, + k0AuthKey, + k2CmacKey, + k3, + k4, + }) + + if (result instanceof Error) { + res.status(400).send({ status: "ERROR", reason: "impossible to create card" }) + return + } + + const url = `${serverUrl}/new?a=${oneTimeCode}` + res.json({ + status: "OK", + url, + }) + }, +) + +interface NewCardResponse { + PROTOCOL_NAME: string + PROTOCOL_VERSION: number + CARD_NAME: string + LNURLW_BASE: string + K0: string + K1: string + K2: string + K3: string + K4: string + UID_PRIVACY: string +} + +boltcardRouter.get("/new", async (req: express.Request, res: express.Response) => { + const url = req.url + console.debug("new_card url:", url) + + const oneTimeCode = req.query.a + + if (!oneTimeCode) { + console.debug("a not found") + res.status(400).send({ status: "ERROR", reason: "a missing" }) + return + } + + if (typeof oneTimeCode !== "string") { + console.debug("a is not a string") + res.status(400).send({ status: "ERROR", reason: "Bad Request" }) + return + } + + const cardInit = await fetchByOneTimeCode(oneTimeCode) + + if (!cardInit) { + console.debug("cardInit not found") + res.status(400).send({ status: "ERROR", reason: "cardInit not found" }) + return + } + + if (cardInit.status !== "init") { + console.debug("cardInit already fetched") + res.status(400).send({ status: "ERROR", reason: "code has been fetched" }) + return + } + + const lnurlwBase = `${serverUrl}/ln` + const k1DecryptKey = aesDecryptKey.toString("hex") + + const response: NewCardResponse = { + PROTOCOL_NAME: "create_bolt_card_response", + PROTOCOL_VERSION: 2, + CARD_NAME: "dummy", + LNURLW_BASE: lnurlwBase, + K0: cardInit.k0AuthKey, + K1: k1DecryptKey, + K2: cardInit.k2CmacKey, + K3: cardInit.k3, + K4: cardInit.k4, + UID_PRIVACY: "Y", + } + + res.status(200).json(response) +}) + +const createboltcard = "" +export { createboltcard } diff --git a/apps/boltcard/package.json b/apps/boltcard/package.json new file mode 100644 index 00000000000..7360c90c89d --- /dev/null +++ b/apps/boltcard/package.json @@ -0,0 +1,18 @@ +{ + "name": "boltcard", + "version": "0.0.1", + "main": "index.ts", + "license": "MIT", + "dependencies": { + "aes-cmac": "^2.0.0", + "aes-js": "^3.1.2", + "body-parser": "^1.20.2", + "express": "^4.18.2", + "knex": "^2.5.1", + "node-aes-cmac": "^0.1.1", + "pg": "^8.11.3" + }, + "devDependencies": { + "@types/pg": "^8.10.2" + } +} diff --git a/apps/boltcard/router.ts b/apps/boltcard/router.ts new file mode 100644 index 00000000000..5b173dcc642 --- /dev/null +++ b/apps/boltcard/router.ts @@ -0,0 +1,3 @@ +import express from "express" + +export const boltcardRouter = express.Router() diff --git a/tsconfig.json b/tsconfig.json index 7d9d80f900b..4b5be3bac74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,7 @@ } }, "compileOnSave": true, - "include": ["src/**/*", "test/**/*"], + "include": ["src/**/*", "test/**/*", "apps/**/*"], "watchOptions": { "synchronousWatchDirectory": true }