Files
luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js
Paul Donald 1299531242 luci-mod-system: file naming improvement for repo key management
When downloading a key, try to get the filename of the key. opkg expects it to
match the key fingerprint when verifying .sig files. apk not so.

Try to save the public key file with the name from the provided URL, so it is
not named "key_blah" which won't match its fingerprint.

The alternative would be to call usign with the public key file as input, and
then rename it to its fingerprint. This is only necessary for opkg which is on
the way out.

If the public key file at the remote resource is not named according to its
fingerprint, that's on the repo owner.

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
2025-11-02 18:34:30 +01:00

289 lines
8.0 KiB
JavaScript

'use strict';
'require baseclass';
'require view';
'require fs';
'require ui';
const APK_DIR = '/etc/apk/keys/';
const OPKG_DIR = '/etc/opkg/keys/';
const isReadonlyView = !L.hasViewPermission() || null;
let KEYDIR = null;
let KEYEXT = null;
/* This safeList is not bullet-proof, but should prevent users
accidentally deleting official repo keys */
const safeList = [
'd310c6f2833e97f7', // 24.10 release usign key
'openwrt-snapshots.pem', // main snapshots EC pub key
];
function isFileInSafeList(file){
for (let name of safeList) {
if (file === name)
return true;
}
return false;
}
function normalizeKey(s) {
return s?.replace(/\s+/g, ' ')?.trim();
}
function determineKeyEnv() {
return fs.stat(APK_DIR).then(() => {
KEYDIR = APK_DIR;
KEYEXT = '.pem'; // not strictly necessary - apk allows any extension
}).catch(() => {
KEYDIR = OPKG_DIR;
KEYEXT = null; // opkg requires key filenames without an extension
});
}
function listKeyFiles() {
return fs.list(KEYDIR).then(entries =>
Promise.all(entries.map(entry =>
fs.read(KEYDIR + entry.name).then(content => ({
filename: entry.name,
key: content
}))
))
);
}
function safeText(str) {
return String(str).replace(/[&<>"']/g, s => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[s]));
}
function renderKeyItem(pubkey) {
const safeFile = isFileInSafeList(pubkey?.filename);
const lines = pubkey?.key?.trim()?.split('\n').map(line =>
[ E('br'), E('code', {}, [ safeText(line) ]) ]
).flat();
return E('div', {
class: 'item',
click: (isReadonlyView || safeFile) ? null : removeKey,
'data-file': pubkey?.filename,
'data-key': normalizeKey(pubkey?.key)
}, [
E('strong', {}, [ pubkey?.filename || _('Unnamed key') ]),
...lines
]);
}
function refreshKeyList(list, keys) {
while (!matchesElem(list.firstElementChild, '.add-item'))
list.removeChild(list.firstElementChild);
keys.forEach(function(pubkey) {
list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
});
if (list.firstElementChild === list.lastElementChild)
list.insertBefore(E('p', _('No software repository public keys present yet.')), list.lastElementChild);
}
function saveKeyFile(keyContent, file, fileContent) {
const ts = Date.now();
// Note: opkg can only verify against a key with filename that matches its key hash
// generate a file name in case key content was pasted
const filename = file ? file?.name?.split('.')?.[0] + (KEYEXT || '') : null;
const noname = 'key_' + ts + (KEYEXT || '');
return fs.write(KEYDIR + (filename ?? noname), fileContent ?? keyContent, 384 /* 0600 */);
}
function removeKey(ev) {
const file = ev.currentTarget.getAttribute('data-file');
const list = findParent(ev.target, '.cbi-dynlist');
L.showModal(_('Delete key'), [
E('div', _('Really delete the following software repository public key?')),
E('pre', [ file ]),
E('div', { class: 'right' }, [
E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
' ',
E('div', {
class: 'btn danger',
click: function() {
fs.remove(KEYDIR + file).then(() => {
return listKeyFiles().then(keys => refreshKeyList(list, keys));
});
ui.hideModal();
}
}, _('Delete key'))
])
]);
}
function isPemFormat(content) {
return new RegExp('-BEGIN ([A-Z ]+)?PUBLIC KEY-').test(content);
}
function keyEnvironmentCheck(key) {
const isPem = isPemFormat(key);
// Reject PEM in OPKG; reject non-PEM in APK
if (KEYDIR === OPKG_DIR && isPem)
return _('This key appears to be in PEM format, which is not supported in an opkg environment.');
if (KEYDIR === APK_DIR && !isPem)
return _('This key does not appear to be in PEM format, which is required in an apk environment.');
return null;
}
function addKey(ev, file, fileContent) {
const list = findParent(ev.target, '.cbi-dynlist');
const input = list.querySelector('textarea[type="text"]');
let key = (fileContent ?? input.value.trim());
if (!key.length)
return;
// Handle remote URL paste
if (/^https?:\/\/\S+$/i.test(key) && !fileContent) {
ui.addTimeLimitedNotification(_('Fetching key from URL…'), [], 5000, 'info');
L.Request.request(key, { method: 'GET' }).then(res => {
if (res.status !== 200) {
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
E('p', _('HTTP error %d').format(res.status)),
], 7000, 'warning');
return;
}
const fetched = res.responseText?.trim();
if (!fetched || fetched.length > 8192) {
ui.addTimeLimitedNotification(_('Key file too large'), [
E('p', _('Fetched content seems too long. Maximum 8192 bytes.')),
], 7000, 'warning');
return;
}
if (!fetched || fetched.length < 32) {
ui.addTimeLimitedNotification(_('Invalid or empty key file'), [
E('p', _('Fetched content seems empty or too short.')),
], 7000, 'warning');
return;
}
const filename = res?.url?.split('/').pop().split('?')[0].split('#')[0];
// Remove extension if any (we'll re-add based on environment)
const file = {name: filename.replace(/\.[^.]+$/, '') };
addKey(ev, file, fetched);
}).catch(err => {
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
E('p', err.message),
], 7000, 'warning');
});
return;
}
// From here on, key content (either pasted, fetched, or dropped)
const formatError = keyEnvironmentCheck(key);
if (formatError) {
ui.addTimeLimitedNotification(_('Invalid key format'), [
E('p', formatError)
], 7000, 'warning');
return;
}
// Prevent duplicates
const exists = Array.from(list.querySelectorAll('.item')).some(
item => item.getAttribute('data-key') === normalizeKey(key)
);
if (exists) {
ui.addTimeLimitedNotification(_('Add key'), [
E('div', _('The given software repository public key is already present.')),
], 7000, 'notice');
return;
}
input.value = '';
saveKeyFile(key, file, fileContent)
.then(() => listKeyFiles())
.then(keys => refreshKeyList(list, keys))
.catch(e => ui.addNotification(null, E('p', e.message)));
}
function dragKey(ev) {
ev.stopPropagation();
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
}
function dropKey(ev) {
ev.preventDefault();
ev.stopPropagation();
const input = ev.currentTarget.querySelector('textarea[type="text"]');
for (const file of ev.dataTransfer.files) {
const reader = new FileReader();
reader.onload = rev => {
input.value = rev.target.result;
addKey(ev, file, rev.target.result);
input.value = '';
};
reader.readAsText(file);
}
}
function handleWindowDragDropIgnore(ev) {
ev.preventDefault();
}
return view.extend({
load() {
return determineKeyEnv().then(listKeyFiles);
},
render(keys) {
const list = E('div', {
class: 'cbi-dynlist',
style: 'max-width: 800px',
dragover: isReadonlyView ? null : dragKey,
drop: isReadonlyView ? null : dropKey
}, [
E('div', { class: 'add-item' }, [
E('textarea', {
id: 'key-input',
'aria-label': _('Paste or drag repository public key'),
class: 'cbi-input-text',
type: 'text',
style: 'width: 300px; min-height: 120px; ',
placeholder: _('Paste content of a file, or a URL to a key file, or drag and drop here to upload a software repository public key…'),
keydown: function(ev) { if (ev.keyCode === 13) addKey(ev); },
disabled: isReadonlyView
}),
E('button', {
class: 'cbi-button',
click: ui.createHandlerFn(this, addKey),
disabled: isReadonlyView
}, _('Add key'))
])
]);
refreshKeyList(list, keys);
window.addEventListener('dragover', handleWindowDragDropIgnore);
window.addEventListener('drop', handleWindowDragDropIgnore);
return E('div', {}, [
E('h2', _('Repository Public Keys')),
E('div', { class: 'cbi-section-descr' },
_('Each software repository public key (from official or third party repositories) allows packages in lists signed by it to be installed by the package manager.')),
E('div', { class: 'cbi-section-descr' },
_('Each key is stored as a file in <code>%s</code>.').format(KEYDIR)),
E('div', { class: 'cbi-section-node' }, list)
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});