Post quantum client core for React Native apps on the Quantova network. Keys are derived and transactions are signed with ML-DSA-65 on the device through the Rust core QCore.rs, linked as native code on Android and iOS with no wasm and no JavaScript cryptography.
npm install @quantovainc/react-native-qcore
cd ios && pod installAndroid needs minSdk 24 or later and iOS needs 13.0 or later. The prebuilt native libraries ship with the package and their hashes are listed in SHA256SUMS, which npm run verify:binaries checks.
Apps that hold user funds should sign through secureQCore. The seed is kept in the Android Keystore or the iOS Keychain, bound to this device and never backed up. Once stored it is never handed to JavaScript again, apart from the recovery phrase that revealPhrase returns after a fresh unlock.
import { secureQCore, SigningSession } from '@quantovainc/react-native-qcore';
secureQCore.createSeed('main');
const from = secureQCore.address('main', 0n);
const session = SigningSession.open([knownPayee]);
session.checkTarget(to);
const nonce = await session.slot(chainId, genesisHash, from, reportedNonce, null, false);
const signed = await secureQCore.signTransfer('main', session, 0n, to, '5', nonce, '1', chainId, validUntil, head, genesisHash);- On Android the key that wraps the seed requires user authentication within the last 15 seconds and is held in StrongBox where the device has it. On iOS the seed is a Keychain item that requires user presence and is readable only while a passcode is set.
- A seed held in memory is wiped after two minutes of no use and whenever the app goes to the background.
revealPhraseasks the user to authenticate again every time and returns the 25 word recovery phrase as bytes, which the app should clear withwipeBytesonce shown.- The vault signs only at a nonce slot reserved from a
SigningSessionopened by this module. Every session in the app shares one nonce ledger, and so does every process of the app, so two open sessions never hand out the same nonce even when they run in two processes. Each process holds an operating system file lock while it reads the saved state of an account, changes it and writes it back, and it reads that saved state again before every slot, signature, outcome and release. On Android the lock is a file in the nonce folder and on iOS it is a file in Application Support. A lock file that is a symbolic link is refused. After it takes the lock a process checks that the file it locked is still the file at that path, and it checks this again before every write, so a lock file that is removed or replaced while it is held stops the write and nothing is signed. The ledger keeps each account under its chain id, the genesis hash of its network and its address, so a nonce used on one network neither blocks nor clears the same nonce on another, even when two networks share a chain id. Every call that reads or writes the nonce state, every session call and every vault signer, returns a promise and does its work on a background thread, never on the JavaScript thread. - The nonce ledger is kept on the device with one record per account and a sealed manifest that names the exact write of every record by its generation and by the random id of the process that wrote it, together with the write before it. The manifest is written before the records it names. A nonce that was signed once is never handed out again after a crash or a restart. When the app stops after the manifest is written and before a record is, the record left on the device is the write before, and the vault accepts it only together with a settling wait as in item 7, so the account is not blocked and no transaction signed before it can land twice. A record that matches neither of the two writes the manifest names, is damaged, or is not listed in the manifest at all blocks only its own account until that account is reset. A manifest that is missing, damaged, in an earlier format or can no longer be opened blocks every account until the nonce state of every account is reset. The one exception is a first save cut short before any record was written. Once the first save completes the vault keeps a marker in the Android Keystore or the Secure Enclave, and while that marker is absent and no manifest and no record exist nothing can have been signed, so the device key left behind is removed and the ledger starts empty. Nonce state saved by an earlier release is in such an earlier format.
- A write that records a signature seals the manifest with a new device key and then deletes every key before it, so a manifest put back from an older copy can no longer be opened and blocks every account. Writes that only reserve, release or record an outcome keep the current key, since putting one of them back only makes the ledger more careful. Every device key carries a counter, and before the new key is made the current manifest names it as the next key. When the manifest names one key and any other manifest key exists, the vault deletes the older keys and the named next key before it reads further, and it refuses to sign while one of them cannot be deleted. Any other newer key means an older manifest was put back after a newer one was written, and the ledger is then blocked until every account is reset. A failed deletion is never ignored, it stops the write and nothing is signed. On Android the keys are held in the Android Keystore and the nonce files live in the folder that is left out of backups. On iOS the manifest and every record carry an HMAC under a key that only a Secure Enclave private key derives, so neither can be made or changed by anyone who holds no such private key, and Secure Enclave keys never go into a backup. On a device without a Secure Enclave the vault refuses to keep nonce state and does not sign. Only the iOS simulator uses a Keychain key in its place.
- When the vault first meets an account that the ledger does not list and the gateway reports a nonce above zero, or when the gateway later reports a nonce above every nonce this device signed for the account, the account has history that this device did not sign. That happens after a recovery phrase is imported, after the app data is cleared and when another device signs for the same account. Such an account settles before the vault signs for it, and every vault signer refuses for it with an error that starts with
SETTLING. The wait is started and ended only by finality certificates thatsettleHistoryverifies against the genesis hash and the validator roster the app pins. The first certificate starts the wait at its height and must carry a block time no older than five minutes on the device clock. The wait ends once a later certificate is at leastmaxValidityBlocks()plus one blocks higher and at least 720 seconds have passed on the clock of the device that counts time since it started, which is the time those blocks take at five blocks per second. That count is kept across app restarts and starts again after a reboot. A later certificate whose block time runs ahead of the device time counted since the start by more than two minutes shows that the start was old, and the wait starts again from it. A head reported by the gateway and the wall clock of the device never end the wait. By then no transaction signed earlier can land. A report of a nonce higher still during the wait starts it again. An account whose first reported nonce is zero has no history and signs at once, as every account of a newly created seed does. The settling state is kept in the record and in the manifest, so a missing or damaged record blocks the account and after its reset it settles again.acknowledgeImportedHistoryends the wait for one account after the user authenticates again in the same way as forresetNonceState, for the case where the user knows nothing signed earlier is still pending. Resetting an account puts it back into settling on its next first use. A signature or an outcome is recorded only for an account that a slot created. - An account keeps at most 16384 nonces that are signed or reserved and not yet accepted, and the ledger keeps at most 4096 accounts across every process of the app. A nonce key is one to 128 printable ASCII characters from
!to~, so it holds no space and no control character. A call that would go past any of these limits or names another kind of key is refused before anything is signed. resetNonceStateclears the saved nonce state of one account, named by its chain id, the genesis hash of its network and its key, or of every account when no account is named. It asks the user to authenticate again in the same way asrevealPhrase. Reset an account only once you know which of its signed nonces have landed. Saved nonce state from an earlier format is refused and needs this reset.- On Android 9 and later
revealPhrase,resetNonceStateandacknowledgeImportedHistoryshow the system biometric prompt with the device credential allowed. On Android 7.0 to 8.1 they show the system screen that confirms the device PIN, pattern or password, and they go ahead only when a Keystore key that needs an authentication within the last five seconds then works. There each confirmation screen is started with its own request code, a second request is refused while one is waiting for the user, and each result answers only the request it belongs to and only once. Both need a device lock. - Every signer takes the current chain
headand refuses avalidUntilthat has already passed or lies more thanmaxValidityBlocks()blocks past it.boundedValidUntilgives a deadline inside that window. - Signing for a retired network name is refused.
nativeQCore exposes the same derivation and signing functions over a seed given as hexadecimal, together with the proof checks verifyFinality, verifyEvent, verifyTransaction, confirmTransaction and decodeEvent. The seed functions take a raw nonce and keep no session, so they are meant for tooling and tests and not for wallets.
Every signer takes the genesis hash of the network as its last argument, after the head, as 64 hex characters, and signs every transaction over that genesis hash together with its body, so a transaction signed for one network is never valid on another that reuses the chain id. verifyFinality and confirmTransaction take the genesis hash right after the chain id. Every attestation signs that genesis hash, so a finality certificate from another network that reuses the chain id and the validator keys never verifies. Take the genesis hash from the network your app pins and never from anything a node or gateway reports, such as node_info. A genesis hash that is not 64 hex characters, or that is all zeros, is refused. nativeQCore also offers boundedValidUntil, which gives a deadline inside the window that item 11 describes.
Each release is built from a tag on main by the release workflow, and the attestation of every published file names that tag and its commit.
Licensed under either of Apache License 2.0 or MIT license at your option. See LICENSE-APACHE, LICENSE-MIT and NOTICE.