Class SecureStorage
Biometric-gated secure storage backed by the platform keychain. Reading an entry prompts the user for biometric authentication; writing or deleting may or may not, depending on the platform.
Entries are bound to the current set of enrolled biometrics. If the user
adds a fingerprint, enrols a new face, or disables device security, every
stored entry is automatically invalidated and subsequent
get(String, String) calls fail with BiometricError.KEY_REVOKED. The
application must then re-prompt the user for the original value and
set(String, String, String) it again.
Use this for short, secret strings (auth tokens, refresh tokens, encryption keys). For larger data, encrypt with a key stored here.
Platform support
- iOS -- backed by Security.framework (
SecItemAdd/SecItemCopyMatching/SecItemDelete) withkSecAccessControlTouchIDCurrentSet. Sharing entries with App Extensions requires both theios.keychainAccessGroupbuild hint AND a call tosetKeychainAccessGroup(String)passing the same Team-ID-prefixed group identifier. - Android -- AES/CBC/PKCS7 ciphertext stored in
SharedPreferenceswith the key in theAndroidKeyStore, locked viasetUserAuthenticationRequired(true). TheBiometricPrompt(API 29+) orFingerprintManager(API 23-28) unlocks the cipher for one operation per prompt. - JavaSE simulator -- backed by
java.util.prefs.Preferences, gated on the same Biometric Simulation menu used byBiometrics. Useful for testing the round-trip andKEY_REVOKEDpaths without a device. - All other platforms -- this base class is returned as-is and acts
as a non-supporting fallback: every method completes with
BiometricError.NOT_AVAILABLE. Application code does not need platformifstatements.
-
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final intThe store answered, and there is nothing under that account.static final intstatic final intThe store could not be asked, so nothing is known about the entry. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionprotected static StringA name unique to this application, for a store the platform shares between applications.protected static StringapplicationNamespace(String preferred) The same identifier, for a port that knows the application beforeDisplaycan say.intentryState(String account) Whether an entry exists, as distinct from whether it can be read.protected static StringThe file name a port uses to gate the creation of one account, for the ports whose create-if-absent is a file.Quietly retrieves a previously-stored entry.Retrieves a previously-stored entry, prompting for biometric authentication.static SecureStorageReturns the platform-specific singleton owned by the current port.booleanQuietly removes an entry.Removes a previously-stored entry.booleanQuietly stores or overwrites an entry underaccount.Stores or overwrites a value for the given account.setIfAbsent(String account, String value) The entry is there.voidsetKeychainAccessGroup(String group) Configures the iOS keychain access group for sharing entries between the main app and its extensions.
-
Field Details
-
ENTRY_PRESENT
public static final int ENTRY_PRESENT- See Also:
-
ENTRY_ABSENT
public static final int ENTRY_ABSENTThe store answered, and there is nothing under that account.- See Also:
-
ENTRY_UNKNOWN
public static final int ENTRY_UNKNOWNThe store could not be asked, so nothing is known about the entry.- See Also:
-
-
Constructor Details
-
SecureStorage
protected SecureStorage()Subclasses are constructed by the port. Application code obtains the active instance viagetInstance().
-
-
Method Details
-
getInstance
Returns the platform-specific singleton owned by the current port. On ports that do not implement secure storage this returns a baseSecureStorageinstance whose methods reportBiometricError.NOT_AVAILABLE. -
get
Retrieves a previously-stored entry, prompting for biometric authentication. The returnedAsyncResourcecompletes with the value, or with aBiometricExceptionon failure (includingBiometricError.KEY_REVOKEDwhen biometrics have been re-enrolled since the entry was written). On the fallback base class this completes immediately withBiometricError.NOT_AVAILABLE. -
set
Stores or overwrites a value for the given account. On iOS the user is typically not prompted (Apple's keychain accepts writes without re-authenticating); on Android the user is prompted because the underlying cipher requires biometric authentication. On the fallback base class this completes immediately withBiometricError.NOT_AVAILABLE. -
remove
Removes a previously-stored entry. No authentication is required since deletion does not reveal the value. On the fallback base class this completes immediately withBiometricError.NOT_AVAILABLE. -
setKeychainAccessGroup
Configures the iOS keychain access group for sharing entries between the main app and its extensions. The argument must include the Team ID prefix (e.g.
"ABCDE12345.group.com.example.app"). Passnullor empty to clear. Ignored on non-iOS platforms and on the fallback base class.The
ios.keychainAccessGroupbuild hint must declare the same group in the app's entitlements for this to work. -
set
-
get
-
remove
Quietly removes an entry. Returnsfalseon the fallback base class. -
setIfAbsent
The entry is there. Stores a value only if this account has none, and reports what the store ended up holding.
The operation a first-time key needs. Reading, generating and storing as three steps is safe within one process and not between two:
synchronizedcovers threads in one VM, while an application can be opened from more than one -- Android components declared with their ownandroid:process, or simply two runs of a desktop build -- and both can see nothing stored, generate different keys, and each overwrite the other. The database is then encrypted with whichever key did not survive, and nothing can open it again.The return value is what makes racing callers agree: a caller that lost stores nothing and is handed the value that won, so both go on to open the database with the same key.
This default is the best a store with no create-if-absent of its own can do -- the check and the write are still two operations, so a second process can land between them, and what it prevents is the divergence rather than the race. A port whose store can do this in one step overrides it; iOS does, because the keychain's own add fails when the item exists.
Parameters
-
account: the account to create -
value: the value to store if there is none
Returns
the value now stored, which may be another caller's, or null if the store cannot say
-
-
applicationNamespace
A name unique to this application, for a store the platform shares between applications.
The mobile ports do not need this: an OS sandbox already separates one application's keychain or keystore from another's. A native desktop build has no sandbox -- its storage is a plain directory under the user account -- so two applications that ask for the same account name reach the same entry. For a managed database key that means one application reading another's key, and forgetting it in either one removing the other's only copy.
The package is what the installer, the store and the build all treat as the application's identity. A display name is the fallback because a build without a package still has one, though it is weaker: two vendors can both ship "Notes".
Returns
an identifier safe to embed in a storage name, never null and never empty
-
applicationNamespace
The same identifier, for a port that knows the application before
Displaycan say.The simulator is that case: it builds its store while the port is still coming up, so
Displaycannot answer yet -- and it has the launcher's main class in hand, which is where itspackage_namecomes from in the first place.Parameters
preferred: an identity the port already knows, or null to askDisplay
Returns
an identifier safe to embed in a storage name, never null and never empty
-
gateName
The file name a port uses to gate the creation of one account, for the ports whose create-if-absent is a file.
Derived from the account itself rather than from its hash, because a hash is not a name:
AaandBBhash alike, so two aliases would share one gate and whichever asked second could never create its key -- it would find no value of its own and no gate to take. The escape is the one#applicationNamespace()uses, so the result is reversible and two accounts that differ keep different gates.A name too long to be a file gets its first part plus a hash of the whole, which is the one place a hash is the right answer: the alternative is a name the filesystem refuses.
Parameters
account: the account being created
Returns
a file name, unique to this application and account
-
entryState
Whether an entry exists, as distinct from whether it can be read.
#get(String)cannot answer this: it returns null for an entry that is not there and for one it could not read, and a caller that treats those alike will eventually treat a store that is briefly unavailable as a store that is empty. Where that caller then writes -- a managed database key is the case this was added for -- it overwrites a key that was there all along, and the database encrypted under the old one can never be opened again.A port answers
#ENTRY_PRESENTfor an entry it can see even if it cannot decrypt it: the question is existence, not readability. The default is#ENTRY_UNKNOWN, which is the honest answer for a platform with no non-prompting store, and callers must treat it as "do not write".Parameters
account: the entry to ask about
Returns
one of
#ENTRY_PRESENT,#ENTRY_ABSENTor#ENTRY_UNKNOWN
-