mirror of
https://github.com/whowechina/diva_pico.git
synced 2026-09-27 09:23:10 +03:00
1087 lines
36 KiB
HTML
1087 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>PS4 Key Packer for Diva Pico</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f4efe6;
|
|
--panel: rgba(255, 251, 245, 0.86);
|
|
--ink: #162521;
|
|
--muted: #53635d;
|
|
--accent: #0f766e;
|
|
--accent-strong: #0b525b;
|
|
--line: rgba(22, 37, 33, 0.14);
|
|
--warn: #b45309;
|
|
--error: #b91c1c;
|
|
--shadow: 0 24px 60px rgba(34, 44, 39, 0.12);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
|
|
radial-gradient(circle at 85% 15%, rgba(180, 83, 9, 0.16), transparent 22%),
|
|
linear-gradient(160deg, #f7f1e7 0%, #efe5d4 44%, #f9f6ef 100%);
|
|
padding: 32px 20px 48px;
|
|
}
|
|
|
|
main {
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.hero {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(1.5rem, 3vw, 2.4rem);
|
|
line-height: 1.1;
|
|
letter-spacing: -0.04em;
|
|
}
|
|
|
|
.subtitle {
|
|
margin: 0;
|
|
max-width: 760px;
|
|
color: var(--muted);
|
|
font-size: 1rem;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid var(--line);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-main {
|
|
margin-top: 12px;
|
|
min-width: 800px;
|
|
}
|
|
|
|
.serial-connect {
|
|
margin-bottom: 14px;
|
|
padding: 18px 24px;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: start;
|
|
gap: 14px;
|
|
}
|
|
|
|
.serial-connect h2 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.serial-connect p {
|
|
margin: 4px 0 0;
|
|
color: var(--muted);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.serial-heading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.serial-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.panel-top {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
align-items: stretch;
|
|
}
|
|
|
|
.section {
|
|
padding: 24px 24px 2px;
|
|
border-right: 1px solid var(--line);
|
|
}
|
|
|
|
.section:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
.button-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
margin-top: 0;
|
|
padding-top: 0;
|
|
}
|
|
|
|
.button-section {
|
|
padding: 2px 24px 24px;
|
|
border-right: 1px solid var(--line);
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.button-section:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
.button-section.left,
|
|
.button-section.right {
|
|
justify-content: center;
|
|
}
|
|
|
|
.section h2 {
|
|
margin: 0 0 8px;
|
|
font-size: 1rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.section p {
|
|
margin: 0 0 18px;
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.section .disclaimer {
|
|
margin: 8px 0 0 0;
|
|
font-size: 0.92em;
|
|
color: #a11a1a;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin: 16px 0 8px;
|
|
font-size: 0.9rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
input[type="text"],
|
|
textarea,
|
|
input[type="file"] {
|
|
width: 100%;
|
|
border: 1px solid rgba(15, 118, 110, 0.2);
|
|
border-radius: 16px;
|
|
padding: 14px 16px;
|
|
font: inherit;
|
|
color: var(--ink);
|
|
background: rgba(255, 255, 255, 0.88);
|
|
transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
|
}
|
|
|
|
input[type="text"]:focus,
|
|
textarea:focus,
|
|
input[type="file"]:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
textarea {
|
|
min-height: 280px;
|
|
resize: vertical;
|
|
}
|
|
|
|
button {
|
|
border: none;
|
|
border-radius: 999px;
|
|
padding: 8px 24px;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: 0 8px 24px rgba(15, 118, 110, 0.10);
|
|
transition: transform 180ms ease, box-shadow 180ms ease, opacity 180ms ease;
|
|
}
|
|
|
|
button:hover {
|
|
transform: translateY(-1px);
|
|
opacity: 0.96;
|
|
}
|
|
|
|
button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.45;
|
|
transform: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.primay {
|
|
color: var(--accent-strong);
|
|
background: linear-gradient(135deg, #e6f7f5 0%, #d2f1ed 100%);
|
|
border: 1px solid #b6e2de;
|
|
box-shadow: 0 8px 24px rgba(15, 118, 110, 0.10);
|
|
}
|
|
|
|
|
|
.status {
|
|
min-height: 24px;
|
|
margin-top: 16px;
|
|
font-weight: 700;
|
|
}
|
|
.status.output-status {
|
|
margin-top: 0;
|
|
margin-bottom: 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.status.serial-status {
|
|
margin-top: 0;
|
|
min-height: 20px;
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.status.warn {
|
|
color: var(--warn);
|
|
}
|
|
|
|
.status.error {
|
|
color: var(--error);
|
|
}
|
|
|
|
.status.ok {
|
|
color: var(--accent-strong);
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px 18px;
|
|
margin-top: 12px;
|
|
color: var(--muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.output {
|
|
min-height: 372px;
|
|
font-family: "Cascadia Code", "Consolas", monospace;
|
|
white-space: pre-wrap;
|
|
overflow: auto;
|
|
word-break: break-word;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.output-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
body {
|
|
padding: 20px 14px 32px;
|
|
}
|
|
|
|
.serial-connect {
|
|
grid-template-columns: 1fr;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.serial-actions {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.button-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.button-section {
|
|
border-right: none;
|
|
border-top: 1px solid var(--line);
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="hero">
|
|
<h1>PS4 Key Packer for Diva Pico</h1>
|
|
</section>
|
|
|
|
<section class="panel serial-connect">
|
|
<div class="serial-heading">
|
|
<h2>Connect to Diva Pico Command-line Port</h2>
|
|
<div id="serialStatus" class="status serial-status" aria-live="polite"></div>
|
|
</div>
|
|
<div class="serial-actions">
|
|
<button id="connectSerialButton" class="primay" type="button">Select Port</button>
|
|
<button id="disconnectSerialButton" class="primay" type="button" disabled>Disconnect</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel panel-main">
|
|
<div class="panel-top">
|
|
<div class="section">
|
|
<h2>Input</h2>
|
|
<p class="disclaimer">
|
|
The Diva Pico project will not provide any files or information related to acquiring these files. Do not request them through any communication channel (e.g., Email, Instant Messaging, GitHub Issues, Discord).
|
|
</p>
|
|
|
|
<label for="sigFile">Select a signature file</label>
|
|
<input id="sigFile" type="file" accept=".bin">
|
|
|
|
<label for="serialFile">Select a serial file</label>
|
|
<input id="serialFile" type="file" accept=".txt,text/plain">
|
|
|
|
<label for="pemFile">Select a PEM-format RSA key file</label>
|
|
<input id="pemFile" type="file" accept=".pem">
|
|
|
|
<div id="status" class="status" aria-live="polite"></div>
|
|
<div id="meta" class="meta"></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Output</h2>
|
|
<textarea id="output" class="output" readonly wrap="soft" placeholder="Packed text will appear here."></textarea>
|
|
<div id="outputStatus" class="status output-status" aria-live="polite"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="button-row">
|
|
<div class="button-section left">
|
|
<button id="packButton" class="primay" type="button">Pack the Files</button>
|
|
</div>
|
|
<div class="button-section right">
|
|
<div class="output-actions">
|
|
<button id="copyButton" class="primay" type="button">Copy to Clipboard</button>
|
|
<button id="saveButton" class="primay" type="button" disabled>Save To Diva Pico</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const PREFIX = "PS4KX";
|
|
const VERSION = 3;
|
|
const SERIAL_LENGTH = 16;
|
|
const SIG_LENGTH = 256;
|
|
const RSA_N_LENGTH = 256;
|
|
const RSA_E_LENGTH = 4;
|
|
const RSA_P_LENGTH = 128;
|
|
const RSA_Q_LENGTH = 128;
|
|
const RSA_D_LENGTH = 256;
|
|
const RSA_DP_LENGTH = 128;
|
|
const RSA_DQ_LENGTH = 128;
|
|
const RSA_QINV_LENGTH = 128;
|
|
const PAYLOAD_LENGTH = SERIAL_LENGTH + SIG_LENGTH + RSA_N_LENGTH + RSA_P_LENGTH + RSA_Q_LENGTH + RSA_E_LENGTH + RSA_D_LENGTH + RSA_DP_LENGTH + RSA_DQ_LENGTH + RSA_QINV_LENGTH;
|
|
|
|
const sigFileInput = document.getElementById("sigFile");
|
|
const serialFileInput = document.getElementById("serialFile");
|
|
const pemFileInput = document.getElementById("pemFile");
|
|
const output = document.getElementById("output");
|
|
const status = document.getElementById("status");
|
|
const meta = document.getElementById("meta");
|
|
const packButton = document.getElementById("packButton");
|
|
const copyButton = document.getElementById("copyButton");
|
|
const saveButton = document.getElementById("saveButton");
|
|
const connectSerialButton = document.getElementById("connectSerialButton");
|
|
const disconnectSerialButton = document.getElementById("disconnectSerialButton");
|
|
const serialStatus = document.getElementById("serialStatus");
|
|
const encoder = new TextEncoder();
|
|
const WELCOME_MARKER_1 = "<< Diva Pico Controller >>";
|
|
const WELCOME_MARKER_2 = "ps4key: Import or clear serialized PS4 key data.";
|
|
const SAVE_OK_MARKER = "PS4 key stored: Serial:";
|
|
const SERIAL_BUFFER_LIMIT = 200000;
|
|
const SERIAL_RETRY_MS = 2000;
|
|
|
|
let serialPort = null;
|
|
let serialReader = null;
|
|
let serialWriter = null;
|
|
let serialReadLoopRunning = false;
|
|
let serialTextBuffer = "";
|
|
let serialHandshakeOk = false;
|
|
let serialRetryTimer = null;
|
|
let serialProbeInFlight = false;
|
|
let manualDisconnectRequested = false;
|
|
let manualSelectionInProgress = false;
|
|
let serialAutoProbeToken = 0;
|
|
|
|
|
|
function setStatus(kind, message) {
|
|
status.className = `status ${kind}`;
|
|
status.textContent = message;
|
|
}
|
|
function setOutputStatus(kind, message) {
|
|
const outputStatus = document.getElementById("outputStatus");
|
|
outputStatus.className = `status output-status ${kind}`;
|
|
outputStatus.textContent = message;
|
|
}
|
|
|
|
function setMeta(parts) {
|
|
meta.innerHTML = "";
|
|
for (const part of parts) {
|
|
const span = document.createElement("span");
|
|
span.textContent = part;
|
|
meta.appendChild(span);
|
|
}
|
|
}
|
|
|
|
function setSerialStatus(kind, message) {
|
|
serialStatus.className = `status serial-status ${kind}`;
|
|
serialStatus.textContent = message;
|
|
}
|
|
|
|
function clearSerialStatus() {
|
|
setSerialStatus("", "");
|
|
}
|
|
|
|
function stopSerialRetryLoop() {
|
|
if (serialRetryTimer !== null) {
|
|
window.clearTimeout(serialRetryTimer);
|
|
serialRetryTimer = null;
|
|
}
|
|
}
|
|
|
|
function cancelAutoProbeRuns() {
|
|
serialAutoProbeToken += 1;
|
|
}
|
|
|
|
function formatPortLabel(port) {
|
|
const info = typeof port.getInfo === "function" ? port.getInfo() : {};
|
|
const hasVidPid = Number.isInteger(info.usbVendorId) || Number.isInteger(info.usbProductId);
|
|
if (!hasVidPid) {
|
|
return "selected";
|
|
}
|
|
const vid = Number.isInteger(info.usbVendorId) ? info.usbVendorId.toString(16).padStart(4, "0") : "----";
|
|
const pid = Number.isInteger(info.usbProductId) ? info.usbProductId.toString(16).padStart(4, "0") : "----";
|
|
return `USB ${vid}:${pid}`;
|
|
}
|
|
|
|
function getConnectedPortLabel() {
|
|
if (serialHandshakeOk) {
|
|
return "Diva Pico";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function showConnectedStatus() {
|
|
const portLabel = getConnectedPortLabel();
|
|
if (portLabel) {
|
|
setSerialStatus("ok", `Connected to ${portLabel}.`);
|
|
}
|
|
}
|
|
|
|
function scheduleAutoConnectRetry() {
|
|
if (!navigator.serial || serialPort || manualDisconnectRequested || manualSelectionInProgress || serialRetryTimer !== null) {
|
|
return;
|
|
}
|
|
serialRetryTimer = window.setTimeout(() => {
|
|
serialRetryTimer = null;
|
|
autoConnectKnownPorts();
|
|
}, SERIAL_RETRY_MS);
|
|
}
|
|
|
|
function hasOutput() {
|
|
return Boolean(output.value && output.value.trim());
|
|
}
|
|
|
|
function updateSerialButtons() {
|
|
const connected = Boolean(serialPort && serialHandshakeOk);
|
|
saveButton.disabled = !(connected && hasOutput());
|
|
copyButton.disabled = !hasOutput();
|
|
disconnectSerialButton.disabled = !connected;
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
|
|
function normalizePem(text) {
|
|
return text.replace(/\r\n?/g, "\n").trim();
|
|
}
|
|
|
|
function assertInputs(serial, pem, sigBytes) {
|
|
if (!sigBytes || sigBytes.length === 0) {
|
|
throw new Error("Please upload the signature file.");
|
|
}
|
|
if (sigBytes.length !== SIG_LENGTH) {
|
|
throw new Error(`signature file must be exactly ${SIG_LENGTH} bytes.`);
|
|
}
|
|
if (!/^\d{16}$/.test(serial)) {
|
|
throw new Error("serial file must be a 16-digit decimal string.");
|
|
}
|
|
if (!pem.includes("-----BEGIN RSA PRIVATE KEY-----") ||
|
|
!pem.includes("-----END RSA PRIVATE KEY-----")) {
|
|
throw new Error("key file must contain BEGIN/END RSA PRIVATE KEY markers.");
|
|
}
|
|
}
|
|
|
|
function base64ToBytes(text) {
|
|
const binary = atob(text.replace(/\s+/g, ""));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function parseLength(bytes, pos) {
|
|
if (pos >= bytes.length) {
|
|
throw new Error("Invalid DER: length overflow.");
|
|
}
|
|
const first = bytes[pos++];
|
|
if ((first & 0x80) === 0) {
|
|
return { len: first, pos };
|
|
}
|
|
const count = first & 0x7f;
|
|
if (count === 0 || count > 4 || pos + count > bytes.length) {
|
|
throw new Error("Invalid DER: unsupported length.");
|
|
}
|
|
let len = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
len = (len << 8) | bytes[pos++];
|
|
}
|
|
return { len, pos };
|
|
}
|
|
|
|
function readTlv(bytes, pos, expectedTag) {
|
|
if (pos >= bytes.length || bytes[pos] !== expectedTag) {
|
|
throw new Error(`Invalid DER: expected tag 0x${expectedTag.toString(16)}.`);
|
|
}
|
|
pos += 1;
|
|
const parsed = parseLength(bytes, pos);
|
|
const len = parsed.len;
|
|
pos = parsed.pos;
|
|
const end = pos + len;
|
|
if (end > bytes.length) {
|
|
throw new Error("Invalid DER: truncated value.");
|
|
}
|
|
return { value: bytes.subarray(pos, end), end };
|
|
}
|
|
|
|
function trimInteger(bytes) {
|
|
let i = 0;
|
|
while (i < bytes.length - 1 && bytes[i] === 0) {
|
|
i += 1;
|
|
}
|
|
return bytes.subarray(i);
|
|
}
|
|
|
|
function toFixedWidth(bytes, width, name) {
|
|
const value = trimInteger(bytes);
|
|
if (value.length > width) {
|
|
throw new Error(`${name} is too large (${value.length} > ${width}).`);
|
|
}
|
|
const out = new Uint8Array(width);
|
|
out.set(value, width - value.length);
|
|
return out;
|
|
}
|
|
|
|
function parseSmallInteger(bytes, name) {
|
|
const value = trimInteger(bytes);
|
|
if (value.length > 4) {
|
|
throw new Error(`${name} is too large to display.`);
|
|
}
|
|
let result = 0;
|
|
for (const byte of value) {
|
|
result = (result << 8) | byte;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parsePkcs1PrivateKey(pem) {
|
|
const base64 = pem
|
|
.replace(/-----BEGIN RSA PRIVATE KEY-----/g, "")
|
|
.replace(/-----END RSA PRIVATE KEY-----/g, "")
|
|
.replace(/\s+/g, "");
|
|
const der = base64ToBytes(base64);
|
|
|
|
const seq = readTlv(der, 0, 0x30);
|
|
let pos = 0;
|
|
const body = seq.value;
|
|
|
|
const version = readTlv(body, pos, 0x02);
|
|
pos = version.end;
|
|
const n = readTlv(body, pos, 0x02);
|
|
pos = n.end;
|
|
const e = readTlv(body, pos, 0x02);
|
|
pos = e.end;
|
|
const d = readTlv(body, pos, 0x02);
|
|
pos = d.end;
|
|
const p = readTlv(body, pos, 0x02);
|
|
pos = p.end;
|
|
const q = readTlv(body, pos, 0x02);
|
|
pos = q.end;
|
|
const dP = readTlv(body, pos, 0x02);
|
|
pos = dP.end;
|
|
const dQ = readTlv(body, pos, 0x02);
|
|
pos = dQ.end;
|
|
const qInv = readTlv(body, pos, 0x02);
|
|
|
|
return {
|
|
pemFormat: "PKCS#1 RSAPrivateKey",
|
|
pemVersion: parseSmallInteger(version.value, "pemVersion"),
|
|
crtSource: "Embedded in PEM fields (dP/dQ/qInv), not derived by the packer",
|
|
n: toFixedWidth(n.value, RSA_N_LENGTH, "rsaN"),
|
|
e: toFixedWidth(e.value, RSA_E_LENGTH, "rsaE"),
|
|
d: toFixedWidth(d.value, RSA_D_LENGTH, "rsaD"),
|
|
p: toFixedWidth(p.value, RSA_P_LENGTH, "rsaP"),
|
|
q: toFixedWidth(q.value, RSA_Q_LENGTH, "rsaQ"),
|
|
dP: toFixedWidth(dP.value, RSA_DP_LENGTH, "rsaDp"),
|
|
dQ: toFixedWidth(dQ.value, RSA_DQ_LENGTH, "rsaDq"),
|
|
qInv: toFixedWidth(qInv.value, RSA_QINV_LENGTH, "rsaQInv"),
|
|
};
|
|
}
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
const mask = -(crc & 1);
|
|
crc = (crc >>> 1) ^ (0xedb88320 & mask);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
let binary = "";
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function packPayload(serial, sigBytes, rsa) {
|
|
const serialBytes = encoder.encode(serial);
|
|
const payload = new Uint8Array(PAYLOAD_LENGTH);
|
|
payload.set(serialBytes, 0);
|
|
let offset = SERIAL_LENGTH;
|
|
payload.set(sigBytes, offset);
|
|
offset += sigBytes.length;
|
|
payload.set(rsa.n, offset);
|
|
offset += rsa.n.length;
|
|
payload.set(rsa.p, offset);
|
|
offset += rsa.p.length;
|
|
payload.set(rsa.q, offset);
|
|
offset += rsa.q.length;
|
|
payload.set(rsa.e, offset);
|
|
offset += rsa.e.length;
|
|
payload.set(rsa.d, offset);
|
|
offset += rsa.d.length;
|
|
payload.set(rsa.dP, offset);
|
|
offset += rsa.dP.length;
|
|
payload.set(rsa.dQ, offset);
|
|
offset += rsa.dQ.length;
|
|
payload.set(rsa.qInv, offset);
|
|
|
|
const header = new Uint8Array(10);
|
|
header.set([0x50, 0x53, 0x34, 0x4b], 0);
|
|
header[4] = VERSION;
|
|
header[5] = 0;
|
|
const view = new DataView(header.buffer);
|
|
view.setUint32(6, crc32(payload), true);
|
|
|
|
const packed = new Uint8Array(header.length + payload.length);
|
|
packed.set(header, 0);
|
|
packed.set(payload, header.length);
|
|
return PREFIX + bytesToBase64(packed);
|
|
}
|
|
|
|
|
|
async function readFileAsText(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return null;
|
|
return await file.text();
|
|
}
|
|
|
|
async function readFileAsBytes(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return null;
|
|
return new Uint8Array(await file.arrayBuffer());
|
|
}
|
|
|
|
async function doPack() {
|
|
try {
|
|
|
|
// Pre-file selection validation
|
|
if (!sigFileInput.files || !sigFileInput.files[0] ||
|
|
!serialFileInput.files || !serialFileInput.files[0] ||
|
|
!pemFileInput.files || !pemFileInput.files[0]
|
|
) {
|
|
setStatus("error", "Please select all required files.");
|
|
return;
|
|
}
|
|
|
|
const sigBytes = await readFileAsBytes(sigFileInput);
|
|
const serialText = await readFileAsText(serialFileInput);
|
|
const pemText = await readFileAsText(pemFileInput);
|
|
|
|
|
|
if (!sigBytes || sigBytes.length === 0) {
|
|
setStatus("error", "signature file read failed or empty.");
|
|
return;
|
|
}
|
|
if (!serialText || serialText.trim().length === 0) {
|
|
setStatus("error", "serial file read failed or empty.");
|
|
return;
|
|
}
|
|
if (!pemText || pemText.trim().length === 0) {
|
|
setStatus("error", "key file read failed or empty.");
|
|
return;
|
|
}
|
|
|
|
const serial = serialText ? serialText.replace(/\s+/g, "").trim() : "";
|
|
const pem = pemText ? normalizePem(pemText) : "";
|
|
|
|
assertInputs(serial, pem, sigBytes);
|
|
const rsa = parsePkcs1PrivateKey(pem);
|
|
const packed = packPayload(serial, sigBytes, rsa);
|
|
output.value = packed;
|
|
setStatus("ok", `Packed successfully, total ${packed.length} chars.`);
|
|
setOutputStatus("", "");
|
|
setMeta([]);
|
|
updateSerialButtons();
|
|
} catch (error) {
|
|
output.value = "";
|
|
setMeta([]);
|
|
setStatus("error", error instanceof Error ? error.message : String(error));
|
|
setOutputStatus("", "");
|
|
updateSerialButtons();
|
|
}
|
|
}
|
|
|
|
async function closeSerialConnection(message = "", kind = "", options = {}) {
|
|
const shouldRetry = options.shouldRetry === true;
|
|
serialHandshakeOk = false;
|
|
stopSerialRetryLoop();
|
|
updateSerialButtons();
|
|
|
|
if (serialReader) {
|
|
try {
|
|
await serialReader.cancel();
|
|
} catch (error) {
|
|
// Ignore reader cancel errors during disconnect.
|
|
}
|
|
try {
|
|
serialReader.releaseLock();
|
|
} catch (error) {
|
|
// Ignore lock release errors.
|
|
}
|
|
serialReader = null;
|
|
}
|
|
|
|
if (serialWriter) {
|
|
try {
|
|
serialWriter.releaseLock();
|
|
} catch (error) {
|
|
// Ignore lock release errors.
|
|
}
|
|
serialWriter = null;
|
|
}
|
|
|
|
if (serialPort) {
|
|
try {
|
|
await serialPort.close();
|
|
} catch (error) {
|
|
// Ignore close errors for already-disconnected devices.
|
|
}
|
|
}
|
|
|
|
serialPort = null;
|
|
serialReadLoopRunning = false;
|
|
serialTextBuffer = "";
|
|
if (message) {
|
|
setSerialStatus(kind, message);
|
|
} else {
|
|
clearSerialStatus();
|
|
}
|
|
updateSerialButtons();
|
|
if (shouldRetry) {
|
|
scheduleAutoConnectRetry();
|
|
}
|
|
}
|
|
|
|
async function serialReadLoop(port) {
|
|
if (!port.readable) {
|
|
return;
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
serialReader = port.readable.getReader();
|
|
serialReadLoopRunning = true;
|
|
|
|
try {
|
|
while (true) {
|
|
const readResult = await serialReader.read();
|
|
if (readResult.done) {
|
|
break;
|
|
}
|
|
if (readResult.value) {
|
|
serialTextBuffer += decoder.decode(readResult.value, { stream: true });
|
|
if (serialTextBuffer.length > SERIAL_BUFFER_LIMIT) {
|
|
serialTextBuffer = serialTextBuffer.slice(-Math.floor(SERIAL_BUFFER_LIMIT / 2));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Reader errors are handled by the disconnect cleanup below.
|
|
} finally {
|
|
serialReadLoopRunning = false;
|
|
if (serialReader) {
|
|
try {
|
|
serialReader.releaseLock();
|
|
} catch (error) {
|
|
// Ignore release failures during teardown.
|
|
}
|
|
serialReader = null;
|
|
}
|
|
|
|
if (serialPort === port) {
|
|
await closeSerialConnection("", "", { shouldRetry: !manualDisconnectRequested });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForText(marker, timeoutMs, startIndex = 0) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (serialTextBuffer.slice(startIndex).includes(marker)) {
|
|
return true;
|
|
}
|
|
if (!serialPort) {
|
|
return false;
|
|
}
|
|
await delay(80);
|
|
}
|
|
return serialTextBuffer.slice(startIndex).includes(marker);
|
|
}
|
|
|
|
function hasRequiredWelcome(text) {
|
|
return text.includes(WELCOME_MARKER_1) && text.includes(WELCOME_MARKER_2);
|
|
}
|
|
|
|
async function openAndVerifySerialPort(port, options = {}) {
|
|
const reportErrors = options.reportErrors !== false;
|
|
const showProgress = options.showProgress !== false;
|
|
const probeToken = options.probeToken;
|
|
|
|
if (probeToken !== undefined && probeToken !== serialAutoProbeToken) {
|
|
return false;
|
|
}
|
|
|
|
if (serialPort && serialPort !== port) {
|
|
await closeSerialConnection();
|
|
}
|
|
|
|
stopSerialRetryLoop();
|
|
serialTextBuffer = "";
|
|
if (showProgress) {
|
|
setSerialStatus("", `Connecting to ${formatPortLabel(port)} port...`);
|
|
} else {
|
|
clearSerialStatus();
|
|
}
|
|
|
|
await port.open({
|
|
baudRate: 115200,
|
|
dataBits: 8,
|
|
stopBits: 1,
|
|
parity: "none",
|
|
flowControl: "none",
|
|
bufferSize: 4096,
|
|
});
|
|
|
|
if (probeToken !== undefined && probeToken !== serialAutoProbeToken) {
|
|
await port.close();
|
|
return false;
|
|
}
|
|
|
|
serialPort = port;
|
|
if (!serialPort.writable) {
|
|
throw new Error("Selected port is not writable.");
|
|
}
|
|
serialWriter = serialPort.writable.getWriter();
|
|
serialReadLoop(port);
|
|
updateSerialButtons();
|
|
|
|
const handshakeOk = await waitForText(WELCOME_MARKER_2, 2400, 0) && hasRequiredWelcome(serialTextBuffer);
|
|
if (!handshakeOk) {
|
|
await closeSerialConnection(reportErrors ? "Connected port is not Diva Pico CLI Port." : "", reportErrors ? "error" : "");
|
|
return false;
|
|
}
|
|
|
|
serialHandshakeOk = true;
|
|
showConnectedStatus();
|
|
updateSerialButtons();
|
|
return true;
|
|
}
|
|
|
|
async function autoConnectKnownPorts() {
|
|
if (!navigator.serial) {
|
|
setSerialStatus("error", "Web Serial is not supported by this browser.");
|
|
connectSerialButton.disabled = true;
|
|
disconnectSerialButton.disabled = true;
|
|
saveButton.disabled = true;
|
|
copyButton.disabled = !hasOutput();
|
|
return;
|
|
}
|
|
|
|
if (serialPort || manualDisconnectRequested || serialProbeInFlight) {
|
|
return;
|
|
}
|
|
|
|
serialProbeInFlight = true;
|
|
const probeToken = ++serialAutoProbeToken;
|
|
|
|
try {
|
|
const ports = await navigator.serial.getPorts();
|
|
if (!ports.length) {
|
|
scheduleAutoConnectRetry();
|
|
return;
|
|
}
|
|
|
|
for (const port of ports) {
|
|
if (probeToken !== serialAutoProbeToken || serialPort || manualDisconnectRequested || manualSelectionInProgress) {
|
|
return;
|
|
}
|
|
try {
|
|
clearSerialStatus();
|
|
const ok = await openAndVerifySerialPort(port, {
|
|
reportErrors: false,
|
|
showProgress: false,
|
|
probeToken,
|
|
});
|
|
if (ok) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
await closeSerialConnection();
|
|
}
|
|
}
|
|
|
|
scheduleAutoConnectRetry();
|
|
} catch (error) {
|
|
scheduleAutoConnectRetry();
|
|
} finally {
|
|
serialProbeInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function chooseSerialPort() {
|
|
if (!navigator.serial) {
|
|
setSerialStatus("error", "Web Serial is not supported by this browser.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
manualDisconnectRequested = false;
|
|
manualSelectionInProgress = true;
|
|
stopSerialRetryLoop();
|
|
cancelAutoProbeRuns();
|
|
const port = await navigator.serial.requestPort();
|
|
await openAndVerifySerialPort(port, {
|
|
reportErrors: true,
|
|
showProgress: true,
|
|
});
|
|
} catch (error) {
|
|
if (error && error.name === "NotFoundError") {
|
|
scheduleAutoConnectRetry();
|
|
return;
|
|
}
|
|
await closeSerialConnection(error instanceof Error ? error.message : String(error), "error");
|
|
scheduleAutoConnectRetry();
|
|
} finally {
|
|
manualSelectionInProgress = false;
|
|
}
|
|
}
|
|
|
|
async function saveToDivaPico() {
|
|
if (!output.value) {
|
|
setOutputStatus("warn", "Please pack key data first.");
|
|
return;
|
|
}
|
|
if (!serialPort || !serialWriter || !serialHandshakeOk) {
|
|
setOutputStatus("warn", "Diva Pico CLI Port is not ready.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setOutputStatus("warn", "Saving output to Diva Pico...");
|
|
const startIndex = serialTextBuffer.length;
|
|
const command = `ps4key ${output.value}`;
|
|
await serialWriter.write(encoder.encode(`${command}\r\n`));
|
|
const ok = await waitForText(SAVE_OK_MARKER, 8000, startIndex);
|
|
if (!ok) {
|
|
throw new Error("Device did not confirm key storage.");
|
|
}
|
|
setOutputStatus("ok", "Output saved to Diva Pico.");
|
|
} catch (error) {
|
|
setOutputStatus("error", error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function copyOutput() {
|
|
if (!output.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(output.value);
|
|
output.select();
|
|
setOutputStatus("ok", "Output copied to clipboard.");
|
|
} catch (error) {
|
|
output.focus();
|
|
output.select();
|
|
}
|
|
}
|
|
|
|
if (navigator.serial) {
|
|
navigator.serial.addEventListener("disconnect", async (event) => {
|
|
const disconnectedPort = event.target || event.port;
|
|
if (serialPort && disconnectedPort === serialPort) {
|
|
await closeSerialConnection("", "", { shouldRetry: !manualDisconnectRequested });
|
|
}
|
|
});
|
|
|
|
navigator.serial.addEventListener("connect", () => {
|
|
if (!serialPort && !manualDisconnectRequested) {
|
|
autoConnectKnownPorts();
|
|
}
|
|
});
|
|
}
|
|
|
|
packButton.addEventListener("click", doPack);
|
|
copyButton.addEventListener("click", copyOutput);
|
|
saveButton.addEventListener("click", saveToDivaPico);
|
|
connectSerialButton.addEventListener("click", chooseSerialPort);
|
|
disconnectSerialButton.addEventListener("click", async () => {
|
|
manualDisconnectRequested = true;
|
|
await closeSerialConnection();
|
|
});
|
|
|
|
updateSerialButtons();
|
|
autoConnectKnownPorts();
|
|
</script>
|
|
</body>
|
|
</html> |