XaultWallet.Core is a standalone .NET 10 class library with no UI dependencies. Other
applications can reference it directly to get the vault format, duress logic, and the
monero-wallet-rpc orchestration without the Avalonia desktop app.
Same caveat as the app: unaudited beta. Do not build anything holding real funds on this until it has had a professional security review. See SECURITY.md.
0.2.0 has breaking changes from 0.1.0: see CHANGELOG.md. 0.3.0 only adds API (local regtest detection); nothing existing changed. 0.5.0 has breaking changes: a password now opens a
WalletProfile(several wallets and an address book), changes are saved through aVaultSession, andChangeDaemonAddressis gone. See CHANGELOG.md. 0.6.0 only adds API: coin control onMoneroWalletService,WalletSecrets.FrozenKeyImagesand theXaultWallet.Core.Tornamespace. See CHANGELOG.md. 0.7.0 adds API: theXaultWallet.Core.Companionnamespace (the browser companion's protocol, transport and registration) andProcessHardening,InputTrustandUntrustedTextinXaultWallet.Core.Security. One behaviour changes:MoneroUri.TryParsecleans and shortens a link's description and recipient name.
VaultManager: create/load/unlock the two-slot vault file. Each slot holds one profile (WalletProfile: wallets + contacts). Every operation is symmetric: nothing distinguishes the decoy from the real profile, and no method reports which slot opened.Create(path, mainPassword, mainSecrets, duressPassword?, duressSecrets?, argon?): one wallet per profile; andCreate(path, mainPassword, mainProfile, duressPassword?, duressProfile?, argon?). Both reject an empty main password, a duress password equal to the main password, the same wallet (mnemonic or address) in both profiles, a half-specified duress profile, andWipeOtherSlotOnUnlockon the main profile (allArgumentException).Load(path)/Exists(path)Unlock(password)→UnlockResult(Profile, UpgradedFromLegacyFormat)for whichever slot the password opens, or null (Secrets= the profile's active wallet). A password "matches" only by authenticating a slot's AES-GCM tag; no plaintext comparison exists. Applies the opened slot's policy before returning: wipe-on-duress, and re-sealing as a v3 profile a 0.1 payload (v1) or a slot carried by a format upgrade. Every successful unlock also re-seals the opened slot (fresh nonce, the key already derived) and rewrites the file, so a duress unlock, which must write to wipe, and a normal one leave the same trace; the write is best-effort and silent. That converts a 0.2/0.3 payload (v2, read as a one-wallet profile) to v3 at the first unlock too, unless its v3 form no longer fits an old-format file, in which case the slot is left as it was.UpgradedFromLegacyFormatis true on the unlock that re-sealed a payload written by 0.1, for either slot alike; show the same notice for both.OpenSession(password)→ aVaultSessionfor whichever slot opens (the same policy asUnlock), or null. This is how changes are saved.ChangePassword(current, new): re-seals whichever slotcurrentopens undernew. Returns false on a wrong password; throwsArgumentExceptionifnewis empty or would also open the OTHER slot (neutral message).- Wipe-on-duress fires on any of these operations when the opened profile carries
WipeOtherSlotOnUnlock: the other slot becomes random bytes, the flag is cleared (re-sealed with the already-derived key, no extra Argon2 time), and<vault>.replaced-*copies beside the vault are shredded. It is best-effort and silent on unlock; a failed write never changes what the caller sees.
VaultSession(IDisposable): an open profile. ChangeProfile, thenSave()(orSaveAsync(): serializes on the calling thread, encrypts and writes in the background). A save re-reads the file and re-seals only this slot with the key derived at unlock; the other slot is written back byte for byte.CheckPassword(password)/ChangePassword(current, new)act on this slot only (false for the other slot's password, too). ThrowsVaultFullException(anIOException) when the profile no longer fits, withOldFormatset when upgrading the vault would make room; nothing is written. A save also refuses (IOException) to write into a file that no longer holds this session's slot (the vault was replaced while open).IsOldFormat: the file still has the 0.2/0.3 format;UpgradeFormat()/UpgradeFormatAsync()convert it, re-sealing this slot and carrying the other (see SECURITY.md → Upgrading to 0.5 before offering it).Dispose()zeroes the key and waits for a save in progress; a save after that throwsObjectDisposedException.VaultFile: the on-disk container: magicXVLT, format 2, two equal slots ofPaddedPlaintextBytes(256 KiB) each, randomized slot order,FileBytesin all.Deserializealso reads format 1 (0.2/0.3: 4 KiB slots), which stays format 1 (FormatVersion,PayloadCapacityofLegacyMaxPayloadBytes) untilUpgradeFormat()carries each old slot byte for byte into the start of a new one; a newer format is refused.Serializewrites the file's own format.WriteSlot(fresh salt),FillRandom,TryUnlock(payload + slot index), andTryOpen→OpenedSlot(payload, derived key,IsLegacyLayout= carried by an upgrade, forResealSlotwith a fresh nonce). Every slot of a format 2 file is tried both ways on every open, so a carried slot costs the same work. DisposeOpenedSlotpromptly.VaultCrypto: Argon2id key derivation (bounded params) + AES-256-GCM encrypt/decrypt,RandomBytes.PasswordStrength:Evaluate(password)→(StrengthLevel, bitsEstimate), discounting repeats, sequences, keyboard runs and very common passwords.MinimumAcceptedis the floor the app enforces.SecureBuffer: pinned, zero-on-dispose byte buffer for passwords/keys.PrivateFiles: owner-only file I/O on Linux/macOS (no-op on Windows):EnsureDirectory(0700, tightening an existing folder),OpenWrite/WriteAllBytes/WriteAllText(0600; an existing file is tightened before it is written),AppendAllText,Tighten, andTightenTree(startup sweep; never follows symlinks). The vault, settings and log writers all go through it.
The sealed payload (internal SlotPayload) is identical in shape for every slot:
{"v":3,"wallets":[{"id","name","type","network","mnemonic","seedOffset","address","viewKey","spendKey","restoreHeight","daemonAddress","ephemeralWalletPassword","subaddresses","labels","accountLabels","notes","frozen"}…],"contacts":[{"id","name","address","note"}…],"active":…,"wipeOther":…}.
(A wallet's kind is type; 0.1's real/decoy marker was kind, and nothing like it exists.) v1 and v2
payloads are read and become a one-wallet profile; a v newer than 3, or a profile without wallets,
is refused with InvalidDataException. frozen (the key images of frozen coins) is always written,
as [] when nothing is frozen, so the shape stays the same for every slot.
ProcessHardening(Windows):Enable()gives this process, and every child passed toTrack(process)(before or after), an access list that lets other programs of the user only query, wait for and end it (no opening for memory access or remote threads, no changing the list back; threads keep their default access and window hooks still work).Enabled,SelfProtected(whether Windows applied it to this process),IsSupported.InputTrust(Windows):IsAction(message),IsTrusted(message, sentByAnotherThread, origin)andIsFromDevice(sentByAnotherThread, origin): which key, button, wheel and pointer messages may act when Enhanced Security is on (device, system or UIAccess input only).UntrustedText: text from outside the app, as it may be shown.HasHiddenCharacters(text)(control, format, line and paragraph separator, private-use, unassigned characters and lone surrogates, by code point) andClean(text, maxLength)(those dropped, whitespace collapsed, cut between characters).
WalletProfile: what one password opens:Wallets,Contacts,ActiveWalletId(opens first),WipeOtherSlotOnUnlock(input when creating a duress profile),ActiveWallet, andOfOne(wallet)for a one-wallet profile.WalletSecrets: one wallet:Id,Name,Kind(WalletKind.Seed/Keys/ViewOnly),Network,Mnemonic+SeedOffset(seed),Address+ViewKey(+SpendKey) (keys, watch-only),RestoreHeight,DaemonAddress,EphemeralWalletPassword, and the wallet's ownSubaddressCounts(per account, so a restored wallet re-creates what it handed out),Labels("account/index"→ label),AccountLabels,TxNotesandFrozenKeyImages(the key images of frozen coins, re-applied every time the wallet opens).CanSpendis false for watch-only. Nothing in it says real or decoy.Contact:Id,Name,Address,Note.SeedOffsetPolicy:ForSeed(wasGenerated, userOffset)→ the offset that is safe to seal: empty for a generated seed, the user's offset byte-for-byte for an imported one.MoneroNetwork:Mainnet/Stagenet/Testnet.
MoneroWalletService: the high-level entry point most integrators want.new MoneroWalletService(binary, proxyAddress?)ornew MoneroWalletService(binary, WalletRpcOptions)GenerateNewSeedAsync(network, daemon)→ 25-word seed + restore height from the daemon's tip read before the seed exists, viaRestoreHeights.ForNewSeed(capped by the clock, minusGeneratedSeedRestoreMargin= 720), or 0 if the node is unreachable. The backend's ownership is checked beforecreate_wallet.ValidateWalletOpensAsync(secrets)→ opens a wallet once (from seed or keys) and returns its primary address. For keys it also proves they belong to the address:generate_from_keysdoesn't check, so the wallet is reopened, where wallet2's key check runs; a mismatch throws, naming the key. (ValidateSeedOpensAsyncis the same method under its old name.)OpenAsync(secrets)→ restores into a private session directory (shredded on close), then re-creates the accounts and subaddresses recorded inSubaddressCountsGetBalanceAsync(account)/GetPrimaryAddressAsync/GetHeightAsync/GetHistoryAsync(account)/RefreshAsync- Accounts and addresses:
GetAccountsAsync,NewAccountAsync,GetAddressesAsync(account)(withUsed),NewSubaddressAsync(account, label)→ (index, address) PrepareSendAsync(destinations, account, priority)→TransferResultwith the exact fee,TxKeyandTxMetadata; builds the signed tx WITHOUT broadcasting (up toMaxDestinations= 15 recipients; a transaction has at most 16 outputs). Discarding it cancels the send.PrepareSendAsync(address, amountXmr, priority)is the one-recipient form.RelaySendAsync(txMetadata)→ tx hash. Broadcasts a prepared tx; its fee cannot change.PrepareSweepAllAsync(address, account, priority)→SweepAllResult(parallel lists; relay each metadata entry)SendAsync(address, amountXmr, priority): builds AND broadcasts in one step (no review); prefer prepare/relay- Proofs:
GetTxKeyAsync(txid)(throws if the backend returns no key) /CheckTxKeyAsync(txid, txKey, address);SignMessageAsync(message)(main address) /VerifyMessageAsync(message, address, signature);GetReserveProofAsync(amount?, account, message)(no amount proves everything the wallet holds) /CheckReserveProofAsync(address, message, proof)→ (good, total, spent) - Coin control:
GetCoinsAsync(account)→ the account's unspentOwnedOutputs (amount, key image, tx hash, block height, subaddress, frozen, unlocked), largest first;SetFrozenAsync(keyImage, frozen)freezes or thaws one coin in the running backend;ApplyFrozenAsync(keyImages)freezes every coin of the list the backend has found so far (a coin not scanned yet is frozen on a later call) and returns how many are frozen now. The backend starts with nothing frozen on every open, so call it after each refresh and right beforePrepareSendAsync. SetDaemonAsync(daemon)(switch node live, URL validated; trusted only when it is on this computer),RescanSpentAsync(sends every key image to the node: refused unlessNodeIsLocal),GetKeysAsync→ (mnemonic, viewKey, spendKey)BackendExited: the wallet-rpc child died underneath an open walletIsLocalTestChain: the open wallet syncs from a private regtest chain on this machine (seeMoneroDiagnostics.IsLocalTestChainAsync); label it as such rather than as its address networkCloseAsync/DisposeAsync: always dispose; this shreds the session directory.
MoneroProcessManager: lower level: launchesmonero-wallet-rpcon a random loopback port with per-session random Digest credentials (written to a0600--config-filein a0700session directory, shredded once ready, never on argv),--log-fileand--shared-ringdb-dirinside that directory, andWorkingDirectoryset to it.EnsureBackendIsOurs()throws unless the child is alive and owns every listener that could answer on its port (Linux, Windows; fail-closed: "can't tell" is a no). It runs when the server first answers and again right before the seed is sent; call it before any other call that carries a secret. A 401 during readiness is a hard failure. The binary must be a fully-qualified path (ExecutableLocator.EnsureLaunchable). On Windows the child is tied to the parent with a kill-on-close Job Object.ShredOrphanedTempDirs()sweeps leftovers from a crashed session; call once at startup under a single-instance guard. Each launch asks the node whether it is a local test chain; if so (IsLocalTestChain), it passes--allow-mismatched-daemon-version, which regtest needs.WalletRpcOptions:ProxyAddress("host:port" SOCKS for daemon traffic, e.g. Tor) andAllowMismatchedDaemonVersion(testing only: forces the flag for any node. A localmonerod --regtestnode doesn't need it; it is detected).MoneroRpcClient: thin JSON-RPC client (hand-built envelope; omits nullparams). Credentials are offered only to aDigestchallenge and never through a proxy. Errors areMoneroRpcException(Code;Message= method + backend error, safe to show and log;Diagnostics= redacted request/response, kept out of logs because it can identify a wallet).AtomicToXmr/XmrToAtomic(range-checked).XmrAmount:TryParse(text, out xmr, out error): culture-independent;.or,is always the decimal point, never grouping, so ambiguous input can only parse lower than intended.LooksThousandsGrouped,Format(invariant, up to 12 decimals).BlockHeight:TryParse(text, out height): plain digits, or groups of three behind one kind of separator (3,150,000,3.150.000,3 150 000); anything else (3150000.0,31,50,000) is refused.RestoreHeights:ApproximateTip(network, now): monero's clock-based chain estimate (wallet2::get_approximate_blockchain_height, ported from upstream).ForNewSeed(reportedTip, network, now)=min(reportedTip, estimate) − SafetyMargin(720), floored at 0: a node can't push a new seed's start past the clock.ExecutableLocator:FindOnPath(fileName, pathVariable)→ absolute path or null (skips relative entries).ResolveConfigured(text, pathVariable): keeps a full path, looks a bare name up on PATH, passes anything else through unchanged.EnsureLaunchable(path)throwsFileNotFoundExceptionunless the path is fully qualified and exists.MoneroAddress:Problem(address, network)→ null or a human-readable reason (charset/length/prefix only).MoneroUri:monero:payment links.Build(MoneroPaymentRequest)(address, optionaltx_amount,tx_description,recipient_name);IsUri(text);TryParse(uri, out problem)→MoneroPaymentRequestor null with a reason. Strict: an amount is digits with an optional.and up to 12 decimals (1,5is refused, never read as 1.5 or 15), and links withtx_payment_idor several addresses are refused. The description and recipient name come from whoever made the link: they are cleaned (UntrustedText.Clean) and shortened toMaxDescriptionLength(200) andMaxRecipientLength(100).DaemonAddress: the one definition of a valid node URL.IsLoopback(address):localhostor a loopback IP (127.0.0.0/8,::1).MoneroDiagnostics:ProbeWalletRpcAsync(binaryPath)(runs--version; same full-path rule as a launch),ProbeDaemonAsync(daemonUrl, proxy)(GET/get_height, same route as wallet-rpc: SOCKS or direct),IsLocalTestChainAsync(daemonUrl, proxy): true only for a loopback node whose/get_inforeportsnettype: "fakechain"(whatmonerod --regtestruns). A remote node is never asked; any failure (unreachable, not JSON, an odd answer) isfalse. 5 s timeout.SecretRedactor: structural JSON redaction of seeds, passwords, keys and signed-tx blobs.
WalletRpcInstaller:new WalletRpcInstaller(installRoot, proxyAddress?);InstallAsync(progress?, ct)→InstalledWalletRpc(Path, Version, VersionLine): fetchesOfficialHashListUrl(getmonero.orghashes.txt), verifies its clearsigned OpenPGP signature againstMoneroSigningKeys.Trusted, picks this platform's CLI archive from the signed list, downloads it fromOfficialDownloadBase, requires the signed SHA-256, extracts onlymonero-wallet-rpc, runs--version, and moves it to<installRoot>/v<version>/(older versions removed). Every failure is aWalletRpcInstallExceptionwith a message fit to show, and leaves nothing installed.FindInstalled(installRoot)→ the installed binary, or null.InstallProgress(Stage, BytesDone, BytesTotal)reportsInstallStage.MoneroSigningKeys:Trusted: binaryFate's RSA keys, read from the copy of Monero'sbinaryfate.ascembedded in the assembly and accepted only if they are exactly the pinnedBinaryFatePrimaryandBinaryFateSubkeyfingerprints.MoneroReleaseList:Parse(signedLines)→MoneroCliArchive(FileName, Platform, Version, Sha256)for the CLI builds;Select(signedLines, platform);CurrentPlatform()/PlatformName(os, arch)(win-x64,linux-x64,linux-armv8,mac-armv8…);WalletRpcFileName(platform).PgpRsaKey(a key'sFingerprint) andSignatureCheckExceptionare public for those; the OpenPGP reader itself is internal.
TorInstaller:new TorInstaller(installRoot, proxyAddress?);InstallAsync(progress?, ct)→InstalledTor(Path, Version, VersionLine): asks Tor Project's update channel which Tor Browser release is current (accepted only as a plain version number, at leastMinimumVersion= 15.0, and not older than a Tor already installed), verifies that release'ssha256sums-signed-build.txtagainst its detached signature withTorSigningKeys.Trusted, downloads this platform's Tor Expert Bundle, requires the signed SHA-256, unpacks only what tor needs, runs--version, and moves it to<installRoot>/v<version>/. Every failure is aTorInstallExceptionand leaves nothing installed.FindInstalled(installRoot)→ the installed binary (TorFileName), or null.TorProcess(IAsyncDisposable):new TorProcess(torBinary, dataDirectory, socksPort = 0);StartAsync(progress?, timeout, ct)runs tor as a SOCKS-only client (no relay, no control port, every system torrc ignored) and reportsTorBootstrap(Percent, Summary)until it is connected;SocksAddress(127.0.0.1:<SocksPort>) is whatWalletRpcOptions.ProxyAddresstakes;ExitedandHasExitedsay when it died;StopAsync/DisposeAsyncend it (it is tied to the parent process either way).TorDiagnostics:ProbeTorAsync(binaryPath)runs--version(full paths only, like every launch).TorReleaseList:CurrentPlatform(),ParseCurrentVersion(json)(refuses anything but a plain version),Select(signedLines, platform, version)→TorBundleArchive(Platform, Version, FileName, Sha256).TorSigningKeys:Trusted: the Tor Browser Developers key embedded in the assembly, accepted only for the pinnedTorBrowserPrimaryandTorBrowserSigningSubkeyfingerprints; only the signing subkey may sign.
The browser companion and monero: links (docs/BROWSER-COMPANION.md).
CompanionProtocol: version 1 of the messages.Parse(utf8Json, via?, out error)→CompanionRequest(Id, Type, Via, Uri, Origin, Label)or null and aCompanionError(Code, Message)(codes inCompanionErrors). Strict: one JSON object,"v": 1, an id of 1 to 64 letters, digits,-or_, a knowntype(status,show,pay,receive), no field twice, at mostMaxRequestBytes; apayneeds amonero:link that passesCleanPaymentUri(at most 4096 characters, nothing hidden in it);originis reduced toscheme://host[:port](ASCII host, an IPv6 address in brackets) byNormalizeOrigin; alabelis cleaned byCleanLabel(at most 40). Withviagiven (the native host's own knowledge of its caller) a"via"field in the request is ignored; without it, it is required (the local channel).Serialize(request),Ok(id, body?),Fail(id, error),RebuildReply(reply, request)(the reply as the browser gets it: only the fields of that kind of request, each checked; null when it isn't a usable answer),TryReadId(json),HostOf(origin),IsInsecureOrigin(origin)(a plain http page off this computer).CompanionErrors.Alllists every code,watch-onlyamong them.NativeMessaging: the browsers' framing (ReadAsync: null at a clean end,InvalidDataExceptionpastMaxIncomingBytesor on a cut message;WriteAsync).CompanionWire: one line per message on the local channel (ReadLineAsync(stream, maxBytes),WriteLineAsync).CompanionEndpoint:ForDataDirectory(dir)→ where the app listens (PipeName: a named pipe on Windows, from the user, session and folder; a Unix socket in$XDG_RUNTIME_DIRor the data folder elsewhere);CreateServer(maxInstances, first)(on Windows the first instance fails if the name is taken),CreateClient(),RemoveLeftoverSocket(),IsUsable(false where a socket's path would be too long). Clients usePipeOptions.CurrentUserOnly; on Windows the server's access list allows only the user and refuses network logons.CompanionClient:SendAsync(endpoint, request, connectTimeout, replyTimeout)→ the reply, or null when nothing listens; on Windows it lets the answering process take the foreground.NativeHost:TryIdentifyCaller(args, out looksLikeHostLaunch)(Chrome's and Firefox's command lines, our extension ids only);new NativeHost(caller, forward, launchApp, startWait?, appRunning?);RunAsync(stdin, stdout);HandleAsync(message)(never throws; replies rebuilt byRebuildReply). Apayorshowfor an app that isn't running starts it withlaunchApp, and one that is running but busy is waited for (seeDetachedLauncher:Launch(exe)outside the browser's job or process group;AllowForeground(processId)).BrowserRegistration:HostName,ChromeExtensionIds,FirefoxExtensionIds;ManifestJson(browser, exe),ManifestPath(browser, paths),RegistryKey(browser),Status(browser, exe, paths, registry)→RegistrationState(NotRegistered,Registered,Elsewhere,Unsupported) andCheck(...)→RegistrationCheck(State, Target)(the program a registration leads to). Both never throw: what can't be read leadsElsewhere; the manifest's path must be exactly the executable (a relative or roundabout path, a linked manifest, a relative or network registry value lead elsewhere).Register,Unregister.BrowserPaths(Home, ConfigHome, DataHome, AppLocalData)(ForCurrentUser),IUserRegistryandWindowsUserRegistry(HKCU only; a value that isn't text throwsInvalidDataException).MoneroLinkRegistration:IsSupported(Windows, Linux),Status(never throws; a COM, DDE or other-verb handler beside the command leads elsewhere),CurrentHandler(what opens links now),Register(refuses a path with%or a quote on Windows),Unregister(leaves another program's, or another copy's, registration alone),WindowsUserChoiceOverrides(registry),WindowsCommand(exe).
Log:Initialize(dir),Info/Warn/Error. Thread-safe file logger. Never log secrets, or anything that differs between the two wallets of a vault (nodes, restore heights, send events).
MoneroWalletService/MoneroProcessManagerown a child process: always dispose (await using), including on failure paths, or you leak an RPC process and its session directory.- All async methods accept a
CancellationToken; cancellation stops in-flight RPC calls but still cleans up on dispose. - The vault file is written atomically (temp + fsync + rename); one process should own a vault at a time.
- Requires the external, user-verified
monero-wallet-rpcbinary and a reachablemonerod. Nothing Monero-cryptographic is reimplemented here by design.