tari-cipherseed
v0.2.0
Published
Tari's official CipherSeed recovery-phrase format and Ristretto account-key derivation, ported byte-for-byte to TypeScript -- encipher/decipher a 24-word recovery phrase and derive account keys from it, interoperable with the official Tari wallet daemon/A
Maintainers
Readme
tari-cipherseed
An unofficial, dependency-light TypeScript port of the Tari protocol's CipherSeed
recovery-phrase format and Ristretto account-key derivation — byte-for-byte compatible with the
official Tari wallet daemon, Aurora, and desktop wallet.
Given a 24-word recovery phrase (or fresh randomness), this package can encipher/decipher the
underlying seed and derive the same account/view-key pairs the Rust wallet would, using nothing
but @noble/hashes, @noble/ciphers, and a vendored Argon2d implementation (see
Vendored dependency) — no WASM build of the Tari codebase required. All 7
of upstream's mnemonic languages are supported (English, French, Italian, Spanish, Japanese,
Korean, Chinese Simplified).
Not affiliated with, published by, or reviewed by The Tari Project. See LICENSE for the full disclaimer. Verify cryptographic correctness independently before trusting this with real funds — see Testing below for how it's checked against the real Rust implementation.
Install
npm install tari-cipherseedUsage
import { createWalletSeed, importWalletSeed, deriveAccountKeys } from "tari-cipherseed";
// Create a new wallet
const { seed, mnemonic } = await createWalletSeed();
console.log(mnemonic); // 24 space-separated words
// Recover a wallet from its phrase
const recovered = await importWalletSeed(mnemonic);
// Derive account 0's owner (spend) key and view-only key
const { ownerSecret, viewSecret } = deriveAccountKeys(recovered.entropy, 0);API
createWalletSeed(language?: MnemonicLanguage): Promise<{ seed: WalletSeed; mnemonic: string }>— generate a new random seed and its 24-word mnemonic. Defaults to"english".importWalletSeed(mnemonic: string): Promise<WalletSeed>— parse and decrypt a mnemonic back into aWalletSeed. The language is auto-detected from the words themselves (matching upstream's defaultfrom_mnemonicbehavior — see Mnemonic languages), so you never need to say which of the 7 it's in. ThrowsInvalidRecoveryPhraseErroron a bad checksum, unknown word, mixed-language phrase, wrong word count, or MAC failure.isPlausibleMnemonic(mnemonic: string): boolean— cheap, synchronous shape/checksum check (word count, wordlist membership in some supported language, CRC32) that doesn't require the Argon2d pass — useful for live input validation before running the full (slower)importWalletSeed.seedToMnemonic(seed: WalletSeed, language?: MnemonicLanguage): Promise<string>— re-derive the mnemonic for an already-decrypted seed (e.g. for a "reveal recovery phrase" UI). Defaults to"english".randomWalletSeed(): WalletSeed— generate seed material without encoding it to a mnemonic.encipherSeed(seed: WalletSeed, passphrase?: string): Promise<Uint8Array>/decipherSeed(bytes: Uint8Array, passphrase?: string): Promise<WalletSeed>— the raw 33-byte enciphered blob, if you want to handle mnemonic encoding yourself.passphraseaccepts an explicitly empty string ("") — distinct from omitting it, which uses Tari's documented default — matching upstream, which allows an empty Argon2 password.serializeSeed(seed: WalletSeed): string/deserializeSeed(s: string): WalletSeed— cheap JSON-friendly (hex) encoding of aWalletSeedfor storage, distinct from the mnemonic/enciphered forms.deriveAccountKeys(entropy: Uint8Array, index: number): DerivedAccountKeys— derive{ ownerSecret, viewSecret }, each a 32-byte little-endian Ristretto scalar reduced mod the group order, for the given account index.bytesToWords(bytes, language?)/wordsToBytes(words, language?)— the mnemonic ⇄ byte codec on its own, for an explicit language (not standard BIP-39 packing — see Format notes). Both default to"english".detectMnemonicLanguage(words: string[]): MnemonicLanguage— the same auto-detectionimportWalletSeeduses internally, exposed directly. ThrowsUnknownMnemonicLanguageErrorif no single language is consistent across every word.WORDLIST(English) /WORDLIST_FRENCH/WORDLIST_ITALIAN/WORDLIST_SPANISH/WORDLIST_JAPANESE/WORDLIST_KOREAN/WORDLIST_CHINESE_SIMPLIFIED— the 2048-word lists, andMNEMONIC_LANGUAGES— all 7 language identifiers, in upstream's own enum order (which matters for how ambiguous single words get auto-detected — see below).- Lower-level:
DomainSeparatedHasher,keyManagerDomainHasher,KEY_MANAGER_DOMAIN,KEY_MANAGER_DOMAIN_VERSION,crc32— exposed for deriving a different key branch/label under Tari'sKeyManagerDomainthan the account/view-only pairderiveAccountKeyscovers.
The higher-level createWalletSeed/importWalletSeed/seedToMnemonic always use Tari's
documented default passphrase and don't take a passphrase argument — a phrase created elsewhere
with a custom seed passphrase will fail to decrypt via importWalletSeed (with a clear
InvalidRecoveryPhraseError, not a silent wrong result). Call decipherSeed(bytes, passphrase)
directly if you need to support one.
Mnemonic languages
All 7 languages upstream Tari supports are implemented: English, French, Italian, Spanish,
Japanese, Korean, and Chinese Simplified. Four of them (English, French, Italian, Spanish) store
their wordlist with diacritics already stripped and fold an imported word's accents off before
matching — so "légion" and "legion" both resolve to the same French word — the other three have
no Latin diacritics and don't need this.
importWalletSeed/isPlausibleMnemonic auto-detect the language the same way upstream's
MnemonicLanguage::detect_language does: for each word, compute every language it's valid in, then
accept the first candidate language every other word in the phrase is also valid in. Where a single
word is genuinely valid in more than one language's wordlist (e.g. "album" is both English and
Spanish), the tie is broken by trying languages in the exact order upstream's enum lists them
(chinese_simplified, english, french, italian, japanese, korean, spanish —
MNEMONIC_LANGUAGES in this package) — matching upstream's behavior exactly, not an arbitrary
choice on this port's part.
Known upstream-shared limitation: upstream's Rust source stores the Japanese and Korean wordlists in Unicode NFD (decomposed) form, and neither the Rust implementation nor this port normalizes an imported word before matching. A phrase this package (or the real Tari wallet) generates is safe to copy-paste back in, since it round-trips in the same form — but a Japanese or Korean phrase retyped through an input method or a tool that normalizes to NFC on the way through will fail to import on both. This isn't a gap this port introduces; it's faithfully reproducing a real fragility that exists in the upstream wallet today.
Format notes
- CipherSeed (version 2): a 33-byte blob —
version(1) ‖ [birthday(2) ‖ entropy(16) ‖ mac(5)] encrypted(23) ‖ salt(5) ‖ crc32(4)— encrypted with ChaCha20, keyed via Argon2d over the passphrase, reproducingbase_layer/common_types/src/seeds/cipher_seed.rsfrom Tari's Rust codebase byte-for-byte. - Mnemonic codec: 24 words encoding those 33 bytes. This is not standard BIP-39 (different
bit-packing, checksum, and wordlist handling) — it's Tari's own scheme, so don't reuse a
generic BIP-39 library for it. Every wordlist is confirmed byte-identical, same order, to Tari's
MNEMONIC_*_WORDSconstants (base_layer/common_types/src/seeds/mnemonic_wordlists.rs). - Key derivation:
derive_ristretto_key(crates/wallet/crypto/src/derive.rsintari-project/tari-ootle) — a domain-separated Blake2b-512 hash underKeyManagerDomain, label"derive_key", chainingentropy ‖ branch ‖ index (u64 LE)(each field individually length-prefixed by the hasher, not pre-concatenated), wide-reduced mod the Ristretto255 group order2^252 + 27742317777372353535851937790883648493. Branch strings ("account"/"view_only_key") matchKeyBranch::as_str().
Vendored dependency
Argon2d is provided by a vendored, minimally patched copy of hash-wasm's bundled build
(vendor/hash-wasm-patched/), not the npm package. The only change: upstream hash-wasm rejects a
zero-length Argon2 password, even though Argon2 itself (RFC 9106) defines an empty password as
valid input and hash-wasm's own internal computation never checks the length — the rejection was a
defensive JS-side guard with no algorithmic justification, and no way to bypass it from outside the
package. See vendor/hash-wasm-patched/README.md for the exact diff and how to re-apply it after a
version bump.
Testing
Three layers of tests:
- Self-consistency tests (
test/*.test.ts, excludinggolden-vectors*) — round-trip and invariant checks (create→import, encipher→decipher, determinism, key uniqueness, per-language mnemonic round-trips,WalletSeedshape validation, the empty-passphrase path) that don't depend on external vectors. - English golden vectors (
test/golden-vectors.test.ts) — checked byte-for-byte against output produced by the real, unmodified upstream Rust crates (tari_common_types,tari_crypto,tari_hashing) via a throwawaycargoharness, coveringencipherSeed/decipherSeed, mnemonic encoding, andderiveAccountKeysat multiple indices. - Non-English golden vectors (
test/golden-vectors-multilingual.test.ts) — the same already-verified enciphered bytes as the English vectors above, re-encoded into all 6 other languages via the real, unmodifiedtari_common_types::seeds::mnemonic::from_bytes— proving byte-exact interop per language, not just internal self-consistency.
This is what stands between a recovery phrase and either losing funds or a wallet that silently fails to interoperate with the official wallet daemon.
npm install
npm testLicense
BSD-3-Clause — see LICENSE, including the non-affiliation disclaimer.
