JavaScript bridge API
Biometrics and the keystore
Fingerprint and face unlock, and hardware-backed keys the page can encrypt with but never read.
AppMintKeystore.decrypt helper#
window.AppMintKeystore.decrypt(alias, base64)
Example
Decrypts data made by AppMintKeystore.encrypt with the same key.
Returns: an object, at once (not a Promise): {ok:true, base64} (the plain data) or {ok:false, error, detail}. Needs: Secure Keys (encryption) turned on when you build.
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;
}For a file written with WebToApkFS.writeSealed, read it back with WebToApkFS.readSealed(path, alias) - same result shape.
Read back a sealed JSON object, unlocking first when the key needs it:
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;
}Open a list of sealed diary entries, keeping the ones that fail visible as "locked":
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'
};
});
}Notes: After deleteKey the data is gone for good; that is the point. A payload that was changed fails the GCM check. Other errors: bad base64, disabled, foreign_origin, unsupported.
AppMintKeystore.deleteKey helper#
window.AppMintKeystore.deleteKey(alias)
Example
Destroys a secure key permanently. Everything encrypted with it can never be read again - by anyone.
Returns: true (deleted) or false (no such key, or the feature is off), at once. Needs: Secure Keys (encryption) turned on when you build.
Overwriting a file does not erase it on flash memory. Encrypt it, then destroy the key - that is a real delete:
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.');
}"Erase everything" in settings: destroy every key this app made, then clear the page's storage:
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();
}Self-destruct the vault after too many wrong app PINs:
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.');
}Notes: There is no undo and no recovery. If isHardwareBacked(alias) was false, the key lived in software - say so before you promise the user an irreversible delete.
AppMintKeystore.encrypt helper#
window.AppMintKeystore.encrypt(alias, base64)
Example
Encrypts base64 data with a secure key. Store the result anywhere - it is useless without the key.
Returns: an object, at once (not a Promise): {ok:true, base64} (IV + ciphertext + tag) or {ok:false, error, detail}. Needs: Secure Keys (encryption) turned on when you build, and a key from 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 can be built on authenticateBiometricEx (see that example).
To write an encrypted FILE of any size, use WebToApkFS.writeSealed(path, base64, alias) - it encrypts chunk by chunk with this same key.
Seal a small JSON object (a note, a token, settings) and keep it in 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);Create the key the first time it is needed, then encrypt:
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}
}Notes: Max 4 MB per call (chunk too large). Errors: no-such-key, auth-required, key-invalidated (the screen lock changed - the key is gone), bad base64, disabled, foreign_origin, unsupported.
AppMintKeystore.generateKey helper#
window.AppMintKeystore.generateKey(alias, options)
Example
Creates (or replaces) an AES-256-GCM key in the Android Keystore. The key never leaves secure hardware; your page only uses its name.
Returns: an object, at once (not a Promise): {ok:true, alias, hardwareBacked} or {ok:false, error, detail}. Needs: Secure Keys (encryption) turned on when you build (see 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);
}A plain key, no unlock needed:
var r = window.AppMintKeystore.generateKey('notes'); // options default to {}Demand the separate secure chip (StrongBox). It fails instead of giving you a weaker key:
var r = window.AppMintKeystore.generateKey('wallet', { strongBox: true });
if (!r.ok && r.error === 'strongbox-unavailable') {
alert('This phone has no secure chip.');
}Notes: Alias: letters, digits, _ - ., up to 64 characters (error:"bad alias" otherwise). Using an existing alias replaces the key, and data sealed with the old one is lost. Other errors: disabled, bad options json, foreign_origin, unsupported (no bridge).
AppMintKeystore.hasKey helper#
window.AppMintKeystore.hasKey(alias)
Example
Tells you whether a secure key with this alias exists.
Returns: true / false, at once. Needs: Secure Keys (encryption) turned on when you build.
function startVault() {
if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return;
if (window.AppMintKeystore.hasKey('vault')) {
showUnlockScreen();
} else {
showCreateVaultScreen();
}
}
window.addEventListener('load', startVault);Make sure a key exists before the first save, creating it only once:
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();Clean up sealed data whose key is gone (deleted, or the app data was restored without it):
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);Notes: Answers false when Secure Keys is off, so check isAvailable() first if you need to tell "no key yet" from "feature not in this app".
AppMintKeystore.isAvailable helper#
window.AppMintKeystore.isAvailable()
Example
Tells you whether hardware-backed secure keys can be used in this app.
Returns: true / false, at once (not a Promise). Needs: Secure Keys (encryption) turned on when you build. The window.AppMintKeystore object is installed (before your first script runs) whenever Secure Keys or Native Folder Access (SAF) is on; isAvailable() answers for Secure Keys.
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();
});Hide every secure-vault control when the feature is not in this app:
window.addEventListener('load', function () {
var ok = !!window.AppMintKeystore && window.AppMintKeystore.isAvailable();
document.querySelectorAll('.needs-secure-keys').forEach(function (el) {
el.hidden = !ok;
});
});Tell "feature off" apart from "no key yet" when you decide which screen to show:
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();
});Notes: The helper is added after the page has loaded, so do not test for it in a script that runs while the page is still parsing - test in a click handler or after load. In a browser there is no window.AppMintKeystore; the check above returns false without throwing.
AppMintKeystore.isHardwareBacked helper#
window.AppMintKeystore.isHardwareBacked(alias)
Example
Tells you whether a key lives in the phone's secure hardware (TEE or StrongBox) rather than in software.
Returns: true / false, at once. false when the key does not exist. Needs: Secure Keys (encryption) turned on when you build.
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)';
}Only keep a high-value secret (a wallet seed, a recovery code) when its key is in secure hardware:
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.');
}List where every key lives, for a support report:
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()));Notes: generateKey also returns hardwareBacked right after it creates the key.
AppMintKeystore.listKeys helper#
window.AppMintKeystore.listKeys()
Example
Lists the aliases of every secure key your pages created.
Returns: an array of strings, sorted, at once, e.g. ['notes', 'vault']. [] when there are none or the feature is off. Needs: Secure Keys (encryption) turned on when you build.
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);
});
}Destroy every key on "Erase all my data", which makes everything sealed with them unreadable:
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;
}Keep one key per signed-in user and remove the keys of users who signed out:
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);
});
}Notes: Only keys made through the bridge are listed; the app's own internal keys are never shown.
authenticateBiometric bridge#
window.WebToApk.authenticateBiometric(callbackId: String, title: String, subtitle: String)
WebToApk.isBiometricAvailable() -> "available" | "no_hardware" | "not_enrolled" | "unavailable" WebToApk.authenticateBiometric(id, title, subtitle) Result is delivered to the same __webToApkAuth callback table the Google Sign-In bridge uses: 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');
Described by its group, Fingerprint / biometric bridge, rather than on its own.
Example
Shows the system fingerprint prompt with a title and subtitle. The simple form of authenticateBiometricEx (use that one for the PIN option, a custom Cancel label or "strong" biometrics).
Returns: nothing. The answer arrives later: the shell calls window.__webToApkAuth[callbackId](json) with '{"ok":true}' or '{"ok":false,"error":"<code>"}', then removes that entry. Needs: Fingerprint ticked when you build (without it: error:"permission_missing").
Register the callback FIRST, then call:
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);
});Confirm a risky action with the fingerprint first (an empty title shows the app's name):
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);
});Lock the app again when it comes back after more than a minute in the background:
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
});Notes: An empty title uses the app's name. A wrong finger does NOT end the prompt - the callback fires only on success, cancel or a real error. Error codes are the same as authenticateBiometricEx. This proves the phone's owner is present; it does not prove who they are to a server.
authenticateBiometricEx bridge#
window.WebToApk.authenticateBiometricEx(callbackId: String, optionsJson: String)
The full form. [optionsJson] accepts: title, subtitle, description, negativeButtonText, allowDeviceCredential (PIN/pattern/password fallback), strong (require BIOMETRIC_STRONG - use for anything payment-shaped). Errors are distinct codes, not one boolean. Before any prompt: no_hardware | none_enrolled | unavailable (the status check could not run - see isBiometricAvailable). From the Android 9+ system prompt: user_cancel | no_hardware | hw_unavailable | none_enrolled | lockout | lockout_permanent | no_device_credential | timeout | no_space | unable_to_process | vendor_error, and for a code Android adds later its own message text (or `error_<code>`). permission_missing when the build lacks the Fingerprint permission. On Android 7-8 (API 24-27, fingerprint only) an error is the phone's own message text as the OEM wrote it - not a fixed code.
Example
Shows the system fingerprint / face prompt with full options: PIN or pattern as a second way in, a custom Cancel label, and "strong" biometrics only.
Returns: nothing. The answer arrives later: the shell calls window.__webToApkAuth[callbackId](json) with '{"ok":true}' or '{"ok":false,"error":"<code>"}', then removes that entry. Needs: Fingerprint ticked when you build (without it: error:"permission_missing").
A small Promise wrapper you can reuse:
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));
});
}Unlock the app - fingerprint, face, or the phone's PIN/pattern:
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.');
}Confirm a payment - strong biometrics only, your own Cancel label:
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);
}Unlock a Keystore key made with requireAuth, then retry:
async function openVault() {
var r = await verifyUser({ title: 'Open vault', allowDeviceCredential: true });
if (r.ok) readSecret(); // AppMintKeystore.decrypt works for authValiditySeconds now
}Notes: Error codes: user_cancel, none_enrolled, no_hardware, hw_unavailable, lockout (wait and retry), lockout_permanent (the user must unlock the phone with PIN first), no_device_credential, timeout, permission_missing, unavailable, and rarely no_space, unable_to_process, vendor_error. On Android 7-8 the shell shows its own dialog and an error is the system's message text. allowDeviceCredential and negativeButtonText do not mix: with the PIN option the system draws its own button. A wrong finger does not end the prompt.
decryptWithKey bridge#
window.WebToApk.decryptWithKey(alias: String, base64Payload: String): String
Reverses [encryptWithKey]. Fails with `error:"no-such-key"` once the key has been deleted - the payload is then unrecoverable by design. @return JSON `{ok, base64, error}`.
Example
Decrypts data made by encryptWithKey with the same Keystore key. Most pages use AppMintKeystore.decrypt(alias, base64), which returns the parsed object.
Returns: a JSON string, at once: {"ok":true,"base64":"<plain data>"} or {"ok":false,"error":"...","detail":"..."}. Needs: Secure Keys (encryption) turned on when you build.
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;
}For a key made with requireAuth:true: on auth-required, show the unlock prompt, then decrypt again:
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;
}Show an encrypted photo: decrypt to bytes, then display it through a 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;
}Notes: Once the key is deleted (deleteKey) the data can never be decrypted again - by design. A changed or damaged payload fails (the GCM tag does not match); the error is then the platform's message, not one of the codes above. Other codes: bad base64, disabled, foreign_origin.
deleteKey bridge#
window.WebToApk.deleteKey(alias: String): String
Destroys [alias] permanently. Everything encrypted with it becomes undecryptable - including by this app, and by anyone holding the device.
Example
Destroys a Keystore key for good. Everything encrypted with it can never be read again - by you, the user or anyone holding the phone. Most pages use AppMintKeystore.deleteKey(alias), which returns a real boolean.
Returns: the STRING "true" (deleted) or "false" (no such key, Secure Keys off, or not the app's own page), at once. Needs: Secure Keys (encryption) turned on when you build.
This is the only real "secure delete" on a phone: overwriting a file does not erase it on flash memory, destroying the key does.
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.');
}Rotate a key: open the data with the old key, seal it with a new one, then destroy the old key:
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
}"Forget me on this phone" at sign-out: destroy the account's key, then drop its cached data:
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();
}Notes: Check isKeyHardwareBacked before you promise a user that deletion is irreversible - a software-only key is weaker.
encryptWithKey bridge#
window.WebToApk.encryptWithKey(alias: String, base64Plaintext: String): String
Encrypts base64 [base64Plaintext] with [alias]. Each call generates its own IV, so a large file is encrypted chunk by chunk - chunks are independent and may be decrypted in any order. @return JSON `{ok, base64, error}` where `base64` is `IV || ciphertext || tag`.
Example
Encrypts base64 data with a Keystore key. Most pages use AppMintKeystore.encrypt(alias, base64), which returns the parsed object.
Returns: a JSON string, at once: {"ok":true,"base64":"<IV + ciphertext + tag>"} or {"ok":false,"error":"...","detail":"..."}. Needs: Secure Keys (encryption) turned on when you build, and a key made with generateKey.
The input is base64. For text, turn it into UTF-8 base64 first:
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;
}Seal a whole object (a session, some settings) as JSON before you store it:
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);Encrypt a photo the user picks (binary data, up to 4 MB per call):
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);
}
});Notes: Each call uses a fresh IV, so the same text gives a different result every time. At most 4 MB per call (error:"chunk too large ..."); for bigger files encrypt chunk by chunk, or use WebToApkFS.writeSealed. Error codes: no-such-key, auth-required, key-invalidated (the user changed the screen lock - the key is gone for good), bad base64, disabled, foreign_origin.
generateKey bridge#
window.WebToApk.generateKey(alias: String, optionsJson: String): String
Creates (or replaces) an AES-256-GCM key called [alias]. @param optionsJson `{"requireAuth":bool, "authValiditySeconds":int, "strongBox":bool}`. `requireAuth` binds the key to the device lock; `strongBox` fails outright on devices without a secure element rather than handing back a weaker key than was asked for. @return JSON `{ok, alias, hardwareBacked, error}`.
Example
Creates (or replaces) an AES-256-GCM key in the Android Keystore. The page gets a name for the key, never the key itself. Most pages use AppMintKeystore.generateKey(alias, options), which does the JSON work for you.
Returns: a JSON string, at once: {"ok":true,"alias":"vault","hardwareBacked":true} or {"ok":false,"error":"...","detail":"..."}. Needs: Secure Keys (encryption) turned on when you build.
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;
}A plain key with no unlock rule (options may be '{}'):
var r = JSON.parse(window.WebToApk.generateKey('notes', '{}'));Create the key once, the first time it is needed - calling generateKey again would replace it and orphan everything already sealed:
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();Demand the separate secure chip for a wallet key, and tell the user plainly when the phone does not have one:
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;
}Notes: The alias may use only letters, digits, _, -, . and at most 64 characters, otherwise error:"bad alias". Calling it again with the same alias REPLACES the key, and data sealed with the old key can no longer be read. Error codes: disabled (Secure Keys not turned on), bad options json, strongbox-unavailable (you asked for strongBox:true and the phone has no secure chip, or it is older than Android 9), foreign_origin (called from a page that is not the app's own).
hasKey bridge#
window.WebToApk.hasKey(alias: String): String
True when a key called [alias] exists in this app's keystore.
Example
Tells you whether a Keystore key with this alias exists. Most pages use AppMintKeystore.hasKey(alias), which returns a real boolean.
Returns: the STRING "true" or "false", at once. Needs: Secure Keys (encryption) turned on when you build (without it the answer is always "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();
}Pick the start screen: unlock an existing vault, or set one up - and clear sealed data whose key is gone:
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();
}
}One key per signed-in account, made the first time that account signs in on this phone:
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);
}Notes: The answer is a string, so if (WebToApk.hasKey('vault')) is always true - compare with === 'true'. You only see keys this app created through the bridge; the shell's own keys are hidden.
isBiometricAvailable bridge#
window.WebToApk.isBiometricAvailable(): String
"available" | "no_hardware" | "not_enrolled" | "unavailable". Android 10+ counts face, iris and fingerprint; Android 6-9 the fingerprint sensor only. "unavailable" = the sensor is temporarily unusable, or the check could not run (e.g. the build did not enable the Fingerprint permission). See [BiometricStatus].
Example
Tells you whether the phone can do a biometric unlock (face, iris or fingerprint) right now. Check it before you show an unlock button.
Returns: a string, at once: "available", "no_hardware" (no sensor), "not_enrolled" (sensor, but nothing registered, or no screen lock set) or "unavailable" (the sensor is temporarily unusable, or the check could not run - for example the build did not tick Fingerprint). Needs: Fingerprint ticked when you build - without it Android refuses to answer and the result is "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
}
}A settings switch "Unlock with fingerprint" that is greyed out with the reason when it cannot work:
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] || '');
}Check again when the user comes back from phone settings, in case they just added a fingerprint:
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.');
}
});Notes: On Android 10 and newer the check counts face, iris and fingerprint, so a face-unlock-only phone answers "available". On Android 6-9 it looks at the fingerprint sensor only, so a face-only phone there answers "no_hardware"; with allowDeviceCredential:true in authenticateBiometricEx the user can still unlock with the phone's PIN, pattern or password (Android 11+).
isKeyHardwareBacked bridge#
window.WebToApk.isKeyHardwareBacked(alias: String): String
True when [alias] lives in the TEE or a secure element rather than in software. Worth checking before promising a user that destroying a key is irreversible.
Example
Tells you whether a key lives inside the phone's secure hardware (TEE or StrongBox chip) rather than in software. Most pages use AppMintKeystore.isHardwareBacked(alias), which returns a real boolean.
Returns: the STRING "true" or "false", at once. "false" also when the key does not exist. Needs: Secure Keys (encryption) turned on when you build.
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.';
}Refuse to keep a high-value secret on a phone that can only hold the key in software:
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;
}Send your server a small device-security summary with sign-in, so it can decide what to allow:
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() })
});Notes: Say "hardware" in your UI only when this is "true".
isKeystoreEnabled bridge#
window.WebToApk.isKeystoreEnabled(): String
Whether the creator enabled the keystore bridge for this app.
Example
Tells you whether the creator turned on the Keystore bridge for this app. Most pages use AppMintKeystore.isAvailable(), which returns a real boolean.
Returns: the STRING "true" or "false", at once. Needs: nothing to call it; it answers "true" only when Secure Keys (encryption) was turned on when you built.
function keystoreReady() {
return !!window.WebToApk &&
typeof window.WebToApk.isKeystoreEnabled === 'function' &&
window.WebToApk.isKeystoreEnabled() === 'true';
}
document.getElementById('vault-button').hidden = !keystoreReady();Offer "Remember me" only when the token can be stored encrypted:
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);
}Tell apart "opened in a browser" and "app built without Secure Keys" while you test:
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());Notes: When it is "false", every other key method answers "false", "[]" or {ok:false, error:"disabled"}. Hide the feature instead of offering a button that cannot work.
listKeys bridge#
window.WebToApk.listKeys(): String
JSON array of every key alias this page has created.
Example
Lists the aliases of every Keystore key this app's pages created. Most pages use AppMintKeystore.listKeys(), which returns the parsed array.
Returns: a JSON array string, sorted by name, at once: '["notes","vault"]'. Needs: Secure Keys (encryption) turned on when you build (without it: "[]").
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';
}A "Security keys" settings screen that lists each key, where it lives, and a Delete button:
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);
});
}On sign-out, destroy every per-account key (named user_...) so nothing cached on the phone can be read again:
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();
});Notes: Only keys made through the bridge are listed; the app's own internal keys are never shown. Also answers "[]" when it is called from a page that is not the app's own (another site loaded in the app).