Skip to content

feat: add SLIP-39 wallet recovery - #34

Merged
Drincann merged 4 commits into
mainfrom
feat/slip39-wallet-recovery
Jun 10, 2026
Merged

feat: add SLIP-39 wallet recovery#34
Drincann merged 4 commits into
mainfrom
feat/slip39-wallet-recovery

Conversation

@Drincann

@Drincann Drincann commented Jun 10, 2026

Copy link
Copy Markdown
Owner

PR Type

Enhancement, Tests


Description

  • Add SLIP-39 wallet sharing

  • Restore wallets from shares

  • Validate CLI option combinations

  • Cover unit and E2E recovery


Diagram Walkthrough

flowchart LR
  A["Wallet mnemonic"]
  B["SLIP-39 split"]
  C["Printed shares"]
  D["Wallet create"]
  E["Recovered mnemonic"]
  A -- "show --slip-39" --> B
  B -- "threshold shares" --> C
  C -- "create --from-slip-39" --> D
  D -- "reconstructs" --> E
Loading

File Walkthrough

Relevant files
Tests
wallet-lifecycle.test.ts
Add E2E SLIP-39 wallet recovery coverage                                 

tests/e2e/wallet-lifecycle.test.ts

  • Adds extractSlip39Shares helper for CLI output.
  • Verifies wallet show --slip-39 emits shares.
  • Restores a wallet using two of three shares.
  • Confirms restored wallet derives matching address.
+45/-0   
slip39.test.ts
Test SLIP-39 split and recovery helpers                                   

tests/slip39.test.ts

  • Adds unit coverage for SLIP-39 splitting.
  • Verifies mnemonic recovery with sufficient shares.
  • Asserts incomplete share sets are rejected.
+19/-0   
Enhancement
create.mts
Restore wallets from SLIP-39 shares                                           

src/cli/commands/wallet/create.mts

  • Adds --from-slip-39 wallet creation mode.
  • Accepts repeated --share options.
  • Recovers mnemonic words from SLIP-39 shares.
  • Rejects incompatible mnemonic, entropy, and share options.
+40/-2   
show.mts
Display SLIP-39 backup shares                                                       

src/cli/commands/wallet/show.mts

  • Adds --slip-39, --threshold, and --shares.
  • Splits wallet mnemonic into SLIP-39 shares.
  • Prints threshold metadata and generated shares.
  • Validates share options and alias-only usage.
+33/-0   
slip39-wordlist.mts
Add SLIP-39 wordlist                                                                         

src/crypto/slip39-wordlist.mts

  • Adds the SLIP-39 1024-word wordlist.
  • Exports SLIP39_WORDS for share encoding.
+1026/-0
slip39.mts
Implement SLIP-39 crypto primitives                                           

src/crypto/slip39.mts

  • Implements single-group SLIP-39 share generation.
  • Implements share decoding, checksum, and validation.
  • Recovers BIP39 mnemonics from threshold shares.
  • Adds Shamir interpolation and SLIP-39 encryption helpers.
+396/-0 

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 Security concerns

Command-line secret exposure:
SLIP-39 shares are wallet recovery secrets, and the new wallet create --from-slip-39 --share ... flow accepts them directly as CLI arguments. This can leak shares through shell history and process inspection on multi-user systems.

⚡ Recommended focus areas for review

Secret Exposure

--share requires SLIP-39 recovery shares to be passed as command-line arguments. In normal shells these can be captured in shell history, process listings, terminal scrollback, or CI logs, which can expose enough material to recover a wallet. Consider accepting shares via an interactive prompt or stdin instead.

.option('--from-slip-39', 'Create a wallet from SLIP-39 shares', false)
.option('--share <share>', 'SLIP-39 share, can be specified multiple times', (value: string, previous: string[]) => {
  const list = Array.isArray(previous) ? previous : []
  list.push(value)
  return list
}, [] as string[])

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Security
Bound recovery work factor

iterationExponent is read from untrusted share text and directly controls the PBKDF2
work factor during decrypt. Reject unsupported or unexpectedly high exponents before
doing expensive recovery work to prevent a crafted share from causing a CPU denial
of service.

src/crypto/slip39.mts [67-72]

 const first = shares[0]
 if (first.groupThreshold !== 1 || first.groupCount !== 1 || first.groupIndex !== 0) {
   throw new Error('Only single-group SLIP-39 shares are supported')
 }
+if (first.iterationExponent > ITERATION_EXPONENT) {
+  throw new Error('Unsupported SLIP-39 iteration exponent')
+}
 
 for (const share of shares) {
Suggestion importance[1-10]: 8

__

Why: iterationExponent comes from untrusted SLIP-39 input and can significantly increase pbkdf2Sync cost during decrypt, creating a plausible CPU denial-of-service risk. Bounding it before recovery is accurate and security-relevant, though it may intentionally limit compatibility with higher-exponent shares.

Medium
Possible issue
Avoid shared option mutation

Avoid mutating the previous array because Commander may reuse the default option
value across parses in the same process. Return a new array so --share values cannot
leak between invocations or tests.

src/cli/commands/wallet/create.mts [35-39]

 .option('--share <share>', 'SLIP-39 share, can be specified multiple times', (value: string, previous: string[]) => {
-  const list = Array.isArray(previous) ? previous : []
-  list.push(value)
-  return list
+  return [...(Array.isArray(previous) ? previous : []), value]
 }, [] as string[])
Suggestion importance[1-10]: 6

__

Why: The --share option parser mutates the previous array, which can cause values to persist across parses if Commander reuses the default array. Returning a new array is a small but valid robustness improvement for CLI tests and repeated invocations.

Low
General
Reject conflicting duplicate shares

uniqueShares silently ignores duplicate share indices even if the duplicate has
different share data. Reject conflicting duplicates so tampered or mistyped shares
are not hidden by whichever copy appears first.

src/crypto/slip39.mts [320-330]

 function uniqueShares(shares: DecodedShare[]): DecodedShare[] {
-  const seen = new Set<number>()
-  const unique: DecodedShare[] = []
+  const byIndex = new Map<number, DecodedShare>()
   for (const share of shares) {
-    if (!seen.has(share.index)) {
-      seen.add(share.index)
-      unique.push(share)
+    const existing = byIndex.get(share.index)
+    if (existing !== undefined) {
+      if (!existing.value.equals(share.value)) {
+        throw new Error('Conflicting SLIP-39 shares with the same index')
+      }
+      continue
     }
+    byIndex.set(share.index, share)
   }
-  return unique
+  return [...byIndex.values()]
 }
Suggestion importance[1-10]: 5

__

Why: uniqueShares currently ignores duplicate indices even when their value differs, which can hide user mistakes or tampered shares. Rejecting conflicting duplicates improves validation clarity, but it is not likely to affect normal successful recovery paths.

Low

@Drincann
Drincann merged commit 3c8880a into main Jun 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant