Skip to content

Commit 57b7634

Browse files
Enhance Taysell security and usability
This commit implements several improvements to the Taysell system: 1. **Rigorous PayPal Webhook Validation:** * Implemented webhook signature verification in `templates/backend-in-a-box/index.js` using the PayPal SDK. * This ensures that incoming webhook notifications originate from PayPal, preventing attackers from spoofing requests to mark purchases as complete. * Added `PAYPAL_WEBHOOK_ID` to environment checks and updated the webhook handler to use `Webhooks.verifyAndGetWebhookEvent`. 2. **Protection Against Replay Attacks:** * Prevented purchase token reuse in `templates/backend-in-a-box/index.js`. * Added a `token_used_at` DATETIME column to the `purchases` table. * The `/get-patch` endpoint now checks this column. If a token has already been used, a 403 error is returned. * The `token_used_at` field is updated after a successful patch download. 3. **Increased PBKDF2 Iterations:** * Increased the PBKDF2 iteration count from 100,000 to 310,000 in `lib/taysell-utils.ts` and `templates/backend-in-a-box/index.js`. * This enhances resistance against brute-force attacks on encrypted data. 4. **Robust CLI Error Handling for Download Failures:** * Improved error handling in `lib/handlers/buy-handler.ts`. * If a patch download fails after a successful payment, the CLI now provides a detailed message. * This message includes seller contact information and a pre-formatted email (with patch ID and purchase token) for you to send for manual fulfillment, improving user experience and support.
1 parent c139c02 commit 57b7634

File tree

3 files changed

+154
-28
lines changed

3 files changed

+154
-28
lines changed

lib/handlers/buy-handler.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,27 @@ export async function handleBuyCommand(
172172
console.log(`Purchase and application of patch '${metadata.name}' completed.`);
173173
}
174174
} catch (error: any) {
175-
printUsageAndExit(`CRITICAL ERROR: Failed to retrieve patch. Details: ${error.message}`);
175+
// --- Enhanced Error Handling for Download Failure ---
176+
console.error("\n--- Payment Succeeded, Download Failed ---");
177+
console.error("An error occurred while attempting to download the patch after your payment was processed.");
178+
console.error("Please contact the seller for assistance.\n");
179+
180+
const sellerContact = taysellData.sellerInfo.contact;
181+
const patchName = taysellData.metadata.name;
182+
183+
console.error(`Seller Contact: ${sellerContact}\n`);
184+
console.error("Please provide them with the following information:\n");
185+
186+
const subject = `Download issue for Taylored purchase: "${patchName}"`;
187+
const body = `Hello,\n\nI recently purchased the patch "${patchName}" (Patch ID: ${patchId}) and my payment was successful.\nHowever, the download failed.\n\nMy Purchase Token is: ${purchaseToken}\n\nPlease assist me in obtaining the patch.\n\nThank you.`;
188+
189+
console.error("--- Pre-formatted Email ---");
190+
console.error(`To: ${sellerContact}`);
191+
console.error(`Subject: ${subject}\n`);
192+
console.error("Body:\n");
193+
console.error(body);
194+
console.error("\n---------------------------\n");
195+
console.error(`CRITICAL ERROR: Failed to retrieve patch. Details: ${error.message}`);
196+
process.exit(1);
176197
}
177198
}

lib/taysell-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const IV_LENGTH = 16; // For GCM, 12 is recommended, but 16 is also common. Node
99
const SALT_LENGTH = 16;
1010
const TAG_LENGTH = 16;
1111
const KEY_LENGTH = 32; // AES-256
12-
const PBKDF2_ITERATIONS = 100000; // OWASP recommended minimum
12+
const PBKDF2_ITERATIONS = 310000; // OWASP recommended minimum
1313

