v3.29.1: the per-row keysets in the outfile preview

The import applied one global default keyset to every TAR entry of every
imported card.  The preview's TAR table (TAR + MSL, the three role rows +
"+ TAR") now has a keyset select per row, mirroring the preset editor: an
empty "— none —" plus the keysets found in the file with their KIc/KID,
the first preselected, and the editor's amber warning when a non-zero MSL
has no keyset.  Each TAR's packets then use exactly the chosen keyset.

- PWA: the global "Default keyset for the TARs" select (and the request's
  tar_keyset) is gone; cardsOutfileTarsFromDom/FromForm carry each row's
  keyset; cardsOutfileKeysets() became cardsOutfileKeysetOptions() (the
  union with the KIc/KID bytes for the labels).
- Server: a `tars` entry's own `keyset` (1-15; an unusable value is a 400)
  wins, then the optional global `tar_keyset` (the API shorthand), then the
  card's first keyset - parse_outfile no longer clobbers per-entry keysets
  with the card-level fallback.  presets.normalise_keyset_ref() is
  module-level (one rule for the store and the parser).
- Docs: help EN/RU, api.md (the tars entry keyset + the global fallback),
  the repo AGENTS.  Tests: the per-entry/global/card fallbacks, the
  bad-keyset 400, the options helper, the row select and round-trip, the
  import body.  925 frontend + 944 Python; sw cache simple-v448.
