Создать APK бесплатно

JavaScript bridge API

Биометрия и хранилище ключей

Разблокировка отпечатком пальца и лицом и аппаратные ключи, которыми страница может шифровать, но которые никогда не может прочитать.

AppMintKeystore.decrypt помощник#

window.AppMintKeystore.decrypt(alias, base64)

Пример

Расшифровывает данные, созданные AppMintKeystore.encrypt, тем же ключом.

Возвращает: объект сразу (не Promise): {ok:true, base64} (открытые данные) или {ok:false, error, detail}. Требуется: включённые при сборке Защищённые ключи (шифрование).

function fromBase64(b64) { return decodeURIComponent(escape(atob(b64))); }

function readSecret() {
  var sealed = localStorage.getItem('secret');
  if (!sealed || !window.AppMintKeystore) return null;

  var r = window.AppMintKeystore.decrypt('vault', sealed);
  if (r.ok) return fromBase64(r.base64);

  switch (r.error) {
    case 'auth-required':   unlockThen(readSecretAndShow); break;   // unlock, then try again
    case 'no-such-key':
    case 'key-invalidated': alert('This secret can no longer be opened.'); break;
    default:                alert('Could not decrypt: ' + r.error);
  }
  return null;
}

Файл, записанный с помощью WebToApkFS.writeSealed, читайте обратно через WebToApkFS.readSealed(path, alias) — результат имеет ту же форму.

Прочитайте запечатанный JSON-объект, сначала разблокировав ключ, если это нужно:

function unlock() {
  return new Promise(function (resolve) {
    var id = 'unlock_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify({
      title: 'Unlock your settings', strong: true, allowDeviceCredential: true
    }));
  });
}

async function loadSealedSettings() {
  var sealed = localStorage.getItem('settings');
  if (!sealed || !window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return null;
  var r = window.AppMintKeystore.decrypt('vault', sealed);
  if (!r.ok && r.error === 'auth-required' && (await unlock()).ok) {
    r = window.AppMintKeystore.decrypt('vault', sealed);
  }
  return r.ok ? JSON.parse(decodeURIComponent(escape(atob(r.base64)))) : null;
}

Откройте список запечатанных записей дневника, оставив записи, которые не удалось открыть, видимыми как «заблокированные»:

function loadDiary() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return [];
  var entries = JSON.parse(localStorage.getItem('diary') || '[]');   // [{date, sealed}]
  return entries.map(function (e) {
    var r = window.AppMintKeystore.decrypt('diary', e.sealed);
    return {
      date: e.date,
      text: r.ok ? decodeURIComponent(escape(atob(r.base64))) : null,
      error: r.ok ? null : r.error                 // e.g. 'auth-required', 'no-such-key'
    };
  });
}

Примечания: После deleteKey данные потеряны навсегда — в этом и смысл. Изменённые данные не проходят проверку GCM. Другие ошибки: bad base64, disabled, foreign_origin, unsupported.

AppMintKeystore.deleteKey помощник#

window.AppMintKeystore.deleteKey(alias)

Пример

Безвозвратно уничтожает защищённый ключ. Всё, что им зашифровано, больше никто никогда не сможет прочитать.

Возвращает: true (удалён) или false (такого ключа нет или функция выключена) сразу. Требуется: включённые при сборке Защищённые ключи (шифрование).

Перезапись файла не стирает его во флеш-памяти. Зашифруйте его, затем уничтожьте ключ — это настоящее удаление:

function deleteNoteForReal() {
  if (!window.AppMintKeystore || !window.WebToApkFS) return;
  if (!confirm('Delete this note forever?')) return;

  var gone = window.AppMintKeystore.deleteKey('vault');  // secret.bin is unreadable from now on
  window.WebToApkFS.delete('secret.bin');                // tidy up the file too
  alert(gone ? 'Deleted.' : 'Nothing to delete.');
}

«Стереть всё» в настройках: уничтожьте все ключи, созданные приложением, затем очистите хранилище страницы:

function eraseEverything() {
  var K = window.AppMintKeystore;
  if (!K || !K.isAvailable()) return;
  if (!confirm('Erase all protected data on this phone? This cannot be undone.')) return;

  var aliases = K.listKeys();
  aliases.forEach(function (alias) { K.deleteKey(alias); });
  localStorage.clear();
  alert('Erased ' + aliases.length + ' key(s).');
  location.reload();
}

Самоуничтожьте хранилище после слишком многих неверных PIN-кодов приложения:

var MAX_TRIES = 5;

function onWrongPin() {
  var tries = Number(localStorage.getItem('pinTries') || 0) + 1;
  localStorage.setItem('pinTries', String(tries));
  if (tries < MAX_TRIES) return alert((MAX_TRIES - tries) + ' tries left.');

  if (window.AppMintKeystore) window.AppMintKeystore.deleteKey('vault');  // vault unreadable from now on
  localStorage.removeItem('vaultData');
  localStorage.removeItem('pinTries');
  alert('Too many wrong PINs. The vault was erased.');
}

Примечания: отменить и восстановить нельзя. Если isHardwareBacked(alias) было false, ключ хранился в программной форме — скажите об этом, прежде чем обещать пользователю необратимое удаление.

AppMintKeystore.encrypt помощник#

window.AppMintKeystore.encrypt(alias, base64)

Пример

Шифрует данные в base64 защищённым ключом. Результат можно хранить где угодно — без ключа он бесполезен.

Возвращает: объект сразу же (не Promise): {ok:true, base64} (IV + шифртекст + тег) или {ok:false, error, detail}. Требуется: включённая при сборке опция Защищённые ключи (шифрование) и ключ из AppMintKeystore.generateKey.

function toBase64(text) { return btoa(unescape(encodeURIComponent(text))); }

function saveSecret(text) {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return false;
  var r = window.AppMintKeystore.encrypt('vault', toBase64(text));
  if (r.ok) {
    localStorage.setItem('secret', r.base64);
    return true;
  }
  if (r.error === 'auth-required') {
    // the key needs a fresh unlock: ask for the fingerprint, then call saveSecret again
    unlockThen(function () { saveSecret(text); });
  } else {
    alert('Could not encrypt: ' + r.error);
  }
  return false;
}

