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