This commit is contained in:
2026-10-15 00:40:16 +03:00
parent cf7ee77a9e
commit 8a66ea5a0a
12 changed files with 168 additions and 95 deletions
+34 -29
View File
@@ -1928,7 +1928,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.29.0';
const SIMPLE_VERSION = '3.29.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -14856,7 +14856,7 @@ function cardsImportFile(input) {
// shows the preview plus the controls: the common name, the 16-byte key
// algorithm, the TAR table and its one global default keyset - the final call
// imports with a mode.
let _cardsOutfile = null; // {text, file, name, algorithm16, tarKeyset, mode, preview}
let _cardsOutfile = null; // {text, file, name, algorithm16, mode, preview}
let _cardsOutfileTars = 0; // the free TAR rows of the panel
function cardsOutfilePick() {
@@ -14879,7 +14879,7 @@ async function cardsOutfileFile(input) {
}
_cardsOutfile = { text: text, file: String(file.name || ''),
name: String(file.name || '').replace(/\.[^.]+$/, ''),
algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
algorithm16: '3des2', mode: 'merge', preview: null };
_cardsOutfileTars = 0;
cardsOutfileRender();
await cardsOutfilePreview();
@@ -14905,8 +14905,6 @@ function cardsOutfileControls() {
if (document.getElementById('cards-of-mode')) {
o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge';
}
const ks = parseInt(val('cards-of-keyset'), 10);
if (Number.isInteger(ks) && ks > 0) o.tarKeyset = ks;
}
// The TAR table as the panel shows it: the three role rows (their defaults
@@ -14917,17 +14915,21 @@ function cardsOutfileTarsFromDom() {
const el = document.getElementById(id);
return el ? String(el.value).trim() : '';
};
const ks = i => {
const v = parseInt(get('cards-of-tar-' + i + '-keyset'), 10);
return Number.isInteger(v) && v > 0 ? v : null;
};
const out = CARDS_TAR_ROLES.map((role, i) => ({
role: role,
tar: get('cards-of-tar-' + i + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + i + '-msl').replace(/\s/g, '').toUpperCase(),
desc: '',
desc: '', keyset: ks(i),
}));
for (let i = 0; i < _cardsOutfileTars; i++) {
const idx = CARDS_TAR_ROLES.length + i;
out.push({ tar: get('cards-of-tar-' + idx + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + idx + '-msl').replace(/\s/g, '').toUpperCase(),
desc: get('cards-of-tar-' + idx + '-desc') });
desc: get('cards-of-tar-' + idx + '-desc'), keyset: ks(idx) });
}
return out;
}
@@ -14942,7 +14944,7 @@ function cardsOutfileTarsFromForm() {
const e = rows[i];
if (e.role) {
out.push({ role: e.role, tar: e.tar || cardsRoleDefault(e.role),
msl: e.msl || CARDS_MSL_DEFAULT, desc: '' });
msl: e.msl || CARDS_MSL_DEFAULT, desc: '', keyset: e.keyset || null });
} else if (e.tar || e.msl || e.desc) {
out.push(e);
}
@@ -14953,9 +14955,24 @@ function cardsOutfileTarsFromForm() {
function cardsOutfileTarRowHtml(e, i) {
const cls = 'border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-0.5 dark:bg-slate-800 font-mono';
const role = CARDS_TAR_ROLES[i] || '';
// The keyset this TAR's packets use: one select per row, like the preset
// editor (v3.29.1); it defaults to the first parsed keyset, and a non-zero
// MSL without one gets the amber marker.
const opts = cardsOutfileKeysetOptions();
const sel = e.keyset ? Number(e.keyset) : (opts.length ? opts[0].v : null);
const msl = String(e.msl || '').toUpperCase();
let html = '<div class="flex flex-wrap items-center gap-1.5 mb-1">';
html += '<input id="cards-of-tar-' + i + '-tar" class="w-24 ' + cls + '" maxlength="6" placeholder="TAR" value="' + escHtml(e.tar || '') + '">';
html += '<input id="cards-of-tar-' + i + '-msl" class="w-14 ' + cls + '" maxlength="2" placeholder="MSL" value="' + escHtml(e.msl || '') + '">';
html += '<select id="cards-of-tar-' + i + '-keyset" class="' + cls + '" title="' + esc(t('The keyset packets to this TAR use - a non-zero MSL requires one')) + '">' +
'<option value=""' + (sel ? '' : ' selected') + '>' + esc(t('— none —')) + '</option>' +
opts.map(o => '<option value="' + o.v + '"' + (o.v === sel ? ' selected' : '') + '>' +
esc(t('Keyset') + ' ' + o.v + ' \u2014 ' + o.kic + '/' + o.kid) + '</option>').join('') +
'</select>';
if (!sel && msl && msl !== '00') {
html += '<span class="text-amber-500" title="' +
esc(t('This TAR needs a keyset (its MSL requires security)')) + '">\u26a0</span>';
}
if (role) {
html += '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(CARDS_TAR_ROLE_DESC[role] || role) + '</span>';
} else {
@@ -15006,15 +15023,19 @@ function cardsOutfileTarRender(rows) {
}
// The keyset numbers the parsed cards carry (the global default's options).
function cardsOutfileKeysets() {
const out = [];
// The parsed keysets as the TAR rows' options: the union across the parsed
// cards, with their KIc/KID bytes for the labels (v3.29.1).
function cardsOutfileKeysetOptions() {
const seen = {};
(((_cardsOutfile || {}).preview || {}).presets || []).forEach(c => {
(c.keysets || []).forEach(ks => {
const v = spKeysetKvnOf(ks.kic);
if (v !== null && !out.includes(v)) out.push(v);
if (v !== null && !seen[v]) {
seen[v] = { v: v, kic: ks.kic || '??', kid: ks.kid || '??' };
}
});
});
return out.sort((a, b) => a - b);
return Object.keys(seen).map(k => seen[k]).sort((a, b) => a.v - b.v);
}
function cardsOutfileResultHtml() {
@@ -15059,15 +15080,6 @@ function cardsOutfileRender() {
const panel = document.getElementById('cards-outfile-panel');
const o = _cardsOutfile;
if (!panel || !o) return;
const keysets = cardsOutfileKeysets();
// An empty or failed preview must not drop a chosen keyset: the value is
// only re-selected against a list that has keysets, and the option list is
// the preview's keysets plus a remembered value (so it stays visible).
if (keysets.length && !keysets.includes(o.tarKeyset)) o.tarKeyset = keysets[0];
const options = keysets.slice();
if (o.tarKeyset && !options.includes(o.tarKeyset)) options.push(o.tarKeyset);
options.sort((a, b) => a - b);
const selected = o.tarKeyset;
let html = '<div class="flex items-center justify-between mb-2">';
html += '<span class="text-sm font-semibold">' + esc(t('outfile import')) +
': <span class="font-mono text-xs">' + esc(o.file || '') + '</span></span>';
@@ -15080,11 +15092,6 @@ function cardsOutfileRender() {
'<select id="cards-of-algo" onchange="cardsOutfilePreview()" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="3des2"' + (o.algorithm16 === 'aes' ? '' : ' selected') + '>3DES2</option>' +
'<option value="aes"' + (o.algorithm16 === 'aes' ? ' selected' : '') + '>AES</option></select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Default keyset for the TARs')) +
'<select id="cards-of-keyset" onchange="cardsOutfilePreview()" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
(options.length ? options.map(v => '<option value="' + v + '"' + (v === selected ? ' selected' : '') + '>' + esc(t('Keyset') + ' ' + v) + '</option>').join('')
: '<option value="">\u2014</option>') +
'</select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Import mode')) +
'<select id="cards-of-mode" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="merge"' + (o.mode === 'replace' ? '' : ' selected') + '>' + esc(t('Merge')) + '</option>' +
@@ -15110,7 +15117,6 @@ async function cardsOutfilePreview() {
text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(),
};
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try {
o.preview = await pysimFetchOk('/api/presets/import-outfile', body);
} catch (e) {
@@ -15131,7 +15137,6 @@ async function cardsOutfileImport() {
text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), mode: o.mode,
};
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try {
const res = await pysimFetchOk('/api/presets/import-outfile', body);
await cardsFetch();
@@ -23632,7 +23637,7 @@ const LANG_RU = {
'outfile import': 'Импорт outfile',
'Common name': 'Общее имя',
'16-byte keys': '16-байтные ключи',
'Default keyset for the TARs': 'Keyset по умолчанию для TAR',
'This TAR needs a keyset (its MSL requires security)': 'Этому TAR нужен keyset (его MSL требует защиты)',
'Import mode': 'Режим импорта',
'Merge': 'Дополнить',
'Replace': 'Заменить',