A single web page that opens a StaticBit wallet backup and shows the keys inside it.
It exists for one situation: the wallet app is gone, or broken, or will not start, and all you have left is the backup you made. Nothing about that situation should depend on us still being here, so the decoder is one HTML file with no build step, no server, no dependency to install and no network access of any kind. Download it, open it in a browser, read your keys.
The page: index.html in this repository, and the copy published from it on GitHub Pages.
Please do this rather than typing your password into a page you are viewing online:
- Open the page and save it to disk (
Ctrl+S, or right-click and "Save page as"). One file lands on your disk. That is the whole program. - Copy that file to a USB stick and carry it to a computer that is not on any network - Wi-Fi off, cable out.
- Open the file there by double-clicking it.
The page works exactly the same offline. That is the point of it being one file: there are no fonts, stylesheets, scripts or images for it to fetch, so nothing is missing when there is no network to fetch from.
This is a precaution about your machine and your browser, not about this page. The page itself sends nothing anywhere, and the next section tells you how to check that for yourself rather than take our word for it.
The wallet can hand you a backup in three shapes, and the page reads all three. Pick the one that matches what you have:
| You have | Choose | Notes |
|---|---|---|
A .sbwallet file |
Backup file | The file the wallet wrote when you saved a backup. Drag it onto the page, or click to browse. |
| A photo or screenshot of the backup QR code | QR code picture | PNG, JPEG, WebP or GIF. You can also paste a picture straight from the clipboard with Ctrl+V. |
| The text scanned out of that QR code | QR code text | A long run of capitals and digits starting with TNA11B. Line breaks introduced by copying are ignored. |
A screenshot of the wallet's QR screen is the easiest thing for the page to read. A photograph works too, as long as the whole code is in frame, in focus and not at a steep angle.
The password is the one you chose when you made the backup. It is not your wallet passcode, and it is not recoverable: nothing in the backup file, and nobody anywhere, can turn a forgotten backup password back into your keys. That is the property that makes a backup safe to keep.
Leave the field empty for a backup made without a password. Those exist only as an on-screen QR code, never as a file.
Each wallet in the backup comes out as an address, a key type, the secret itself, and whatever optional fields were stored with it: a mnemonic passphrase, a derivation path, a master address, a description. The secret is everything an XRPL wallet needs to import the account - this wallet, or any other software that speaks XRPL.
Treat what is now on your screen the way you would treat the words on a seed card. Close the tab when you are done, and clear whatever you copied out of it.
The page never just says "error". Every refusal names what failed and, importantly, says whether your password was even involved. The common ones:
- "That password did not open this backup." The bytes decrypted to something that failed the authentication check. Almost always a wrong password: check the keyboard layout, check Caps Lock, try an older password you might have used. Rarely it means a byte of the file has been altered - but suspect the password first.
- "This backup needs a password." The container is encrypted and the password field was empty.
- "This is not a StaticBit backup." The file does not begin with the four bytes
SBW1. Nothing was decrypted and your password was never used, so retyping it will not help. A backup is the.sbwalletfile itself - not a screenshot of the app, not an exported JSON, not a key file from another wallet. - "No QR code was found in this picture." The picture loaded, but no code could be made out in it. Retake it with the whole code in frame and in focus; glare across a printed code does this too. A screenshot beats a photograph every time.
- "The contents are damaged." The password was accepted - the authentication tag verified, so the bytes are exactly the ones that were written - but what came out is not a wallet list. Your password is not the problem here.
If a backup file will not open and you have another copy of it, try the other copy. A single flipped bit anywhere in the file is enough to make it refuse, by design.
You do not have to. Everything below is written so that it can be checked, and every claim has a way to falsify it.
index.html is the entire program: markup, stylesheet and script in one document, deliberately
not minified. Open it in any text editor, or use your browser's "View source". The code that
decodes your backup is a few hundred lines, with comments explaining what each field of the
container is and why.
There is no <script src>, no <link rel=stylesheet>, no @import, no web font, no image URL,
no fetch, no XMLHttpRequest, no WebSocket and no navigator.sendBeacon anywhere in the
file. Search for those strings yourself; the test suite in this repository does exactly that on
every run.
The page also enforces this on itself, in its own <head>:
Content-Security-Policy:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'
default-src 'none' denies every resource the page could load. connect-src 'none' denies every
outbound connection it could open - requests, sockets and beacons alike. So even a future edit
that tried to send your keys somewhere would be stopped by the browser, rather than by our good
intentions. There is deliberately no 'unsafe-eval', no external origin and no CDN; the two
'unsafe-inline' entries are what let a single file carry its own script and stylesheet.
If you want to watch it happen: open the browser's developer tools on the Network tab, decode a backup, and see that the request list stays empty.
No cookies, no localStorage, no sessionStorage, no IndexedDB, no writes to the URL. Closing
the tab is all the cleanup there is.
Key derivation, decryption and decompression are done by the browser's own WebCrypto (PBKDF2,
AES-GCM) and DecompressionStream. This page contains no hand-rolled cipher. What it does
contain is the container parsing: which bytes are the salt, which are the nonce, and what goes in
as associated data.
To read a QR code out of a picture the page prefers the browser's own BarcodeDetector. Firefox
and Safari do not have it, so for those browsers the page carries a copy of jsQR 1.4.0 (Apache
License 2.0, https://github.com/cozmo/jsQR) inside itself - unminified, with its license
reproduced in full, its provenance, and the checksum of the published file it was copied from
written out immediately above it in the source.
Verify that copy against upstream yourself:
npm pack jsqr@1.4.0
tar xzf jsqr-1.4.0.tgz
sha256sum package/dist/jsQR.js
# bc40c8a15196236b2314db0856f72ca0b49980cd5413b8c852a7349f5fee0859Compare that checksum with the one written in index.html, and diff the file against the bytes
between the BEGIN/END markers in the backup-decoder-qr-vendor block. The test suite
recomputes the same checksum on every run and fails if the vendored bytes have moved by one byte.
run-tests.mjs does not contain a second implementation of the decoder. It pulls the script
blocks out of index.html verbatim and executes those exact bytes under Node, so what passes
there is what a browser runs.
node run-tests.mjsNo dependencies and no npm install; it needs Node 18 or newer, for DecompressionStream. It
checks, among other things: that real containers produced by the wallet's own packer open
correctly; that a wrong password, a tampered header byte, a tampered ciphertext byte, a foreign
file and a decompression bomb are each refused with their own distinct message; that a QR code is
read back out of a generated picture; and the page-level claims above - no outbound anything, no
storage, no eval, the CSP as quoted, the source unminified.
Every vector the suite needs is committed here, so the whole run happens from a clone of this
repository and nothing reports as skipped. That includes vectors/known.sbwallet - the frozen
container the wallet's own BackupContainerTests opens - which is what turns "this page parses a
format" into "this page opens files the wallet actually wrote". If that file is missing the run
stops with an error instead of quietly making two fewer checks: a compatibility check that skips
itself is a green run that proves nothing.
SBWALLET_KNOWN_VECTOR moves where that one vector is read from, for checking a copy against
another. It cannot switch the checks off - a path that does not exist is an error too. A run from
inside a wallet checkout additionally compares the committed copy against the wallet's own and
fails if the two have drifted apart.
Enough detail to write your own decoder without reading ours. All of it is derived from the wallet's packer rather than from a design document, so this is what real files actually contain.
All multi-byte integers are big-endian.
"SBW1" (4) | version (1) | kdfId (1) | iterations (4) | salt (16) | nonce (12) | ciphertext (N) | tag (16)
| Field | Size | Meaning |
|---|---|---|
magic |
4 | ASCII SBW1, bytes 53 42 57 31 |
version |
1 | Currently 1 |
kdfId |
1 | 0 means no password; any other value means a password-protected container |
iterations |
4 | PBKDF2 iteration count. Present only when kdfId != 0 |
salt |
16 | PBKDF2 salt. Present only when kdfId != 0 |
nonce |
12 | AES-GCM nonce |
ciphertext |
N | The encrypted, compressed payload |
tag |
16 | AES-GCM authentication tag, after the ciphertext |
The associated data for AES-GCM is the whole header - everything before the nonce, which is 26
bytes (4 + 1 + 1 + 4 + 16) for a password-protected container. That is what makes a substituted
kdfId, version byte or salt byte detectable, instead of silently changing the result.
In WebCrypto the tag is passed to decrypt together with the ciphertext as a single buffer, so
ciphertext | tag goes in as it stands - there is no need to split it.
When kdfId = 0 the header is just "SBW1" | version | 0 (6 bytes) and the compressed payload
follows it immediately: there is no iterations, no salt, no nonce and no tag at all.
Nothing is encrypted, so nothing is authenticated either. The wallet only ever produces this form
as a QR code shown on screen, never as a file.
| Key derivation | PBKDF2-HMAC-SHA256 |
| Key length | 32 bytes |
| Iterations | read from the container's own iterations field, never from a constant |
| Salt | the salt field, 16 bytes |
| Password to bytes | UTF-8, no normalization |
| Cipher | AES-256-GCM |
| Nonce | 12 bytes |
| Tag | 16 bytes, after the ciphertext |
Reading the iteration count out of the file rather than assuming one is not optional: containers exist at several counts, and a decoder that hard-codes one fails on the others in a way that looks exactly like a wrong password.
Raw DEFLATE (RFC 1951), with no zlib wrapper. In .NET that is DeflateStream; in a browser it
is DecompressionStream('deflate-raw') and not 'deflate' - the latter expects a zlib header
and fails on this stream.
Packing order is JSON -> UTF-8 -> deflate -> encrypt. Unpacking is the reverse.
Bound the inflated size. A few kilobytes of hostile input can otherwise expand into gigabytes; this page refuses anything that wants to grow past about 4 MB, which is 1000 wallets of 4 KiB each plus the envelope.
JSON, camelCase field names, no indentation:
{"wallets":[{"keyValue":"...","classicAddress":"r...","keyType":0,"algorithm":"..."}]}Fields of a wallet entry, in declaration order: keyValue, mnemonicPassphrase,
classicAddress, keyType, algorithm, derivationPath, masterAddress, description, folder,
destinationTag. A field with no value is left out rather than written as null, so most wallets
carry only the first four; this page treats a missing key and an explicit null the same way.
keyValue, classicAddress, keyType and algorithm are required; the rest may be null.
keyValue is the secret itself - a seed, a mnemonic or a secret key, depending on keyType.
The contents of a backup QR code are the container encoded with Base45 (RFC 9285), not raw bytes:
both platform scanners hand back a string, and bytes above 0x7F do not survive that.
The alphabet, index equal to value:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:
(36 is a space, then $ % * + - . / :)
Encoding: a pair of bytes n = b0 * 256 + b1 is written as three characters, where
n = c0 + c1*45 + c2*45^2 - least significant digit first. A single trailing byte is written
as two characters in the same order. The encoded length is always a multiple of 3, except for an
odd byte count, where the remainder is 2.
Because every container starts with the same bytes, every StaticBit backup code starts with the
same characters: the pair S B (0x5342 = 21314) encodes as T N A, and the version and kdfId
bytes that follow produce the familiar TNA11B prefix. That is a useful "this looks like one of
ours" check and nothing more - the real check is parsing the header.
index.html the decoder - the whole program, and the only file that gets published
run-tests.mjs the test runner; extracts and executes the script blocks out of index.html
vectors/ backup containers produced by the wallet's own packer, plus their manifest
vector-gen/ the C# generator that writes vectors/ (needs a checkout of the wallet)
vendor/ qrcode-generator, used by the tests to build a QR picture to read back
Only index.html is published to GitHub Pages. The tests, the vectors and the generator stay in
the repository and never become part of the deployed page - a visitor has no use for them, and
backup containers have no business being fetchable from the same origin as the decoder. They stay
in the repository because that is where they do their work: publication is gated on the test run,
so a decoder that cannot open the vectors does not get published at all.
vectors/ holds real containers, not fixtures written by hand to match this page's own idea of
the format. They come in two kinds.
known.sbwallet is frozen. It is the same file the wallet's BackupContainerTests opens, byte for
byte, and it is the compatibility claim in this repository: a container the wallet packed, opened
here. It is never regenerated - a regenerated "frozen" vector would stop being evidence of
anything - and it is copied, not derived, when the wallet's copy is touched.
The rest are regenerated by vector-gen, which packs them with the wallet's own BackupContainer
rather than a second implementation of the format; a vector produced by a reimplementation would
only prove that two of our decoders agree with each other. That needs a checkout of the wallet and
is done by hand when the container format changes:
dotnet run --project vector-genThe generated files are committed, so nobody needs a wallet checkout to run the tests.
Nothing real. Every key, address, description and password in them was made up for the tests, and
says so in its own text: seeds read sEdV1DecoderVectorSeedOne1111111, addresses read
rDecoderVectorAddressOne111111111, and the passwords are correct horse battery staple and
пароль-Ω-🔐-ok. None of those strings is a decodable XRPL seed or address (they fail base58check)
and none corresponds to an account on any network. They are published deliberately, because they
are what lets somebody who does not trust this page check that it opens the real format.
StaticBit's own code here is released under the MIT license - see LICENSE.
That covers our code and only our code. index.html also contains a verbatim copy of jsQR
1.4.0, copyright Cosmo Wolfe, Jefff Nelson and the jsQR contributors, which is under the Apache
License 2.0 and stays there: somebody else's work cannot be relicensed by putting it in our file.
Its full license text, its provenance and its checksum are reproduced inside index.html
immediately above the vendored code, as section 4(a) of that license requires. Anything that
redistributes the page - including saving it and handing the file to somebody else - carries that
notice along with it automatically, because the notice lives inside the file.
Section 4(d) of the Apache license, which carries forward the contents of a NOTICE file, does not
apply: jsQR ships no NOTICE. Its repository root has a LICENSE and nothing else of the kind, so
the reproduced license text and attribution are the whole of the obligation.
MIT and Apache-2.0 are compatible in this direction - the page may be redistributed under the MIT terms for our part while the vendored block keeps its own - and the vendored block's notice stays in the file either way.
vendor/qrcode-generator-1.4.4.js is a copy of QR Code Generator for JavaScript,
copyright 2009 Kazuhiko Arase, under the MIT license, with its notice intact at the top of the
file. It is used only by the test suite - to draw a picture of a QR code that the page is then
asked to read back - and is not part of index.html or of the published page.