unlockThen можно построить на authenticateBiometricEx (см. этот пример).

Чтобы записать зашифрованный ФАЙЛ любого размера, используйте WebToApkFS.writeSealed(path, base64, alias) — он шифрует по частям тем же ключом.

Запечатайте небольшой JSON-объект (заметку, токен, настройки) и храните его в localStorage:

function sealJson(alias, obj) {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return null;
  var b64 = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
  var r = window.AppMintKeystore.encrypt(alias, b64);
  if (!r.ok) { console.warn('encrypt failed:', r.error, r.detail); return null; }
  return r.base64;
}

var sealed = sealJson('notes', { title: 'Shopping', items: ['milk', 'eggs'] });
if (sealed) localStorage.setItem('note-1', sealed);

Создайте ключ при первой необходимости, затем шифруйте:

function encryptWithKeyReady(alias, b64) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return { ok: false, error: 'unsupported' };
  var r = ks.encrypt(alias, b64);
  if (!r.ok && r.error === 'no-such-key') {
    var g = ks.generateKey(alias);          // plain key, no unlock needed
    if (!g.ok) return g;
    r = ks.encrypt(alias, b64);
  }
  return r;                                 // {ok:true, base64} or {ok:false, error, detail}
}

Примечания: максимум 4 МБ на вызов (chunk too large). Ошибки: no-such-key, auth-required, key-invalidated (блокировка экрана изменилась — ключ исчез), bad base64, disabled, foreign_origin, unsupported.

AppMintKeystore.generateKey помощник#

window.AppMintKeystore.generateKey(alias, options)

Пример

Создаёт (или заменяет) ключ AES-256-GCM в Android Keystore. Ключ никогда не покидает защищённое оборудование; ваша страница использует только его имя.

Возвращает: объект сразу (не Promise): {ok:true, alias, hardwareBacked} или {ok:false, error, detail}. Требуется: включённые при сборке Защищённые ключи (шифрование) (см. AppMintKeystore.isAvailable).

function createVault() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) {
    alert('Secure keys are not available in this app.');
    return;
  }
  var r = window.AppMintKeystore.generateKey('vault', {
    requireAuth: true,         // usable only after a fingerprint / PIN unlock...
    authValiditySeconds: 30    // ...for 30 seconds after it
  });
  if (!r.ok) { alert('Could not create the key: ' + r.error); return; }
  console.log('Hardware key:', r.hardwareBacked);
}

Обычный ключ, разблокировка не нужна:

var r = window.AppMintKeystore.generateKey('notes');   // options default to {}

Потребуйте отдельный защищённый чип (StrongBox). Если его нет, вызов завершится ошибкой, а не выдаст менее надёжный ключ:

var r = window.AppMintKeystore.generateKey('wallet', { strongBox: true });
if (!r.ok && r.error === 'strongbox-unavailable') {
  alert('This phone has no secure chip.');
}

Примечания: Псевдоним: буквы, цифры, _ - ., до 64 символов (иначе error:"bad alias"). Если использовать существующий псевдоним, ключ заменяется, а данные, запечатанные старым ключом, теряются. Другие ошибки: disabled, bad options json, foreign_origin, unsupported (нет моста).

AppMintKeystore.hasKey помощник#

window.AppMintKeystore.hasKey(alias)

Пример

Сообщает, существует ли защищённый ключ с этим псевдонимом.

Возвращает: true / false сразу. Требуется: включённые при сборке Защищённые ключи (шифрование).

function startVault() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return;
  if (window.AppMintKeystore.hasKey('vault')) {
    showUnlockScreen();
  } else {
    showCreateVaultScreen();
  }
}
window.addEventListener('load', startVault);

Убедитесь, что ключ существует, до первого сохранения, создавая его только один раз:

function ensureKey(alias) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return false;
  if (ks.hasKey(alias)) return true;
  var r = ks.generateKey(alias);   // generateKey REPLACES an existing key, so only call it when hasKey says no
  if (!r.ok) console.warn('Could not create key:', r.error);
  return r.ok;
}

if (ensureKey('notes')) enableSaveButton();

Очищайте запечатанные данные, ключ от которых исчез (удалён или данные приложения восстановлены без него):

function checkVault() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return;
  var sealed = localStorage.getItem('secret');
  if (sealed && !ks.hasKey('vault')) {
    localStorage.removeItem('secret');   // without the key it can never be opened
    showMessage('Your saved secret could not be recovered and was removed.');
  }
}
window.addEventListener('load', checkVault);

Примечания: Возвращает false, если защищённые ключи выключены, поэтому сначала проверьте isAvailable(), если нужно отличить «ключа ещё нет» от «функции нет в приложении».

AppMintKeystore.isAvailable помощник#

window.AppMintKeystore.isAvailable()

Пример

Сообщает, можно ли использовать в этом приложении защищённые ключи с аппаратной защитой.

Возвращает: true / false сразу же (не Promise). Требуется: включённая при сборке опция Защищённые ключи (шифрование). Объект window.AppMintKeystore устанавливается (до запуска вашего первого скрипта), когда включены Защищённые ключи или Доступ к родным папкам (SAF); isAvailable() отвечает за защищённые ключи.

function secureKeysReady() {
  return !!window.AppMintKeystore && window.AppMintKeystore.isAvailable();
}

// Check when the user acts (the helper is added when the page finishes loading):
document.getElementById('vault-button').addEventListener('click', function () {
  if (!secureKeysReady()) {
    alert('The secure vault is not available in this app.');
    return;
  }
  openVault();
});

Скройте все элементы защищённого хранилища, если этой функции нет в приложении:

window.addEventListener('load', function () {
  var ok = !!window.AppMintKeystore && window.AppMintKeystore.isAvailable();
  document.querySelectorAll('.needs-secure-keys').forEach(function (el) {
    el.hidden = !ok;
  });
});