1414
/**
1515
* Encrypts text using AES-256-GCM.

templates/backend-in-a-box/index.js

Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const IV_LENGTH = 16;
1717
const SALT_LENGTH = 16;
1818
const TAG_LENGTH = 16;
1919
const KEY_LENGTH = 32; // AES-256
20-
const PBKDF2_ITERATIONS = 100000;
20+
const PBKDF2_ITERATIONS = 310000;
2121

2222
/**
2323
* Decrypts text encrypted with AES-256-GCM.
@@ -48,16 +48,34 @@ function decryptAES256GCM(encryptedText, passwordKey) {
4848
// Setup PayPal environment
4949
const clientId = process.env.PAYPAL_CLIENT_ID;
5050
const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
51+
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
5152

52-
if (!clientId || !clientSecret) {
53-
console.error("CRITICAL ERROR: PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables are not defined.");
54-
console.error("These variables are required for PayPal integration.");
53+
if (!clientId || !clientSecret || !webhookId) {
54+
console.error("CRITICAL ERROR: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_WEBHOOK_ID environment variables are not defined.");
55+
console.error("These variables are required for PayPal integration and webhook verification.");
5556
console.error("Without them, payment functionalities will not operate correctly.");
5657
}
5758

5859
const environment = new paypal.core.SandboxEnvironment(clientId, clientSecret);
5960
const client = new paypal.core.PayPalHttpClient(environment);
6061

62+
// Middleware to capture raw body
63+
app.use((req, res, next) => {
64+
if (req.originalUrl === '/paypal/webhook') { // Only for PayPal webhook route
65+
let data = '';
66+
req.setEncoding('utf8');
67+
req.on('data', chunk => {
68+
data += chunk;
69+
});
70+
req.on('end', () => {
71+
req.rawBody = data;
72+
next();
73+
});
74+
} else {
75+
next();
76+
}
77+
});
78+
6179
app.use(express.json());
6280
app.use(express.static('public'));
6381

@@ -84,7 +102,8 @@ const db = new sqlite3.Database(DB_PATH, (err) => {
84102
paypal_order_id TEXT UNIQUE,
85103
status TEXT NOT NULL,
86104
cli_session_id TEXT UNIQUE,
87-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
105+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
106+
token_used_at DATETIME DEFAULT NULL
88107
)`, (err) => {
89108
if (err) {
90109
console.error("Error creating 'purchases' table:", err.message);
@@ -159,27 +178,93 @@ app.get('/pay/:patchId', async (req, res) => {
159178
});
160179

161180
app.post('/paypal/webhook', async (req, res) => {
162-
const webhookEvent = req.body;
163-
if (webhookEvent.event_type === 'CHECKOUT.ORDER.APPROVED' || webhookEvent.event_type === 'CHECKOUT.ORDER.COMPLETED') {
164-
const orderID = webhookEvent.resource.id;
165-
const purchaseToken = crypto.randomBytes(16).toString('hex');
166-
db.run(`UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ?`,
167-
[purchaseToken, orderID],
168-
function(err) {
181+
const transmissionId = req.headers['paypal-transmission-id'];
182+
const transmissionTime = req.headers['paypal-transmission-time'];
183+
const transmissionSig = req.headers['paypal-transmission-sig'];
184+
const certUrl = req.headers['paypal-cert-url'];
185+
const authAlgo = req.headers['paypal-auth-algo'];
186+
const requestBody = req.rawBody || JSON.stringify(req.body); // PayPal SDK requires the raw body
187+
188+
try {
189+
// Note: Accessing webhooks directly from the paypal object as per documentation
190+
const { verified, event: verifiedEvent } = await paypal.webhooks.Webhooks.verifyAndGetWebhookEvent(
191+
transmissionId,
192+
transmissionTime,
193+
transmissionSig,
194+
certUrl,
195+
webhookId, // PAYPAL_WEBHOOK_ID from env
196+
requestBody,
197+
authAlgo
198+
);
199+
200+
if (!verified) {
201+
console.error("Webhook verification failed.");
202+
return res.status(403).send("Webhook verification failed.");
203+
}
204+
205+
if (verifiedEvent.event_type === 'CHECKOUT.ORDER.APPROVED') {
206+
const orderID = verifiedEvent.resource.id;
207+
// Check current status before updating
208+
db.get(`SELECT status FROM purchases WHERE paypal_order_id = ?`, [orderID], (err, row) => {
169209
if (err) {
170-
console.error(`Error updating purchase status for PayPal order ${orderID}:`, err.message);
210+
console.error(`Error fetching purchase status for PayPal order ${orderID}:`, err.message);
171211
return res.sendStatus(500);
172212
}
173-
if (this.changes === 0) {
174-
console.warn(`No purchase found or updated for PayPal order ${orderID}. The order may not exist or may have already been processed.`);
175-
return res.sendStatus(404);
213+
if (row && row.status === 'COMPLETED') {
214+
console.log(`Order ${orderID} is already marked as COMPLETED. No action taken.`);
215+
return res.sendStatus(200); // Or 204 No Content
176216
}
177-
console.log(`Purchase completed and token generated for PayPal order ${orderID}. Token: ${purchaseToken}`);
178-
res.sendStatus(200);
217+
218+
const purchaseToken = crypto.randomBytes(16).toString('hex');
219+
db.run(`UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ? AND status != 'COMPLETED'`,
220+
[purchaseToken, orderID],
221+
function(updateErr) {
222+
if (updateErr) {
223+
console.error(`Error updating purchase status for PayPal order ${orderID}:`, updateErr.message);
224+
return res.sendStatus(500);
225+
}
226+
if (this.changes === 0) {
227+
console.warn(`No purchase found or updated for PayPal order ${orderID}. The order may not exist or was already processed.`);
228+
// It's possible it was already completed, so sending 200 is okay if the goal is idempotency.
229+
// If it must be found and not completed, then 404 might be more appropriate.
230+
// Given the pre-check, this path might indicate a race condition or an issue.
231+
return res.sendStatus(200); // Adjusted from 404 as status check is now above
232+
}
233+
console.log(`Purchase COMPLETED (from APPROVED) and token generated for PayPal order ${orderID}. Token: ${purchaseToken}`);
234+
res.sendStatus(200);
235+
});
179236
});
180-
} else {
181-
console.log(`Unhandled webhook event received: ${webhookEvent.event_type}`);
182-
res.sendStatus(200);
237+
} else if (verifiedEvent.event_type === 'CHECKOUT.ORDER.COMPLETED') {
238+
const orderID = verifiedEvent.resource.id;
239+
const purchaseToken = crypto.randomBytes(16).toString('hex');
240+
// This path assumes CHECKOUT.ORDER.COMPLETED means we should finalize,
241+
// potentially overwriting if it was APPROVED but not yet tokenized by that path.
242+
// Or, if it's a direct COMPLETED event.
243+
db.run(`UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ?`,
244+
[purchaseToken, orderID], // Consider adding AND status != 'COMPLETED' if needed, though COMPLETED event should be final.
245+
function(err) {
246+
if (err) {
247+
console.error(`Error updating purchase status for PayPal order ${orderID} (on COMPLETED event):`, err.message);
248+
return res.sendStatus(500);
249+
}
250+
if (this.changes === 0) {
251+
// This could happen if the order was already processed by an APPROVED event or doesn't exist
252+
console.warn(`No purchase found or updated for PayPal order ${orderID} on COMPLETED event. May have been processed or not exist.`);
253+
return res.sendStatus(200); // Or 404 if it must exist
254+
}
255+
console.log(`Purchase COMPLETED (from COMPLETED event) and token generated for PayPal order ${orderID}. Token: ${purchaseToken}`);
256+
res.sendStatus(200);
257+
});
258+
} else {
259+
console.log(`Unhandled webhook event received: ${verifiedEvent.event_type}`);
260+
res.sendStatus(200);
261+
}
262+
} catch (err) {
263+
console.error("Error processing PayPal webhook:", err.message, err.stack);
264+
if (err.name === 'WEBHOOK_SIGNATURE_VERIFICATION_FAILED') {
265+
return res.status(403).send('Webhook signature verification failed.');
266+
}
267+
res.status(500).send('Error processing webhook');
183268
}
184269
});
185270

@@ -230,7 +315,7 @@ app.post('/get-patch', async (req, res) => {
230315
return res.status(500).json({ error: "Server configuration error." });
231316
}
232317

233-
db.get(`SELECT * FROM purchases WHERE patch_id = ? AND purchase_token = ? AND status = 'COMPLETED'`,
318+
db.get(`SELECT id, patch_id, purchase_token, status, token_used_at FROM purchases WHERE patch_id = ? AND purchase_token = ? AND status = 'COMPLETED'`,
234319
[patchId, purchaseToken],
235320
async (err, row) => {
236321
if (err) {
@@ -240,13 +325,33 @@ app.post('/get-patch', async (req, res) => {
240325
if (!row) {
241326
return res.status(401).json({ error: "Invalid or unauthorized purchase token." });
242327
}
328+
if (row.token_used_at) {
329+
console.warn(`Attempt to reuse token for purchase ID ${row.id} (Patch ${patchId}). Token already used at ${row.token_used_at}.`);
330+
return res.status(403).json({ error: "Purchase token has already been used." });
331+
}
243332

244333
const encryptedFilePath = path.join(__dirname, 'patches', `${patchId}.taylored.enc`);
245334
try {
246335
const encryptedContent = await fs.readFile(encryptedFilePath, 'utf-8');
247336
const decryptedContent = decryptAES256GCM(encryptedContent, encryptionKey);
248-
res.setHeader('Content-Type', 'text/plain');
249-
res.status(200).send(decryptedContent);
337+
338+
// Mark token as used before sending content
339+
db.run(`UPDATE purchases SET token_used_at = CURRENT_TIMESTAMP WHERE id = ?`, [row.id], function(updateErr) {
340+
if (updateErr) {
341+
console.error(`Failed to mark token as used for purchase ID ${row.id}:`, updateErr.message);
342+
// Decide if we should still send the content or return an error.
343+
// For now, let's assume if we can't mark it, we shouldn't send it to be safe.
344+
return res.status(500).json({ error: "Server error processing request." });
345+
}
346+
if (this.changes === 0) {
347+
// This case should ideally not be reached if the initial SELECT worked.
348+
console.error(`Failed to update token_used_at: No rows updated for purchase ID ${row.id}.`);
349+
return res.status(500).json({ error: "Failed to finalize purchase." });
350+
}
351+
console.log(`Token for purchase ID ${row.id} (Patch ${patchId}) marked as used.`);
352+
res.setHeader('Content-Type', 'text/plain');
353+
res.status(200).send(decryptedContent);
354+
});
250355
} catch (error) {
251356
if (error.code === 'ENOENT') {
252357
console.error(`Encrypted patch file not found at: ${encryptedFilePath}`, error);
@@ -266,8 +371,8 @@ app.get('/', (req, res) => {
266371
app.listen(PORT, () => {
267372
console.log(`Server listening on port ${PORT}`);
268373
console.log(`Server base URL: ${SERVER_BASE_URL}`);
269-
if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET) {
270-
console.warn("WARNING: PAYPAL_CLIENT_ID or PAYPAL_CLIENT_SECRET are not set. PayPal functionality will be unavailable.");
374+
if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_WEBHOOK_ID) {
375+
console.warn("WARNING: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET or PAYPAL_WEBHOOK_ID are not set. PayPal functionality will be unavailable or insecure.");
271376
}
272377
});
273378

0 commit comments

Comments
 (0)