feat: add client-side SSH Ed25519 key generator on order page
Adds a "Generate a new key" button to the checkout SSH key section that creates an Ed25519 keypair entirely in the browser using Web Crypto API. The public key auto-fills the form field, and the private key is presented for download/copy with a clear "save now" warning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
use WHMCS\User\User;
|
use WHMCS\User\User;
|
||||||
|
|
||||||
if (!defined("WHMCS")) {
|
if (!defined("WHMCS")) {
|
||||||
@@ -135,7 +136,10 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$systemUrl = Database::getSystemUrl();
|
||||||
|
|
||||||
return "
|
return "
|
||||||
|
<script src=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/js/keygen.js?v=0.0.20\"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var osTemplates = " . json_encode($dropdownOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
var osTemplates = " . json_encode($dropdownOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
||||||
@@ -197,6 +201,106 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
sshPasteContainer.appendChild(pasteLabel);
|
sshPasteContainer.appendChild(pasteLabel);
|
||||||
sshPasteContainer.appendChild(pasteArea);
|
sshPasteContainer.appendChild(pasteArea);
|
||||||
|
|
||||||
|
// Generate key button
|
||||||
|
var generateBtn = document.createElement('button');
|
||||||
|
generateBtn.type = 'button';
|
||||||
|
generateBtn.className = 'btn btn-outline-secondary btn-sm';
|
||||||
|
generateBtn.textContent = 'Generate a new key';
|
||||||
|
generateBtn.style.marginTop = '8px';
|
||||||
|
|
||||||
|
// Private key panel (hidden initially)
|
||||||
|
var privKeyPanel = document.createElement('div');
|
||||||
|
privKeyPanel.setAttribute('id', 'vf-privkey-panel');
|
||||||
|
privKeyPanel.style.display = 'none';
|
||||||
|
privKeyPanel.style.marginTop = '12px';
|
||||||
|
privKeyPanel.style.border = '2px solid #dc3545';
|
||||||
|
privKeyPanel.style.borderRadius = '6px';
|
||||||
|
privKeyPanel.style.padding = '12px';
|
||||||
|
|
||||||
|
var privKeyWarning = document.createElement('div');
|
||||||
|
privKeyWarning.style.color = '#dc3545';
|
||||||
|
privKeyWarning.style.fontWeight = 'bold';
|
||||||
|
privKeyWarning.style.marginBottom = '8px';
|
||||||
|
privKeyWarning.textContent = 'Private Key — Save This Now! It will not be shown again.';
|
||||||
|
|
||||||
|
var privKeyArea = document.createElement('textarea');
|
||||||
|
privKeyArea.className = 'form-control';
|
||||||
|
privKeyArea.setAttribute('rows', '6');
|
||||||
|
privKeyArea.setAttribute('readonly', 'readonly');
|
||||||
|
privKeyArea.style.fontFamily = 'monospace';
|
||||||
|
privKeyArea.style.fontSize = '12px';
|
||||||
|
privKeyArea.style.marginBottom = '8px';
|
||||||
|
|
||||||
|
var privKeyBtnRow = document.createElement('div');
|
||||||
|
privKeyBtnRow.style.display = 'flex';
|
||||||
|
privKeyBtnRow.style.gap = '8px';
|
||||||
|
privKeyBtnRow.style.alignItems = 'center';
|
||||||
|
privKeyBtnRow.style.flexWrap = 'wrap';
|
||||||
|
|
||||||
|
var downloadBtn = document.createElement('button');
|
||||||
|
downloadBtn.type = 'button';
|
||||||
|
downloadBtn.className = 'btn btn-primary btn-sm';
|
||||||
|
downloadBtn.textContent = 'Download';
|
||||||
|
|
||||||
|
var copyBtn = document.createElement('button');
|
||||||
|
copyBtn.type = 'button';
|
||||||
|
copyBtn.className = 'btn btn-default btn-secondary btn-sm';
|
||||||
|
copyBtn.textContent = 'Copy to Clipboard';
|
||||||
|
|
||||||
|
var pubKeyConfirm = document.createElement('span');
|
||||||
|
pubKeyConfirm.style.color = '#28a745';
|
||||||
|
pubKeyConfirm.style.fontWeight = 'bold';
|
||||||
|
pubKeyConfirm.textContent = 'Public key set automatically.';
|
||||||
|
|
||||||
|
privKeyBtnRow.appendChild(downloadBtn);
|
||||||
|
privKeyBtnRow.appendChild(copyBtn);
|
||||||
|
privKeyBtnRow.appendChild(pubKeyConfirm);
|
||||||
|
privKeyPanel.appendChild(privKeyWarning);
|
||||||
|
privKeyPanel.appendChild(privKeyArea);
|
||||||
|
privKeyPanel.appendChild(privKeyBtnRow);
|
||||||
|
|
||||||
|
downloadBtn.addEventListener('click', function() {
|
||||||
|
vfDownloadFile('id_ed25519', privKeyArea.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', function() {
|
||||||
|
navigator.clipboard.writeText(privKeyArea.value).then(function() {
|
||||||
|
copyBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(function() { copyBtn.textContent = 'Copy to Clipboard'; }, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error message for unsupported browsers
|
||||||
|
var genErrorMsg = document.createElement('div');
|
||||||
|
genErrorMsg.style.display = 'none';
|
||||||
|
genErrorMsg.style.marginTop = '8px';
|
||||||
|
genErrorMsg.style.color = '#dc3545';
|
||||||
|
genErrorMsg.textContent = 'Your browser does not support Ed25519 key generation. Please paste your public key manually.';
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', async function() {
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = 'Generating...';
|
||||||
|
try {
|
||||||
|
var keys = await vfGenerateSSHKey();
|
||||||
|
var sshSelect = document.getElementById('vf-ssh-select');
|
||||||
|
if (sshSelect) {
|
||||||
|
sshSelect.value = '__new__';
|
||||||
|
sshPasteContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
pasteArea.value = keys.publicKey;
|
||||||
|
sshInputField.value = keys.publicKey;
|
||||||
|
privKeyArea.value = keys.privateKey;
|
||||||
|
privKeyPanel.style.display = 'block';
|
||||||
|
genErrorMsg.style.display = 'none';
|
||||||
|
} catch (e) {
|
||||||
|
genErrorMsg.style.display = 'block';
|
||||||
|
privKeyPanel.style.display = 'none';
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = 'Generate a new key';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (sshKeys.length > 0) {
|
if (sshKeys.length > 0) {
|
||||||
var sshSelect = document.createElement('select');
|
var sshSelect = document.createElement('select');
|
||||||
sshSelect.className = 'form-control';
|
sshSelect.className = 'form-control';
|
||||||
@@ -233,11 +337,17 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
|
|
||||||
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
|
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
|
||||||
sshSelect.parentNode.insertBefore(sshPasteContainer, sshSelect.nextSibling);
|
sshSelect.parentNode.insertBefore(sshPasteContainer, sshSelect.nextSibling);
|
||||||
|
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
|
||||||
|
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
|
||||||
|
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
|
||||||
sshInputField.style.display = 'none';
|
sshInputField.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
// No existing keys — show the paste textarea directly
|
// No existing keys — show the paste textarea directly
|
||||||
sshPasteContainer.style.display = 'block';
|
sshPasteContainer.style.display = 'block';
|
||||||
sshInputField.parentNode.insertBefore(sshPasteContainer, sshInputField.nextSibling);
|
sshInputField.parentNode.insertBefore(sshPasteContainer, sshInputField.nextSibling);
|
||||||
|
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
|
||||||
|
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
|
||||||
|
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
|
||||||
sshInputField.style.display = 'none';
|
sshInputField.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
124
modules/servers/VirtFusionDirect/templates/js/keygen.js
Normal file
124
modules/servers/VirtFusionDirect/templates/js/keygen.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* VirtFusion SSH Ed25519 Key Generator
|
||||||
|
*
|
||||||
|
* Client-side Ed25519 keypair generation using Web Crypto API.
|
||||||
|
* Produces OpenSSH-format public and private keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function vfConcatArrays() {
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 0; i < arguments.length; i++) total += arguments[i].length;
|
||||||
|
var result = new Uint8Array(total);
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < arguments.length; i++) {
|
||||||
|
result.set(arguments[i], offset);
|
||||||
|
offset += arguments[i].length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfSshEncodeUint32(value) {
|
||||||
|
return new Uint8Array([
|
||||||
|
(value >>> 24) & 0xff,
|
||||||
|
(value >>> 16) & 0xff,
|
||||||
|
(value >>> 8) & 0xff,
|
||||||
|
value & 0xff
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfSshEncodeString(data) {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
return vfConcatArrays(vfSshEncodeUint32(data.length), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfArrayToBase64(uint8Array) {
|
||||||
|
var binary = '';
|
||||||
|
for (var i = 0; i < uint8Array.length; i++) {
|
||||||
|
binary += String.fromCharCode(uint8Array[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfEncodeSSHPublicKey(pubKeyBytes) {
|
||||||
|
var blob = vfConcatArrays(
|
||||||
|
vfSshEncodeString('ssh-ed25519'),
|
||||||
|
vfSshEncodeString(pubKeyBytes)
|
||||||
|
);
|
||||||
|
return 'ssh-ed25519 ' + vfArrayToBase64(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfEncodeSSHPrivateKey(seed, pubKeyBytes) {
|
||||||
|
var keyType = vfSshEncodeString('ssh-ed25519');
|
||||||
|
var pubBlob = vfConcatArrays(keyType, vfSshEncodeString(pubKeyBytes));
|
||||||
|
|
||||||
|
var checkInt = crypto.getRandomValues(new Uint8Array(4));
|
||||||
|
var privKey = vfConcatArrays(seed, pubKeyBytes); // 64 bytes: seed || pubkey
|
||||||
|
|
||||||
|
var privateSection = vfConcatArrays(
|
||||||
|
checkInt,
|
||||||
|
checkInt,
|
||||||
|
vfSshEncodeString('ssh-ed25519'),
|
||||||
|
vfSshEncodeString(pubKeyBytes),
|
||||||
|
vfSshEncodeString(privKey),
|
||||||
|
vfSshEncodeString('') // empty comment
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pad to 8-byte boundary with 1,2,3,4,5...
|
||||||
|
var padLen = 8 - (privateSection.length % 8);
|
||||||
|
if (padLen < 8) {
|
||||||
|
var padding = new Uint8Array(padLen);
|
||||||
|
for (var i = 0; i < padLen; i++) padding[i] = i + 1;
|
||||||
|
privateSection = vfConcatArrays(privateSection, padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
var authMagic = new TextEncoder().encode('openssh-key-v1\0');
|
||||||
|
var body = vfConcatArrays(
|
||||||
|
authMagic,
|
||||||
|
vfSshEncodeString('none'), // cipher
|
||||||
|
vfSshEncodeString('none'), // kdf
|
||||||
|
vfSshEncodeString(''), // kdf options
|
||||||
|
vfSshEncodeUint32(1), // number of keys
|
||||||
|
vfSshEncodeString(pubBlob),
|
||||||
|
vfSshEncodeString(privateSection)
|
||||||
|
);
|
||||||
|
|
||||||
|
var b64 = vfArrayToBase64(body);
|
||||||
|
var lines = ['-----BEGIN OPENSSH PRIVATE KEY-----'];
|
||||||
|
for (var i = 0; i < b64.length; i += 70) {
|
||||||
|
lines.push(b64.substring(i, i + 70));
|
||||||
|
}
|
||||||
|
lines.push('-----END OPENSSH PRIVATE KEY-----');
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vfGenerateSSHKey() {
|
||||||
|
var keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
||||||
|
|
||||||
|
var pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||||
|
var pubKeyBytes = new Uint8Array(pubRaw);
|
||||||
|
|
||||||
|
// PKCS#8 for Ed25519 is exactly 48 bytes; bytes 16-47 are the 32-byte seed
|
||||||
|
var privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
var privBytes = new Uint8Array(privPkcs8);
|
||||||
|
var seed = privBytes.slice(16, 48);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: vfEncodeSSHPublicKey(pubKeyBytes),
|
||||||
|
privateKey: vfEncodeSSHPrivateKey(seed, pubKeyBytes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vfDownloadFile(filename, content) {
|
||||||
|
var blob = new Blob([content], { type: 'application/octet-stream' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user