Отличайте «функция выключена» от «ключа ещё нет», когда решаете, какой экран показать:

function vaultState() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return 'off';   // a browser, or Secure Keys not on in this build
  return ks.hasKey('vault') ? 'ready' : 'new';
}

document.getElementById('vault-button').addEventListener('click', function () {
  var state = vaultState();
  if (state === 'off') alert('The secure vault is not available in this app.');
  else if (state === 'new') showCreateVaultScreen();
  else showUnlockScreen();
});

Примечания: Помощник добавляется после загрузки страницы, поэтому не проверяйте его в скрипте, который выполняется, пока страница ещё разбирается, — проверяйте в обработчике клика или после load. В браузере нет window.AppMintKeystore; проверка выше вернёт false без ошибки.

AppMintKeystore.isHardwareBacked помощник#

window.AppMintKeystore.isHardwareBacked(alias)

Пример

Сообщает, находится ли ключ в защищённом оборудовании телефона (TEE или StrongBox), а не в программном обеспечении.

Возвращает: true / false сразу. false, если ключа не существует. Требуется: включённые при сборке Защищённые ключи (шифрование).

function showKeyStrength() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.hasKey('vault')) return;
  var hw = window.AppMintKeystore.isHardwareBacked('vault');
  document.getElementById('strength').textContent = hw
    ? 'Protected by secure hardware'
    : 'Protected by software (this phone has no secure hardware for keys)';
}

Храните ценный секрет (сид-фразу кошелька, код восстановления) только если его ключ находится в защищённом оборудовании:

function walletKeyIsHardware() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return false;
  if (!ks.hasKey('wallet')) {
    var r = ks.generateKey('wallet', { requireAuth: true });
    if (!r.ok) return false;
  }
  return ks.isHardwareBacked('wallet');
}

if (!walletKeyIsHardware()) {
  alert('This phone cannot keep the wallet key in secure hardware.');
}

Выведите, где находится каждый ключ, для отчёта в поддержку:

function keyReport() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return { secureKeys: false };
  return {
    secureKeys: true,
    keys: ks.listKeys().map(function (alias) {
      return { alias: alias, hardware: ks.isHardwareBacked(alias) };
    })
  };
}
console.log(JSON.stringify(keyReport()));

Примечания: generateKey также возвращает hardwareBacked сразу после создания ключа.

AppMintKeystore.listKeys помощник#

window.AppMintKeystore.listKeys()

Пример

Перечисляет псевдонимы всех защищённых ключей, созданных вашими страницами.

Возвращает: массив строк, отсортированный, сразу, например ['notes', 'vault']. [], если ключей нет или функция выключена. Требуется: включённые при сборке Защищённые ключи (шифрование).

function renderKeyList() {
  var list = document.getElementById('key-list');
  list.innerHTML = '';
  if (!window.AppMintKeystore) return;
  window.AppMintKeystore.listKeys().forEach(function (alias) {
    var li = document.createElement('li');
    li.textContent = alias + (window.AppMintKeystore.isHardwareBacked(alias) ? ' (hardware)' : '');
    list.appendChild(li);
  });
}

Уничтожайте каждый ключ при «Стереть все мои данные», благодаря чему всё запечатанное ими становится нечитаемым:

function eraseAllKeys() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return 0;
  var removed = 0;
  ks.listKeys().forEach(function (alias) {
    if (ks.deleteKey(alias)) removed++;
  });
  localStorage.clear();   // the sealed copies can no longer be opened anyway
  return removed;
}

Храните по одному ключу на каждого вошедшего пользователя и удаляйте ключи вышедших:

function pruneUserKeys(currentUserId) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return;
  var keep = 'user-' + currentUserId;   // aliases: letters, digits, _ - . only
  ks.listKeys().forEach(function (alias) {
    if (alias.indexOf('user-') === 0 && alias !== keep) ks.deleteKey(alias);
  });
}

Примечания: перечисляются только ключи, созданные через мост; внутренние ключи самого приложения никогда не показываются.

authenticateBiometric мост#

window.WebToApk.authenticateBiometric(callbackId: String, title: String, subtitle: String)

WebToApk.isBiometricAvailable() -> "available" | "no_hardware" | "not_enrolled" | "unavailable" WebToApk.authenticateBiometric(id, title, subtitle) Результат передаётся в ту же таблицу обратных вызовов __webToApkAuth, которую использует мост входа через Google: window.__webToApkAuth = window.__webToApkAuth || {}; window.__webToApkAuth['unlock'] = function (json) { var r = JSON.parse(json); // {ok:true} | {ok:false,error:"..."} if (r.ok) showApp(); }; WebToApk.authenticateBiometric('unlock', 'Unlock', 'Use your fingerprint');

Описано в своей группе, Мост отпечатка пальца / биометрии, а не отдельно.

Пример

Показывает системный запрос отпечатка пальца с заголовком и подзаголовком. Простая форма authenticateBiometricEx (используйте её для варианта с PIN, своей подписи кнопки «Отмена» или «сильной» биометрии).

Возвращает: ничего. Ответ приходит позже: оболочка вызывает window.__webToApkAuth[callbackId](json) с '{"ok":true}' или '{"ok":false,"error":"<code>"}', затем удаляет эту запись. Требуется: отмеченное при сборке Fingerprint (без него: error:"permission_missing").

Сначала зарегистрируйте обратный вызов, затем вызывайте:

function unlockWithFingerprint() {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometric !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'bio_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometric(id, 'Unlock', 'Touch the fingerprint sensor');
  });
}

document.getElementById('unlock').addEventListener('click', async function () {
  var r = await unlockWithFingerprint();
  if (r.ok) showMyApp();
  else if (r.error === 'user_cancel') { /* the user closed it — do nothing */ }
  else if (r.error === 'none_enrolled') alert('Please add a fingerprint in phone settings.');
  else if (r.error === 'lockout') alert('Too many tries. Wait a little and try again.');
  else alert('Could not unlock: ' + r.error);
});

