Repository navigation
Expand file tree
/
Copy pathscript.js
More file actions
614 lines (501 loc) · 22.5 KB
/
Copy pathscript.js
File metadata and controls
614 lines (501 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
const responseEl = document.getElementById('response');
async function deriveKey(password, salt) {
const hashResult = await argon2.hash({
pass: password,
salt,
time: 3,
mem: 65536, // 64 MiB
parallelism: 1,
hashLen: 32,
type: argon2.Argon2id
});
const hash = hashResult.hash ?? hashResult;
return crypto.subtle.importKey(
"raw",
hash,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"]
);
}
async function aesEncrypt(text, password) {
const enc = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(text)
);
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
return btoa(String.fromCharCode(...combined));
}
async function aesDecrypt(base64, password) {
const raw = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const salt = raw.slice(0, 16);
const iv = raw.slice(16, 28);
const data = raw.slice(28);
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
return new TextDecoder().decode(decrypted);
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result.split(',')[1]; // Remove data:...base64, prefix
resolve(result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function chunkString(str, size) {
const chunks = [];
for (let i = 0; i < str.length; i += size) {
chunks.push(str.slice(i, i + size));
}
return chunks;
}
async function generateQrGrid(text, pixelSize=350, cols=8) {
//console.log("Generating QR grid for text:\n", text);
const chunks = chunkString(text, 500); // 500 chars per QR
const rows = Math.ceil(chunks.length / cols);
const canvas = document.getElementById("qrCanvas");
const ctx = canvas.getContext("2d");
canvas.width = cols * pixelSize;
canvas.height = rows * pixelSize;
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const promises = chunks.map((chunk, idx) => {
const x = (idx % cols) * pixelSize;
const y = Math.floor(idx / cols) * pixelSize;
return new Promise((resolve) => {
QRCode.toCanvas(chunk, { width: pixelSize }, (err, canvas) => {
if (err) throw err;
ctx.drawImage(canvas, x, y, pixelSize, pixelSize);
resolve();
});
});
});
await Promise.all(promises);
return canvas;
}
async function readQrGrid(imageFile, cols=8) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const cellWidth = img.width / parseInt(cols);
const cellHeight = img.height / Math.ceil(img.height / cellWidth);
const chunks = [];
for (let y = 0; y < img.height; y += cellHeight) {
for (let x = 0; x < img.width; x += cellWidth) {
const chunkCanvas = document.createElement("canvas");
chunkCanvas.width = cellWidth;
chunkCanvas.height = cellHeight;
const chunkCtx = chunkCanvas.getContext("2d");
chunkCtx.drawImage(img, x, y, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
const imageData = chunkCtx.getImageData(0, 0, cellWidth, cellHeight);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) chunks.push(code.data);
}
}
resolve(chunks.join(""));
};
img.src = URL.createObjectURL(imageFile);
});
}
function updateStatus(content, type = 'info') {
const base = "mt-6 text-sm font-mono p-4 rounded-lg border transition-all duration-300 ";
const types = {
info: "bg-blue-50 text-blue-800 border-blue-300 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-700",
success: "bg-green-50 text-green-800 border-green-300 dark:bg-green-900 dark:text-green-100 dark:border-green-700",
error: "bg-red-50 text-red-800 border-red-300 dark:bg-red-900 dark:text-red-100 dark:border-red-700",
warning: "bg-yellow-50 text-yellow-800 border-yellow-300 dark:bg-yellow-900 dark:text-yellow-100 dark:border-yellow-700"
};
responseEl.className = base + (types[type] || types.info);
responseEl.innerHTML = content;
}
// Fetch the latest Feather IPA info from GitHub, using API instead of direct download so we can get the hash
async function getFeatherIpaInfo() {
const latestReleaseUrl = 'https://api.github.com/repos/khcrysalis/feather/releases/latest';
try {
// Step 1: Fetch the latest release data
const releaseResponse = await fetch(latestReleaseUrl, {
headers: { 'Accept': 'application/vnd.github+json' }
});
if (!releaseResponse.ok) {
throw new Error(`Failed to fetch release: ${releaseResponse.statusText}`);
}
const releaseData = await releaseResponse.json();
const assetsUrl = releaseData.assets_url;
// Step 2: Fetch the assets from the assets URL
const assetsResponse = await fetch(assetsUrl, {
headers: { 'Accept': 'application/vnd.github+json' }
});
if (!assetsResponse.ok) {
throw new Error(`Failed to fetch assets: ${assetsResponse.statusText}`);
}
const assets = await assetsResponse.json();
// Step 3: Find the "Feather.ipa" asset
const featherIpa = assets.find(asset => asset.name === "Feather.ipa");
if (!featherIpa) {
throw new Error("Feather.ipa not found in assets");
}
// Return the download URL and digest (if available)
return {
url: featherIpa.url,
digest: featherIpa.digest.replace('sha256:', '') || null // Digest might not always be present
};
} catch (error) {
console.error(error);
return null;
}
}
async function verifyDigest(arrayBuffer, expectedDigest) {
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
console.log(`Calculated SHA-256: ${hashHex}`);
console.log(`Expected SHA-256: ${expectedDigest}`);
return hashHex === expectedDigest.toLowerCase();
}
async function uploadRawText(text, filename = 'upload.png', time = 'PT1H', litterbox = true) {
const canvas = await generateQrGrid(text);
const fileBlob = await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to generate QR grid image.'));
return;
}
resolve(new File([blob], filename, { type: 'image/png' }));
}, "image/png", 1.0);
});
const formData = new FormData();
formData.append('source', fileBlob);
formData.append('type', 'file');
formData.append('action', 'upload');
formData.append('timestamp', Date.now().toString());
formData.append('expiration', time);
formData.append('auth_token', '9396b8a7d25f0ba402080bca0c33fdd4b8a39c21');
const uploadUrl = litterbox ? 'https://imgbb.com/json' : 'https://catbox.moe/user/api.php';
try {
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
const data = await response.json();
const url = data?.image?.url ?? "";
const id = url.replace("https://i.ibb.co/", "").split("/")[0]; // Extract the ID from the URL
return id;
} catch (error) {
throw new Error(`Upload error: ${error.message}`);
}
}
function generateRandomPassword(length = 48) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
}
async function getEncryptedUrl(taskId, certificateData, litterbox = true) {
showCopiedNotification('🔒 Encrypting and uploading data...');
// Block button clicks while processing
const container = document.getElementById('sendToPhoneContainer');
const buttons = container.querySelectorAll('button');
buttons.forEach(btn => btn.disabled = true);
let uploadedUrlId = null;
let password = null;
try {
password = generateRandomPassword();
var encryptedJsonData = certificateData
if (taskId && taskId.length > 0) {
encryptedJsonData = JSON.stringify({
taskId: await aesEncrypt(taskId, password),
certificateData: await aesEncrypt(certificateData, password)
});
}
const encryptedData = await aesEncrypt(encryptedJsonData, password);
uploadedUrlId = await uploadRawText(encryptedData, 'upload.png', 'PT1H', litterbox);
if (!uploadedUrlId) {
throw new Error('File upload failed.');
}
} catch (err) {
console.error('Upload error:', err);
alert('Failed to upload encrypted URL: ' + err.message);
// Renable buttons after error
buttons.forEach(btn => btn.disabled = false);
return { id: uploadedUrlId, password };
}
// Renable buttons after processing
buttons.forEach(btn => btn.disabled = false);
if (!litterbox) {
return uploadedUrlId, password;
}
// Build a shareable URL that points back to this page with the uploaded id and password in the hash.
const shareUrl = `${window.location.origin}${window.location.pathname}?id=${encodeURIComponent(uploadedUrlId)}#${encodeURIComponent(password)}`;
return {
url: shareUrl,
password,
id: uploadedUrlId
};
}
async function downloadAndDecrypt(id, password) {
try {
const response = await fetch(`https://i.ibb.co/${id}/upload.png`);
const imageBlob = await response.blob();
const imageFile = new File([imageBlob], 'upload.png', { type: 'image/png' });
const blobURL = URL.createObjectURL(imageBlob);
//console.log('Downloaded image file:', imageFile);
//console.log('Blob download:', blobURL);
const encryptedData = await readQrGrid(imageFile);
//console.log('Encrypted data from QR grid:', encryptedData);
const decryptedText = await aesDecrypt(encryptedData, password);
const data = JSON.parse(decryptedText);
const decryptedTaskId = await aesDecrypt(data.taskId, password);
const decryptedcertificateData = await aesDecrypt(data.certificateData, password);
if (!decryptedTaskId || !decryptedcertificateData) throw new Error('Failed to decrypt data.');
return { taskId: decryptedTaskId, certificateData: decryptedcertificateData };
} catch (err) {
console.error('Decryption error:', err);
throw new Error('Failed to decrypt data.');
}
}
function createFinalButtons(taskId, certificateData) {
const container = document.getElementById('sendToPhoneContainer');
container.innerHTML = ''; // Clear previous
let encryptedUrlSaved = '';
// 📲 Button to copy URL
const button = document.createElement('button');
button.textContent = '📲 Send to Phone';
button.className = 'mt-2 bg-purple-600 hover:bg-purple-700 text-white font-semibold py-2 px-4 rounded-lg shadow transition';
button.onclick = async () => {
try {
if (encryptedUrlSaved && encryptedUrlSaved.length > 3) {
await navigator.clipboard.writeText(encryptedUrlSaved);
showCopiedNotification();
} else {
const { url } = await getEncryptedUrl(taskId, certificateData);
await navigator.clipboard.writeText(url);
showCopiedNotification();
encryptedUrlSaved = url; // Save the URL for future copies
}
} catch (err) {
alert('Failed to copy URL: ' + err.message);
}
};
container.appendChild(button);
// 📷 QR Code Button
const qrButton = document.createElement('button');
qrButton.textContent = '📷 Generate QR Code';
qrButton.className = 'mt-2 ml-2 bg-purple-600 hover:bg-purple-700 text-white font-semibold py-2 px-4 rounded-lg shadow transition';
qrButton.onclick = async () => {
try {
let qrCodeUrl = encryptedUrlSaved;
if (!qrCodeUrl || (qrCodeUrl && qrCodeUrl.length <= 3)) {
const result = await getEncryptedUrl(taskId, certificateData);
qrCodeUrl = result.url;
encryptedUrlSaved = qrCodeUrl; // Save the URL for future copies
}
const qrContainer = document.getElementById('qrCodeContainer');
qrContainer.innerHTML = '';
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
QRCode.toCanvas(qrCodeUrl, {
width: 200,
color: {
dark: isDark ? '#FFFFFF' : '#000000', // White QR on dark bg, black on light bg
light: isDark ? '#1f2937' : '#FFFFFF' // Match Tailwind dark:bg-gray-800 or white
}
}, (error, canvas) => {
if (error) {
console.error(error);
qrContainer.innerHTML = '❌ Failed to generate QR code';
return;
}
const qrWrapper = document.createElement('div');
qrWrapper.className = 'rounded-lg p-1 border-2 border-gray-600 bg-white dark:border-gray-500 dark:bg-gray-800 inline-block';
qrWrapper.appendChild(canvas);
qrContainer.appendChild(qrWrapper);
});
} catch (err) {
console.error('Upload error:', err);
alert('Failed to upload encrypted URL: ' + err.message);
}
};
container.appendChild(qrButton);
const qrContainer = document.createElement('div');
qrContainer.id = 'qrCodeContainer';
qrContainer.className = 'mt-4 flex justify-center';
container.appendChild(qrContainer);
}
function showCopiedNotification(text = 'URL copied to clipboard!') {
const notif = document.createElement('div');
notif.textContent = `✅ ${text}`;
notif.className = `
fixed bottom-[-100px] left-1/2 transform -translate-x-1/2
bg-purple-600 text-white px-4 py-2 rounded-lg shadow-lg
transition-all duration-500 ease-in-out z-50
`;
document.body.appendChild(notif);
// Trigger slide in
requestAnimationFrame(() => {
notif.classList.remove('bottom-[-100px]');
notif.classList.add('bottom-6');
});
// Slide out after 3s
setTimeout(() => {
notif.classList.remove('bottom-6');
notif.classList.add('bottom-[-100px]');
setTimeout(() => notif.remove(), 500);
}, 3000);
}
async function pollStatus(taskId, certificateData, submitBtn = null, paramMode = false) {
updateStatus(`🛠 Signing in progress...<br><code>Task ID: ${taskId}</code>`, 'info');
const poll = async () => {
try {
const statusResponse = await fetch(`https://ipa.ipasign.cc/status/${taskId}`);
const statusData = await statusResponse.json();
if (statusData.status === 'SUCCESS' && statusData.download_url) {
const installUrl = `itms-services://?action=download-manifest&url=https://ipa.ipasign.cc/manifest/${statusData.task_id}.plist`;
updateStatus(`
✅ <strong>Signing complete!</strong><br><br>
<a href="${installUrl}" target="_blank"
class="inline-block mt-2 bg-green-600 hover:bg-green-700 text-white font-semibold py-2 px-4 rounded-lg shadow transition">
📱Install Feather
</a>
`, 'success');
// Only create final buttons if NOT in param mode
if (!paramMode) {
createFinalButtons(taskId, certificateData);
}
if (submitBtn) submitBtn.disabled = false;
} else if (statusData.status === 'FAILURE') {
updateStatus(`❌ Signing failed:<br><code>${statusData.message || 'Unknown error'}</code>`, 'error');
if (submitBtn) submitBtn.disabled = false;
} else {
updateStatus(`⏳ Status: ${statusData.status}...<br><code>Task ID: ${taskId}</code>`, 'info');
setTimeout(poll, 3000);
}
} catch (err) {
updateStatus('🚫 Error: ' + err.message, 'error');
console.error('Error in polling process:', err);
if (submitBtn) submitBtn.disabled = false;
}
};
poll();
}
// Helper to get query params
function getQueryParam(name) {
const value = new URLSearchParams(window.location.search).get(name);
return value && value.trim() !== '' ? value : null;
}
async function injectFeather(ipaArrayBuffer, p12File, mpFile, password) {
const zip = await JSZip.loadAsync(ipaArrayBuffer);
const certificateName = p12File.name.replace(/\.[^/.]+$/, ""); // Remove extension
const certificatePath = `Payload/Feather.app/signing-assets/1. ${certificateName}/`;
// Add the .p12, .mobileprovision and password to the IPA
zip.file(`${certificatePath}cert.p12`, p12File);
zip.file(`${certificatePath}cert.mobileprovision`, mpFile);
zip.file(`${certificatePath}cert.txt`, password.trim());
const modifiedIpaArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
// Create a Blob and download URL log in console for debugging
const modifiedIpaBlob = new Blob([modifiedIpaArrayBuffer], { type: 'application/octet-stream' });
// console.log('Modified IPA Blob:', modifiedIpaBlob);
console.log('Modified IPA Blob Download URL:', URL.createObjectURL(modifiedIpaBlob));
return modifiedIpaArrayBuffer;
}
window.addEventListener('DOMContentLoaded', () => {
(async () => {
const catboxId = getQueryParam('id');
const passwordParam = location.hash ? location.hash.substring(1) : null;
const form = document.getElementById('uploadForm');
if (catboxId && passwordParam) {
form.classList.add('hidden');
updateStatus('🛠 Decryption in progress...', 'info');
const password = decodeURIComponent(passwordParam);
try {
downloadAndDecrypt(catboxId, password).then(({ taskId: decryptedTaskId, certificateData: decryptedcertificateData }) => {
if (!decryptedTaskId || !decryptedcertificateData) {
throw new Error('Failed to decrypt data.');
} else {
pollStatus(decryptedTaskId, decryptedcertificateData, null, true);
}
});
} catch (err) {
console.error('Decryption error:', err);
updateStatus('🚫 Failed to decrypt data.', 'error');
}
}
})();
});
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const password = form.password.value;
const p12File = form.p12.files[0];
const mpFile = form.mp.files[0];
if (!password || !p12File || !mpFile) {
updateStatus('⚠️ Please fill in all fields.', 'warning');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
const container = document.getElementById('sendToPhoneContainer');
container.innerHTML = ''; // Clear previous QR code and buttons
updateStatus('🔄 Uploading and signing...', 'info');
try {
const info = await getFeatherIpaInfo();
//console.log(info);
const ipaResponse = await fetch('https://feather-cors.yodaluca.dev', {
headers: {
'Accept': 'application/octet-stream'
}
});
if (!ipaResponse.ok) throw new Error('Failed to download IPA');
const ipaArrayBuffer = await ipaResponse.arrayBuffer();
// Perform integrity check
if (info.digest) {
const isValid = await verifyDigest(ipaArrayBuffer, info.digest);
if (!isValid) throw new Error('IPA integrity check failed (digest mismatch)');
} else {
console.warn('No digest provided for IPA; skipping integrity check.');
alert('No digest provided for IPA; skipping integrity check.');
}
const injectedIpaArrayBuffer = await injectFeather(ipaArrayBuffer, p12File, mpFile, password);
const ipaBlob = new Blob([injectedIpaArrayBuffer], { type: 'application/octet-stream' });
const formData = new FormData();
formData.append('ipa', ipaBlob, 'Feather.ipa');
formData.append('p12', p12File);
formData.append('mp', mpFile);
formData.append('password', password);
const uploadResponse = await fetch('https://ipa.ipasign.cc/sign', {
method: 'POST',
body: formData
});
const result = await uploadResponse.json();
if (!result.task_id) throw new Error('No task_id returned from server');
const taskId = result.task_id;
const [p12Base64, mpBase64] = await Promise.all([
fileToBase64(p12File),
fileToBase64(mpFile),
]);
const passwordBase64 = btoa(password);
const certificateData = `feather://import-certificate?p12=${p12Base64}&mobileprovision=${mpBase64}&password=${passwordBase64}`;
pollStatus(taskId, certificateData, submitBtn);
} catch (err) {
updateStatus('🚫 Error: ' + err.message, 'error');
console.error('Error in signing process:', err);
submitBtn.disabled = false;
}
});