add feistel encryption

This commit is contained in:
nate 2026-04-11 04:36:14 +04:00
parent 4bb8e394ef
commit b2cc83538b
6 changed files with 62 additions and 8 deletions

View File

@ -10,6 +10,9 @@ COORDINATOR_URL=http://localhost:3000
MONITOR_TOKEN=changeme-use-a-random-secret MONITOR_TOKEN=changeme-use-a-random-secret
RUST_LOG=info RUST_LOG=info
# Feistel cipher for payment IDs (shared between pay + web)
FEISTEL_SECRET=changeme-use-a-random-secret
# Pay app — crypto payments # Pay app — crypto payments
FREEDOM_API=https://api-v1.freedom.st FREEDOM_API=https://api-v1.freedom.st
XPUB_BTC= XPUB_BTC=

View File

@ -1,5 +1,6 @@
import sql from "./db"; import sql from "./db";
import { COINS } from "../../shared/plans"; import { COINS } from "../../shared/plans";
import { encodeId } from "../../shared/feistel";
export async function generateReceipt(paymentId: number): Promise<string> { export async function generateReceipt(paymentId: number): Promise<string> {
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`; const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
@ -40,7 +41,7 @@ export async function generateReceipt(paymentId: number): Promise<string> {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingQL Receipt #${payment.id}</title> <title>PingQL Receipt #${encodeId(payment.id)}</title>
<link rel="icon" href="https://pingql.com/favicon.svg" type="image/svg+xml"> <link rel="icon" href="https://pingql.com/favicon.svg" type="image/svg+xml">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@ -71,7 +72,7 @@ export async function generateReceipt(paymentId: number): Promise<string> {
<div class="header"> <div class="header">
<div class="logo">Ping<span>QL</span></div> <div class="logo">Ping<span>QL</span></div>
<div class="meta">Receipt #${payment.id} · Issued ${paidDate}</div> <div class="meta">Receipt #${encodeId(payment.id)} · Issued ${paidDate}</div>
</div> </div>
<div class="section"> <div class="section">

View File

@ -6,6 +6,7 @@ import { PLAN_PRICING as PLANS, COINS, planTier } from "../../shared/plans";
import { generateReceipt } from "./receipt"; import { generateReceipt } from "./receipt";
import { watchPayment } from "./monitor"; import { watchPayment } from "./monitor";
import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth"; import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth";
import { encodeId, decodeId } from "../../shared/feistel";
function requireAuth(app: Elysia) { function requireAuth(app: Elysia) {
return app return app
@ -100,7 +101,7 @@ export const routes = new Elysia()
const uri = `${coinInfo.uri}:${address.replace(/^.*:/, '')}?amount=${amountCrypto}`; const uri = `${coinInfo.uri}:${address.replace(/^.*:/, '')}?amount=${amountCrypto}`;
return { return {
id: payment.id, id: encodeId(payment.id),
plan: payment.plan, plan: payment.plan,
months: payment.months, months: payment.months,
amount_usd: Number(payment.amount_usd), amount_usd: Number(payment.amount_usd),
@ -126,8 +127,9 @@ export const routes = new Elysia()
}) })
.get("/checkout/:id", async ({ accountId, params, set }) => { .get("/checkout/:id", async ({ accountId, params, set }) => {
const dbId = decodeId(params.id);
const [payment] = await sql` const [payment] = await sql`
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId} SELECT * FROM payments WHERE id = ${dbId} AND account_id = ${accountId}
`; `;
if (!payment) { set.status = 404; return { error: "Payment not found" }; } if (!payment) { set.status = 404; return { error: "Payment not found" }; }
@ -148,7 +150,7 @@ export const routes = new Elysia()
`; `;
return { return {
id: payment.id, id: encodeId(payment.id),
plan: payment.plan, plan: payment.plan,
months: payment.months, months: payment.months,
amount_usd: Number(payment.amount_usd), amount_usd: Number(payment.amount_usd),
@ -170,8 +172,9 @@ export const routes = new Elysia()
}) })
.get("/checkout/:id/receipt", async ({ accountId, params, set }) => { .get("/checkout/:id/receipt", async ({ accountId, params, set }) => {
const dbId = decodeId(params.id);
const [payment] = await sql` const [payment] = await sql`
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId} SELECT * FROM payments WHERE id = ${dbId} AND account_id = ${accountId}
`; `;
if (!payment) { set.status = 404; return { error: "Payment not found" }; } if (!payment) { set.status = 404; return { error: "Payment not found" }; }
if (payment.status !== "paid") { set.status = 400; return { error: "Receipt is only available for paid invoices" }; } if (payment.status !== "paid") { set.status = 400; return { error: "Receipt is only available for paid invoices" }; }

45
apps/shared/feistel.ts Normal file
View File

@ -0,0 +1,45 @@
const SECRET = process.env.FEISTEL_SECRET || "change-me";
const ROUNDS = 8;
const HALF = 20;
const MASK = (1 << HALF) - 1;
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function roundFn(right: number, round: number): number {
const h = new Bun.CryptoHasher("sha256").update(SECRET + ":" + round + ":" + right).digest();
return (h[0] << 12 | h[1] << 4 | h[2] >> 4) & MASK;
}
function toBase62(n: number): string {
let s = "";
for (let i = 0; i < 7; i++) { s = CHARS[n % 62] + s; n = Math.floor(n / 62); }
return s;
}
function fromBase62(s: string): number {
let n = 0;
for (const c of s) n = n * 62 + CHARS.indexOf(c);
return n;
}
export function encodeId(val: number): string {
let left = Math.floor(val / (MASK + 1)) & MASK;
let right = val & MASK;
for (let i = 0; i < ROUNDS; i++) {
const tmp = (left ^ roundFn(right, i)) & MASK;
left = right;
right = tmp;
}
return toBase62(left * (MASK + 1) + right);
}
export function decodeId(s: string): number {
const val = fromBase62(s);
let left = Math.floor(val / (MASK + 1)) & MASK;
let right = val & MASK;
for (let i = ROUNDS - 1; i >= 0; i--) {
const tmp = (right ^ roundFn(left, i)) & MASK;
right = left;
left = tmp;
}
return left * (MASK + 1) + right;
}

View File

@ -5,6 +5,7 @@ import { resolveKey } from "./auth";
import sql from "../db"; import sql from "../db";
import { sparklineFromPings, pickBestRegion } from "../utils/sparkline"; import { sparklineFromPings, pickBestRegion } from "../utils/sparkline";
import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS, planTier } from "../../../shared/plans"; import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS, planTier } from "../../../shared/plans";
import { encodeId } from "../../../shared/feistel";
async function hashFile(path: string): Promise<string> { async function hashFile(path: string): Promise<string> {
const bytes = await Bun.file(path).bytes(); const bytes = await Bun.file(path).bytes();
@ -311,6 +312,7 @@ export const dashboard = new Elysia()
const acc = accountRows[0]; const acc = accountRows[0];
const monitorCount = monitorCountRows[0].count; const monitorCount = monitorCountRows[0].count;
for (const inv of invoices) inv.public_id = encodeId(inv.id);
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices }); return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices });
}) })

View File

@ -194,10 +194,10 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-gray-500"><%= date %></span> <span class="text-xs text-gray-500"><%= date %></span>
<% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %> <% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
<a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a> <a href="/dashboard/checkout/<%= inv.public_id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
<% } else if (inv.status === 'paid') { %> <% } else if (inv.status === 'paid') { %>
<span class="text-xs text-green-500/70">Paid</span> <span class="text-xs text-green-500/70">Paid</span>
<a href="/dashboard/checkout/<%= inv.id %>/receipt" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">Receipt</a> <a href="/dashboard/checkout/<%= inv.public_id %>/receipt" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">Receipt</a>
<% } %> <% } %>
</div> </div>
</div> </div>