Сначала подтвердите рискованное действие отпечатком пальца (пустой заголовок показывает название приложения):

function confirmItsYou(subtitle) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometric !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'confirm_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometric(id, '', subtitle);
  });
}

document.getElementById('delete-account').addEventListener('click', async function () {
  var r = await confirmItsYou('Confirm to delete your account');
  if (r.ok) deleteMyAccount();
  else if (r.error !== 'user_cancel') alert('Not confirmed: ' + r.error);
});

Блокируйте приложение снова, когда оно возвращается после более чем минуты в фоне:

var hiddenAt = 0;

document.addEventListener('visibilitychange', async function () {
  if (document.visibilityState === 'hidden') {
    hiddenAt = Date.now();
    return;
  }
  if (!hiddenAt || Date.now() - hiddenAt < 60000) return;
  hiddenAt = 0;
  hideMyApp();
  var r = await unlockWithFingerprint();     // from the first example
  if (r.ok) showMyApp();
  else showLockedScreen();                   // with its own "Unlock" button
});

Примечания: Если заголовок пуст, используется название приложения. Неверный палец НЕ завершает окно — обратный вызов срабатывает только при успехе, отмене или настоящей ошибке. Коды ошибок те же, что у authenticateBiometricEx. Это подтверждает, что владелец телефона рядом, но не доказывает серверу, кто он.

authenticateBiometricEx мост#

window.WebToApk.authenticateBiometricEx(callbackId: String, optionsJson: String)

Полная форма. [optionsJson] принимает: title, subtitle, description, negativeButtonText, allowDeviceCredential (запасной вариант — PIN-код/графический ключ/пароль), strong (требовать BIOMETRIC_STRONG — используйте для всего, что связано с платежами). Ошибки — это отдельные коды, а не одно логическое значение. До показа окна: no_hardware | none_enrolled | unavailable (проверку состояния не удалось выполнить — см. isBiometricAvailable). От системного окна Android 9+: user_cancel | no_hardware | hw_unavailable | none_enrolled | lockout | lockout_permanent | no_device_credential | timeout | no_space | unable_to_process | vendor_error, а для кода, который Android добавит позже, — его собственный текст сообщения (или `error_<code>`). permission_missing, если в сборке нет разрешения Fingerprint. На Android 7–8 (API 24–27, только отпечаток пальца) ошибкой становится текст сообщения самого телефона в том виде, как его написал производитель, а не фиксированный код.

Пример

Показывает системный запрос отпечатка пальца / лица со всеми опциями: PIN-код или графический ключ как второй способ входа, своя подпись кнопки «Отмена» и только «сильная» биометрия.

Возвращает: ничего. Ответ приходит позже: оболочка вызывает window.__webToApkAuth[callbackId](json) с '{"ok":true}' или '{"ok":false,"error":"<code>"}', затем удаляет эту запись. Требуется: отмеченное при сборке Fingerprint (без него: error:"permission_missing").

Небольшая обёртка на Promise, которую можно использовать повторно:

function verifyUser(options) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometricEx !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'bio_' + Date.now() + '_' + Math.random().toString(36).slice(2);
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify(options));
  });
}

Разблокируйте приложение — отпечатком пальца, лицом или PIN-кодом/графическим ключом телефона:

async function unlockApp() {
  var r = await verifyUser({
    title: 'Unlock',
    subtitle: 'Use your fingerprint or PIN',
    allowDeviceCredential: true       // Android 11+: the system adds "Use PIN"
  });
  if (r.ok) showMyApp();
  else if (r.error === 'lockout') alert('Too many tries. Wait a little and try again.');
}

Подтверждение платежа — только надёжная биометрия, собственная подпись кнопки «Отмена»:

async function confirmPayment() {
  var r = await verifyUser({
    title: 'Confirm payment',
    description: 'Pay 12.00 to Coffee House',
    negativeButtonText: 'Not now',
    strong: true                      // BIOMETRIC_STRONG (Android 11+)
  });
  if (r.ok) sendPayment();
  else if (r.error !== 'user_cancel') alert('Could not confirm: ' + r.error);
}

Разблокируйте ключ Keystore, созданный с requireAuth, затем повторите:

async function openVault() {
  var r = await verifyUser({ title: 'Open vault', allowDeviceCredential: true });
  if (r.ok) readSecret();    // AppMintKeystore.decrypt works for authValiditySeconds now
}

Примечания: коды ошибок: user_cancel, none_enrolled, no_hardware, hw_unavailable, lockout (подождите и повторите), lockout_permanent (пользователь должен сначала разблокировать телефон PIN-кодом), no_device_credential, timeout, permission_missing, unavailable, и редко no_space, unable_to_process, vendor_error. На Android 7–8 оболочка показывает собственный диалог, а ошибка — это текст системного сообщения. allowDeviceCredential и negativeButtonText несовместимы: с вариантом PIN система рисует свою кнопку. Неверный палец не завершает запрос.

decryptWithKey мост#

window.WebToApk.decryptWithKey(alias: String, base64Payload: String): String

Обращает [encryptWithKey]. После удаления ключа завершается с `error:"no-such-key"` — данные при этом невозможно восстановить, так и задумано. @return JSON `{ok, base64, error}`.

Пример

Расшифровывает данные, созданные encryptWithKey, тем же ключом Keystore. Большинство страниц используют AppMintKeystore.decrypt(alias, base64), который возвращает разобранный объект.

Возвращает: JSON-строку сразу: {"ok":true,"base64":"<plain data>"} или {"ok":false,"error":"...","detail":"..."}. Требуется: включённые при сборке Защищённые ключи (шифрование).

function fromBase64(b64) { return decodeURIComponent(escape(atob(b64))); }

function openNote() {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return null;
  var sealed = localStorage.getItem('note');
  if (!sealed) return null;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  if (r.ok) return fromBase64(r.base64);

  if (r.error === 'auth-required') {
    askUserToUnlockThenRetry();          // unlock with authenticateBiometricEx, then call again
  } else if (r.error === 'no-such-key' || r.error === 'key-invalidated') {
    alert('This note can no longer be opened.');   // the key was deleted or the screen lock changed
  } else {
    alert('Could not open the note: ' + r.error);
  }
  return null;
}

Для ключа, созданного с requireAuth:true: при auth-required покажите запрос разблокировки, затем расшифруйте снова:

function unlock() {
  return new Promise(function (resolve) {
    var id = 'unlock_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify({
      title: 'Unlock your notes', strong: true, allowDeviceCredential: true
    }));
  });
}

async function openNoteWithUnlock() {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return null;
  var sealed = localStorage.getItem('note');
  if (!sealed) return null;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  if (!r.ok && r.error === 'auth-required') {
    var auth = await unlock();
    if (!auth.ok) return null;                      // cancelled or failed
    r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  }
  return r.ok ? decodeURIComponent(escape(atob(r.base64))) : null;
}

Покажите зашифрованное фото: расшифруйте в байты, затем отобразите через blob URL:

function showSealedPhoto(sealedB64, img) {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealedB64));
  if (!r.ok) {
    alert('Could not open the photo: ' + r.error);
    return;
  }
  var bin = atob(r.base64);
  var bytes = new Uint8Array(bin.length);
  for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  var url = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' }));
  img.onload = function () { URL.revokeObjectURL(url); };
  img.src = url;
}

Примечания: После удаления ключа (deleteKey) данные больше никогда нельзя расшифровать — так и задумано. Изменённые или повреждённые данные не проходят проверку (тег GCM не совпадает); тогда error — это сообщение платформы, а не один из перечисленных выше кодов. Другие коды: bad base64, disabled, foreign_origin.

deleteKey мост#

window.WebToApk.deleteKey(alias: String): String

Безвозвратно уничтожает [alias]. Всё, что им зашифровано, становится нерасшифровываемым — в том числе для самого приложения и для любого, у кого окажется устройство.

Пример

Навсегда уничтожает ключ Keystore. Всё, что было им зашифровано, больше никогда нельзя будет прочитать — ни вам, ни пользователю, ни тому, у кого окажется телефон. Большинство страниц используют AppMintKeystore.deleteKey(alias), который возвращает настоящий boolean.

Возвращает: СТРОКУ "true" (удалён) или "false" (нет такого ключа, защищённые ключи выключены или страница не принадлежит приложению) сразу же. Требуется: включённая при сборке опция Защищённые ключи (шифрование).

Это единственное настоящее «безопасное удаление» на телефоне: перезапись файла не стирает его во флеш-памяти, а уничтожение ключа стирает.

function destroyVault() {
  if (!window.WebToApk || typeof window.WebToApk.deleteKey !== 'function') return;
  if (!confirm('Delete the vault? This cannot be undone.')) return;

  var gone = window.WebToApk.deleteKey('vault') === 'true';
  localStorage.removeItem('note');              // the sealed data is now useless anyway
  alert(gone ? 'Vault deleted.' : 'There was no vault to delete.');
}

Смена ключа: откройте данные старым ключом, запечатайте новым, затем уничтожьте старый ключ:

function rotateVault() {
  var W = window.WebToApk;
  if (!W || typeof W.deleteKey !== 'function') return false;
  var sealed = localStorage.getItem('note');
  if (!sealed) return false;

  var clear = JSON.parse(W.decryptWithKey('vault', sealed));
  if (!clear.ok) return false;
  var made = JSON.parse(W.generateKey('vault2', '{}'));
  if (!made.ok) return false;
  var fresh = JSON.parse(W.encryptWithKey('vault2', clear.base64));
  if (!fresh.ok) return false;

  localStorage.setItem('note', fresh.base64);
  localStorage.setItem('noteKey', 'vault2');
  return W.deleteKey('vault') === 'true';          // the old copies are dead from here on
}

«Забыть меня на этом телефоне» при выходе из аккаунта: уничтожьте ключ аккаунта, затем удалите его кэшированные данные:

function signOutAndForget(userId) {
  var alias = 'user_' + userId;
  if (window.WebToApk && typeof window.WebToApk.deleteKey === 'function') {
    var gone = window.WebToApk.deleteKey(alias) === 'true';
    console.log(gone ? 'Account key destroyed' : 'No key for this account');
  }
  localStorage.removeItem('messages_' + userId);   // sealed with that key, unreadable now
  signOut();
}

Примечания: Проверьте isKeyHardwareBacked, прежде чем обещать пользователю, что удаление необратимо, — только программный ключ менее надёжен.

encryptWithKey мост#

window.WebToApk.encryptWithKey(alias: String, base64Plaintext: String): String

Шифрует base64 [base64Plaintext] ключом [alias]. Каждый вызов создаёт собственный IV, поэтому большой файл шифруется по частям: части независимы и расшифровываются в любом порядке. @return JSON `{ok, base64, error}`, где `base64` — это `IV || ciphertext || tag`.

Пример

Шифрует данные в base64 ключом Keystore. Большинство страниц используют AppMintKeystore.encrypt(alias, base64), который возвращает разобранный объект.

Возвращает: JSON-строку сразу: {"ok":true,"base64":"<IV + ciphertext + tag>"} или {"ok":false,"error":"...","detail":"..."}. Требуется: включённые при сборке Защищённые ключи (шифрование) и ключ, созданный с generateKey.

Входные данные — base64. Для текста сначала переведите его в base64 из UTF-8:

function toBase64(text) { return btoa(unescape(encodeURIComponent(text))); }

function sealNote(text) {
  if (!window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return null;
  var r = JSON.parse(window.WebToApk.encryptWithKey('vault', toBase64(text)));
  if (r.ok) {
    localStorage.setItem('note', r.base64);   // safe to store anywhere: useless without the key
    return r.base64;
  }
  if (r.error === 'auth-required') {
    // key was made with requireAuth — unlock first (authenticateBiometricEx), then call again
    askUserToUnlockThenRetry();
  } else {
    alert('Could not encrypt: ' + r.error);
  }
  return null;
}

Запечатайте целый объект (сессию, настройки) как JSON перед сохранением:

function sealJson(alias, obj) {
  if (!window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return null;
  var b64 = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
  var r = JSON.parse(window.WebToApk.encryptWithKey(alias, b64));
  if (!r.ok) console.warn('encrypt failed: ' + r.error);
  return r.ok ? r.base64 : null;
}

var sealed = sealJson('vault', { token: accessToken, savedAt: Date.now() });
if (sealed) localStorage.setItem('session', sealed);

Зашифруйте фото, выбранное пользователем (двоичные данные, до 4 МБ за вызов):

document.getElementById('id-photo').addEventListener('change', async function (e) {
  var file = e.target.files[0];
  if (!file || !window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return;
  var bytes = new Uint8Array(await file.arrayBuffer());
  var bin = '';
  for (var i = 0; i < bytes.length; i += 0x8000) {
    bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  }
  var r = JSON.parse(window.WebToApk.encryptWithKey('vault', btoa(bin)));
  if (r.ok) {
    saveSealedPhoto(file.name, r.base64);          // your own storage
  } else if (r.error.indexOf('chunk too large') === 0) {
    alert('That photo is too big. Pick one under 4 MB.');
  } else {
    alert('Could not encrypt the photo: ' + r.error);
  }
});

Примечания: Каждый вызов использует новый IV, поэтому один и тот же текст каждый раз даёт разный результат. Не более 4 МБ за вызов (error:"chunk too large ..."); для больших файлов шифруйте по частям или используйте WebToApkFS.writeSealed. Коды ошибок: no-such-key, auth-required, key-invalidated (пользователь сменил блокировку экрана — ключ утрачен навсегда), bad base64, disabled, foreign_origin.

generateKey мост#

window.WebToApk.generateKey(alias: String, optionsJson: String): String

Создаёт (или заменяет) ключ AES-256-GCM с именем [alias]. @param optionsJson `{"requireAuth":bool, "authValiditySeconds":int, "strongBox":bool}`. `requireAuth` привязывает ключ к блокировке устройства; `strongBox` просто завершается ошибкой на устройствах без защищённого элемента, а не выдаёт более слабый ключ, чем запрошено. @return JSON `{ok, alias, hardwareBacked, error}`.

Пример

Создаёт (или заменяет) ключ AES-256-GCM в Android Keystore. Страница получает имя ключа, но никогда сам ключ. Большинство страниц используют AppMintKeystore.generateKey(alias, options), который берёт на себя работу с JSON.

Возвращает: JSON-строку сразу: {"ok":true,"alias":"vault","hardwareBacked":true} или {"ok":false,"error":"...","detail":"..."}. Требуется: включённые при сборке Защищённые ключи (шифрование).

function createVaultKey() {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') {
    alert('Secure keys work only in the app.');
    return false;
  }
  var options = {
    requireAuth: true,          // key works only after the user unlocks (fingerprint / PIN)
    authValiditySeconds: 30,    // ...and for 30 seconds after that unlock
    strongBox: false            // true = demand the separate secure chip (fails if missing)
  };
  var r = JSON.parse(window.WebToApk.generateKey('vault', JSON.stringify(options)));
  if (!r.ok) {
    alert('Could not create the key: ' + r.error);
    return false;
  }
  if (!r.hardwareBacked) console.log('Key is kept in software on this phone');
  return true;
}

Обычный ключ без правила разблокировки (параметры могут быть '{}'):

var r = JSON.parse(window.WebToApk.generateKey('notes', '{}'));

Создайте ключ один раз, при первой необходимости — повторный вызов generateKey заменил бы его и сделал бы осиротевшим всё уже запечатанное:

function ensureKey(alias) {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') return false;
  if (window.WebToApk.hasKey(alias) === 'true') return true;    // keep the existing key
  var r = JSON.parse(window.WebToApk.generateKey(alias, '{}'));
  if (!r.ok && r.error === 'disabled') {
    console.warn('Secure Keys was not turned on for this build');
  }
  return r.ok;
}

if (ensureKey('notes')) enableEncryptedNotes();

Потребуйте отдельный защищённый чип для ключа кошелька и прямо сообщите пользователю, если в телефоне его нет:

function createWalletKey() {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') return false;
  var r = JSON.parse(window.WebToApk.generateKey('wallet', JSON.stringify({
    requireAuth: true,
    authValiditySeconds: 10,
    strongBox: true
  })));
  if (r.ok) return true;
  if (r.error === 'strongbox-unavailable') {
    alert('The wallet needs a phone with a dedicated security chip.');
  } else if (r.error === 'bad alias') {
    alert('Key names may use letters, digits, _ - . only.');
  } else {
    alert('Could not create the wallet key: ' + r.error);
  }
  return false;
}

Примечания: псевдоним может содержать только буквы, цифры, _, -, . и не более 64 символов, иначе error:"bad alias". Повторный вызов с тем же псевдонимом ЗАМЕНЯЕТ ключ, и данные, запечатанные старым ключом, больше нельзя прочитать. Коды ошибок: disabled (Защищённые ключи не включены), bad options json, strongbox-unavailable (вы запросили strongBox:true, а в телефоне нет защищённого чипа или он старше Android 9), foreign_origin (вызвано со страницы, не принадлежащей приложению).

hasKey мост#

window.WebToApk.hasKey(alias: String): String

True, если ключ с именем [alias] существует в keystore этого приложения.

Пример

Сообщает, существует ли ключ Keystore с этим псевдонимом. Большинство страниц используют AppMintKeystore.hasKey(alias), который возвращает настоящее логическое значение.

Возвращает: СТРОКУ "true" или "false" сразу. Требуется: включённые при сборке Защищённые ключи (шифрование) (без них ответ всегда "false").

function vaultExists() {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return false;
  return window.WebToApk.hasKey('vault') === 'true';   // compare the string
}

if (!vaultExists()) {
  showCreateVaultScreen();
}

Выберите стартовый экран: разблокировать существующее хранилище или создать новое — и очистите запечатанные данные, ключ от которых исчез:

function chooseStartScreen() {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return showMyApp();
  var hasVault = window.WebToApk.hasKey('vault') === 'true';
  var hasData = localStorage.getItem('note') !== null;

  if (hasVault) {
    showUnlockScreen();
  } else if (hasData) {
    localStorage.removeItem('note');          // sealed with a key that no longer exists
    showCreateVaultScreen('Your old vault could not be opened, so it was cleared.');
  } else {
    showCreateVaultScreen();
  }
}

Один ключ на каждый вошедший аккаунт, создаваемый при первом входе этого аккаунта на этом телефоне:

function keyAliasFor(userId) {
  // aliases allow letters, digits, _ - . and at most 64 characters
  return 'user_' + String(userId).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 59);
}

function onSignedIn(user) {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return openAccount(user, null);
  var alias = keyAliasFor(user.id);
  if (window.WebToApk.hasKey(alias) !== 'true') {
    var r = JSON.parse(window.WebToApk.generateKey(alias, '{}'));
    if (!r.ok) return alert('Could not set up secure storage: ' + r.error);
  }
  openAccount(user, alias);
}

Примечания: ответ — строка, поэтому if (WebToApk.hasKey('vault')) всегда истинно — сравнивайте с === 'true'. Вы видите только ключи, которые это приложение создало через мост; собственные ключи оболочки скрыты.

isBiometricAvailable мост#

window.WebToApk.isBiometricAvailable(): String

«available» | «no_hardware» | «not_enrolled» | «unavailable». Android 10+ учитывает лицо, радужку и отпечаток пальца; Android 6–9 — только сканер отпечатков. «unavailable» = сенсор временно недоступен или проверку не удалось выполнить (например, в сборке не включено разрешение Fingerprint). См. [BiometricStatus].

Пример

Сообщает, может ли телефон прямо сейчас выполнить биометрическую разблокировку (лицо, радужка или отпечаток пальца). Проверьте перед показом кнопки разблокировки.

Возвращает: строку сразу: "available", "no_hardware" (нет сенсора), "not_enrolled" (сенсор есть, но ничего не зарегистрировано или не задана блокировка экрана) или "unavailable" (сенсор временно недоступен или проверку не удалось выполнить — например, в сборке не отмечено Fingerprint). Требуется: отмеченное при сборке Fingerprint — без него Android отказывается отвечать, и результат — "unavailable".

function setupFingerprintButton() {
  var btn = document.getElementById('fingerprint-button');
  if (!window.WebToApk || typeof window.WebToApk.isBiometricAvailable !== 'function') {
    btn.hidden = true;                      // browser: no fingerprint bridge
    return;
  }
  var state = window.WebToApk.isBiometricAvailable();
  if (state === 'available') {
    btn.hidden = false;
  } else if (state === 'not_enrolled') {
    btn.hidden = true;
    showHint('Add a fingerprint in your phone settings to unlock with it.');
  } else {
    btn.hidden = true;                      // no_hardware / unavailable
  }
}

Переключатель в настройках «Разблокировка отпечатком пальца», который неактивен с указанием причины, когда не может работать:

var BIO_REASONS = {
  no_hardware: 'This phone has no fingerprint or face sensor.',
  not_enrolled: 'Set up a fingerprint or face unlock in your phone settings first.',
  unavailable: 'The sensor cannot be used right now.'
};

function renderBioSwitch() {
  var sw = document.getElementById('bio-switch');
  var note = document.getElementById('bio-note');
  var inApp = window.WebToApk && typeof window.WebToApk.isBiometricAvailable === 'function';
  var state = inApp ? window.WebToApk.isBiometricAvailable() : 'not_in_app';
  sw.disabled = state !== 'available';
  if (sw.disabled) sw.checked = false;
  note.textContent = state === 'not_in_app' ? 'Available in the app.' : (BIO_REASONS[state] || '');
}

Проверьте снова, когда пользователь вернётся из настроек телефона, — вдруг он только что добавил отпечаток пальца:

document.addEventListener('visibilitychange', function () {
  if (document.visibilityState !== 'visible') return;
  if (!window.WebToApk || typeof window.WebToApk.isBiometricAvailable !== 'function') return;
  if (window.WebToApk.isBiometricAvailable() === 'available') {
    renderBioSwitch();
    showHint('Fingerprint unlock is ready. Turn it on in Settings.');
  }
});

Примечания: на Android 10 и новее проверка учитывает лицо, радужку и отпечаток пальца, поэтому телефон только с разблокировкой по лицу отвечает "available". На Android 6–9 смотрится только сканер отпечатков, поэтому телефон только с лицом там отвечает "no_hardware"; при allowDeviceCredential:true в authenticateBiometricEx пользователь всё равно может разблокировать PIN-кодом, графическим ключом или паролем телефона (Android 11+).

isKeyHardwareBacked мост#

window.WebToApk.isKeyHardwareBacked(alias: String): String

True, если [alias] находится в TEE или защищённом элементе, а не в программной среде. Стоит проверить, прежде чем обещать пользователю, что уничтожение ключа необратимо.

Пример

Сообщает, находится ли ключ внутри защищённого оборудования телефона (TEE или чип StrongBox), а не в программном обеспечении. Большинство страниц используют AppMintKeystore.isHardwareBacked(alias), который возвращает настоящее логическое значение.

Возвращает: СТРОКУ "true" или "false" сразу же. "false" также и тогда, когда ключа не существует. Требуется: включённая при сборке опция Защищённые ключи (шифрование).

function describeVault() {
  if (!window.WebToApk || typeof window.WebToApk.isKeyHardwareBacked !== 'function') return;
  var hw = window.WebToApk.isKeyHardwareBacked('vault') === 'true';
  document.getElementById('vault-info').textContent = hw
    ? 'Your key is locked in this phone\'s secure hardware.'
    : 'Your key is protected by software on this phone.';
}

Откажитесь хранить ценный секрет на телефоне, который может держать ключ только в программной форме:

function setUpWalletKey() {
  var W = window.WebToApk;
  if (!W || typeof W.isKeyHardwareBacked !== 'function') return false;
  var r = JSON.parse(W.generateKey('wallet', JSON.stringify({ requireAuth: true })));
  if (!r.ok) return false;
  if (W.isKeyHardwareBacked('wallet') !== 'true') {
    W.deleteKey('wallet');
    alert('This phone cannot keep the wallet key in secure hardware, so the wallet is off here.');
    return false;
  }
  return true;
}

Отправьте на сервер краткую сводку о безопасности устройства вместе со входом, чтобы он решил, что разрешить:

function deviceSecurity() {
  var W = window.WebToApk;
  var inApp = !!W && typeof W.isKeyHardwareBacked === 'function';
  return {
    secureKeys: inApp && W.isKeystoreEnabled() === 'true',
    hardwareKey: inApp && W.isKeyHardwareBacked('vault') === 'true'
  };
}

fetch('/api/sign-in', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: email, password: password, device: deviceSecurity() })
});

Примечания: пишите в интерфейсе «аппаратный», только когда это "true".

isKeystoreEnabled мост#

window.WebToApk.isKeystoreEnabled(): String

Включил ли создатель мост хранилища ключей для этого приложения.

Пример

Сообщает, включил ли автор мост Keystore для этого приложения. Большинство страниц используют AppMintKeystore.isAvailable(), который возвращает настоящий boolean.

Возвращает: СТРОКУ "true" или "false" сразу. Требуется: для вызова ничего не нужно; он отвечает "true" только если при сборке были включены Защищённые ключи (шифрование).

function keystoreReady() {
  return !!window.WebToApk &&
    typeof window.WebToApk.isKeystoreEnabled === 'function' &&
    window.WebToApk.isKeystoreEnabled() === 'true';
}

document.getElementById('vault-button').hidden = !keystoreReady();

Предлагайте «Запомнить меня», только если токен можно хранить в зашифрованном виде:

var canSeal = !!window.WebToApk &&
  typeof window.WebToApk.isKeystoreEnabled === 'function' &&
  window.WebToApk.isKeystoreEnabled() === 'true';

var remember = document.getElementById('remember-me');
remember.parentElement.hidden = !canSeal;

function afterSignIn(token) {
  if (!canSeal || !remember.checked) return;      // nothing stored in plain text
  if (window.WebToApk.hasKey('session') !== 'true') window.WebToApk.generateKey('session', '{}');
  var r = JSON.parse(window.WebToApk.encryptWithKey('session', btoa(token)));
  if (r.ok) localStorage.setItem('token', r.base64);
}

При тестировании отличайте «открыто в браузере» от «приложение собрано без защищённых ключей»:

function keystoreStatus() {
  if (!window.WebToApk) return 'browser: no app bridge';
  if (typeof window.WebToApk.isKeystoreEnabled !== 'function') return 'app without the keystore bridge';
  return window.WebToApk.isKeystoreEnabled() === 'true'
    ? 'Secure Keys on'
    : 'Secure Keys off in this build';
}

console.log('Keystore:', keystoreStatus());

Примечания: когда это "false", все остальные методы ключей отвечают "false", "[]" или {ok:false, error:"disabled"}. Скройте функцию вместо кнопки, которая не может работать.

listKeys мост#

window.WebToApk.listKeys(): String

JSON-массив всех псевдонимов ключей, созданных этой страницей.

Пример

Перечисляет псевдонимы всех ключей Keystore, созданных страницами этого приложения. Большинство страниц используют AppMintKeystore.listKeys(), который возвращает разобранный массив.

Возвращает: строку с JSON-массивом, отсортированным по имени, сразу же: '["notes","vault"]'. Требуется: включённая при сборке опция Защищённые ключи (шифрование) (без неё: "[]").

function showKeys() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return;
  var aliases = JSON.parse(window.WebToApk.listKeys());
  document.getElementById('keys').textContent =
    aliases.length ? aliases.join(', ') : 'No keys yet';
}

Экран настроек «Ключи безопасности», где перечислен каждый ключ, место его хранения и кнопка «Удалить»:

function renderKeyManager() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return;
  var list = document.getElementById('key-list');
  list.innerHTML = '';
  JSON.parse(window.WebToApk.listKeys()).forEach(function (alias) {
    var hw = window.WebToApk.isKeyHardwareBacked(alias) === 'true';
    var li = document.createElement('li');
    li.textContent = alias + (hw ? ' (secure hardware) ' : ' (software) ');
    var del = document.createElement('button');
    del.textContent = 'Delete';
    del.onclick = function () {
      if (!confirm('Delete "' + alias + '"? Data sealed with it is lost for good.')) return;
      window.WebToApk.deleteKey(alias);
      renderKeyManager();
    };
    li.appendChild(del);
    list.appendChild(li);
  });
}

При выходе из аккаунта уничтожьте все ключи отдельных аккаунтов (с именами user_...), чтобы ничего из сохранённого на телефоне нельзя было прочитать снова:

function forgetAllAccounts() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return 0;
  var removed = 0;
  JSON.parse(window.WebToApk.listKeys()).forEach(function (alias) {
    if (alias.indexOf('user_') === 0 && window.WebToApk.deleteKey(alias) === 'true') removed++;
  });
  return removed;
}

document.getElementById('sign-out').addEventListener('click', function () {
  var n = forgetAllAccounts();
  console.log('Destroyed ' + n + ' account key(s)');
  signOut();
});

Примечания: В списке только ключи, созданные через мост; внутренние ключи самого приложения никогда не показываются. Также возвращает "[]", если вызван со страницы, не принадлежащей приложению (другой сайт, загруженный в приложении).