Skip to content

BIP85: Add Codex32 as application 93' - #1958

Open
BenWestgate wants to merge 1 commit into
bitcoin:masterfrom
BenWestgate:codex32
Open

BIP85: Add Codex32 as application 93'#1958
BenWestgate wants to merge 1 commit into
bitcoin:masterfrom
BenWestgate:codex32

Conversation

@BenWestgate

@BenWestgate BenWestgate commented Sep 7, 2025

Copy link
Copy Markdown
Contributor

This allows wallets to derive codex32 secrets and codex32 shares from BIP-0032 root keys.

Summary of changes

Rationale

  • Mirrors the existing BIP-85 application for BIP-39.
  • Codex32 offers error correction, hand verification, identifiers, and secret sharing improvements vs BIP-39.
  • Deterministic generation produces auditable backups by avoiding reliance on local RNG, helping users who distrust device entropy.

Specification

  • Adds Application 93' to BIP-0085 using derivation path:
m/83696968'/93'/{profile}'/{payload_len}'/{index}'
  • Profile 0 uses BIP93's ms human-readable part and Master seed format
  • {payload_len} counts Bech32 payload characters
  • {index} serializes the six-character codex32 header into 30 bits
  • Threshold 0 derives secrets according to profile specific construction
  • A nonzero threshold derives one canonical initial share per path

Tests
Reference tests included in the reference bipsea implementation:
https://github.com/akarve/bipsea/pull/68/changes

Mailing List
Discussion: https://groups.google.com/g/bitcoindev/c/--lHTAtq0Qc

Status
Ready for renewed conceptual and approach review. This change is additive and does not modify existing BIP-85 behavior.

@jonatack jonatack added Proposed BIP modification PR by non-owner to update BIP content Pending acceptance This BIP modification requires sign-off by the champion of the BIP being modified labels Sep 8, 2025
@BenWestgate
BenWestgate marked this pull request as draft September 8, 2025 00:17
@BenWestgate
BenWestgate marked this pull request as ready for review September 8, 2025 00:26
@BenWestgate BenWestgate changed the title Add Codex32 (BIP-0093) as application 93' to BIP-0085 BIP85: Add Codex32 application 93' Sep 9, 2025
@BenWestgate BenWestgate changed the title BIP85: Add Codex32 application 93' BIP85: Add Codex32 as application 93' Sep 9, 2025
@akarve

akarve commented Sep 10, 2025

Copy link
Copy Markdown
Contributor

Documenting recent discussions:
@BenWestgate Please see my mailing list comments to your thread with suggestions and simplifications (path, byte extraction, idx, etc.). Regarding 1.4.0 the main thing is we want to warrant full compatibility (all features) up to the prior version and (just saw you reopened 68) a PR to the 1.3.0 client is probably the easiest way to achieve that. Lmk if anything is unclear.

@BenWestgate

BenWestgate commented Sep 12, 2025

Copy link
Copy Markdown
Contributor Author

Documenting recent discussions: @BenWestgate Please see my mailing list comments to your thread with suggestions and simplifications (path, byte extraction, idx, etc.). Regarding 1.4.0 the main thing is we want to warrant full compatibility (all features) up to the prior version and (just saw you reopened 68) a PR to the 1.3.0 client is probably the easiest way to achieve that. Lmk if anything is unclear.

It seems you'd like to consolidate some of the paths. There's a few ways to do this, if you have a favorite or one that immediately stands out as obvious let me know.

I'm thinking the identifier could be the bech32 encoding of the bip85 index, as the purpose of incrementing the index is to get new seeds, and BIP93 says "...the identifier SHOULD be distinct for all master seeds the user may need to disambiguate."

index = 0 -> identifier = qqqq, index = 1 -> identifier qqqp, and so on. A particular identifier could be selected by converting it to an integer {index} once index reaches 32^4, it can fall back to the default BIP-0032 fingerprint or roll over.

On byte extraction: I agree we should draw byte_length bytes and pad to a multiple of 5 bits with a CRC. The polynomials (1 << crc_len) | 3 is optimal for 1-4 bits.

If we output share indices still can use the current read one byte at a time method.

@jonatack jonatack left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinging @scgbckbone (who has been active on BIP85 review) for feedback.

@scgbckbone

Copy link
Copy Markdown
Contributor

Seems to me this is well over the BIP-85 application scope. As I understand it, BIP85 generates "a thing" from "a thing". Your application is generating "multiple things" from "a thing".

Why are you generating multiple initial shares via BIP85 ?

What I imagined BIP85 application should looks like after reading BIP93:

  1. way to generate secret share s from BIP-32 root seed (so that you can load other wallets with derived entropy). Something like this: m/83696968'/93'/{b93_index mapped to int -> s in this case}'/{byte_length}'/{index}'
  2. way to generate any non-secret share from BIP32 root seed. This, as per rationale, would allow users to generate 2nd (and only 2nd) share deterministically via BIP85, and not via RNG. All other shares should be derived according to BIP93 via interpolation. m/83696968'/93'/{b93_index mapped to int -> not s in this case}'/{byte_length}'/{index}'

** maybe even threshold should be part of the BIP32 derivation path, BUT I think not as it has no effect to the actual secret generated (it only affects checksum)

Assuming I'm not wrong in my "specualtion", why not just use m/83696968'/128169'/{num_bytes}'/{index}' to generate deterministic bytes from BIP-32 root seed for any share ?

@jonatack jonatack added the PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author label Oct 14, 2025
@BenWestgate

This comment was marked as outdated.

@jonatack jonatack removed the PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author label Oct 19, 2025
@scgbckbone

Copy link
Copy Markdown
Contributor

Then why not use that for BIP39 or any other application too?
Let users convert deterministic bytes into mnemonics or codex32 strings as they wish.
The point of a BIP85 application is to standardize how that entropy is consumed into a specific deterministic format.

agreed, rest my case here...

For example, BIP39 derives an entire mnemonic, not one word at a time.

this is bad comparison, as 12/24 words represent encoding of 16/32 bytes of entropy. While your approach creates multiple shares. Same as if I would create multiple 12 words seeds from 16 bytes of entropy.

this is imo ok (using code from your snippets whithout ever running it or reviewing it). My understanding is that each line in below snippet, generates just one share?

secret_share = bip85.derive_codex32(t=3, share_idx='s')
share_a = bip85.derive_codex32(t=3, share_idx='a'),
share_c = bip85.derive_codex32(t=3, share_idx='c'),
share_d = bip85.derive_codex32(t=3, share_idx='d'),

with what I have issue is this, where you just generating multiple shares (somehow):

# 2. generate `k` fixed non-"s" shares from root seed, interpolate according to BIP93
shares = bip85.derive_codex32(k=2)

The version brings it back within scope:

only k=0 is within the scope (imho)

Don't get me wrong, I'm not intending to block this BIP update. Updated version is much better. I'm only trying to figure out why is this needed & whether there is any advantage in what you're doing vs. what I'm doing. Here is my pseudo-code, to try to prove the point that nothing else than simple "one share generation" is needed here & rest can be left to BIP-93 interpolation:

CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
secret_share = <32 bytes secret loaded in HWW>
id = "cash"
threshold = 3
num_shares = 4
# use BIP85 to deterministically generate secret share "L" (or any other, up to specific wallet implementation)
share_l = bip85(secret_share).derive_codex32(t=threshold, id=id, share_idx='l')
shares = [sahre_l]
for i in range(num_shares - 1):  # -1 as share 'a' was already generated
    shares.append(bip93.interpolate([secret_share, share_l], CHARSET[i]))   

Above pseudo-code always generate the same shares.

@BenWestgate

This comment was marked as outdated.

@jonatack

jonatack commented Nov 4, 2025

Copy link
Copy Markdown
Member

@akarve thoughts here? (thanks!)

@akarve

akarve commented Nov 4, 2025

Copy link
Copy Markdown
Contributor

Yeah. Not to slow down the innovation here (and in @3rdIteration 's PRs) but my thinking is for me take on implementing both applications in the current reference implementation as Python protocols. My belief is that if we can come up with a standard duck typing interface for all BIP-85 applications that this—as yet non-existent—abstraction will stand the test of time. Of course anyone else in this thread is free to propose the shape of the protocol and even implement it without me. My experience is that we will need full unit tests and such for the protocol to be hard enough to stand the test of time. I'm volunteering to complete said protocol + implementation this quarter but if anyone wants to go first down this path they're welcome.

Protocol designs can go in this thread. The ultimate goal is we have a standard protocol/interface for BIP-85 applications and a standard "graph" that they all go through and boom out come the entropy products. In this way all applications use the same protocol and same core logic. This would benefit current and future applications as well as benefit PR-clearing speed (after the initial investment) once the protocol is in place. Said protocol will also resolve the "one-product/two-product" style debates happening in this PR because the quacking of the duck types will resolve such points as how many things turn into how many other things. I hope that makes sense.

@BenWestgate

This comment was marked as off-topic.

@BenWestgate

BenWestgate commented Nov 11, 2025

Copy link
Copy Markdown
Contributor Author

What I imagined BIP85 application should looks like after reading BIP93:

1. way to generate secret share `s` from BIP-32 root seed (so that you can load other wallets with derived entropy). Something like this: m/83696968'/93'/{b93_index mapped to int -> `s` in this case}'/{byte_length}'/{index}'

2. way to generate any non-secret share from BIP32 root seed. This, as per rationale, would allow users to generate 2nd (and only 2nd) share deterministically via BIP85, and not via RNG. All other shares should be derived according to BIP93 via interpolation. m/83696968'/93'/{b93_index mapped to int -> `not s` in this case}'/{byte_length}'/{index}'

** maybe even threshold should be part of the BIP32 derivation path, BUT I think not as it has no effect to the actual secret generated (it only affects checksum)

After Heavy thinking, I concur the best solution is to generate single codex32 strings in a manner very similar to what @scgbckbone proposed.

I have rewritten the BIP text to match this design, it is a fraction of the original's length so that's another win.

I look forward to updated feedback and will be re-writing the reference implementation in the coming weeks.

The use-case that sold me on single share outputs was:

I have two trusted friends Alice and Bob. I want a master seed neither friend knows (unless they collude) that I can recover by asking Alice and Bob for derived shares. So Alice derives my threshold=2 share A with identifier "help", and Bob derives my threshold=2 share C with identifier "help" and I can recover the secret MS12HELPS..., and derive other shares such as "M", "Y", etc that will allow me to recover my seed without Alice or Bob. But even if I lose every share, I can still recover in the same manner as my seed was created.

By limiting the shares to the first threshold indicies and including the identifier in share entropy derivation, it avoids the issue of there being multiple seeds recoverable at a given threshold and identifier, at least from a single bip85 root key.

@scgbckbone scgbckbone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

much better!

few more questions/comments:

what are CANONICAL_INDICES ? it seems that you limit maximum number of shares to 10 by using them ?

The identifier SHOULD default to the 4 left-most characters of the Bech32-encoded BIP-0032 fingerprint derived from the master seed.

why? imo this should be left out from this spec and left to specific implementations to decide

Users should enter a unique identifier instead of incrementing {index} as different share sets SHOULD have unique identifiers.

I agree here & thinking that maybe we should remove index completely...

What do you think about below derivation path calculation:

CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
VALID_HRP = {
    "ms": 0,
    "cl": 1,
}

def pack_header(threshold: int, identifier: str, share_idx: str) -> int:
    id_a = CHARSET.index(identifier[0])
    id_b = CHARSET.index(identifier[1])
    id_c = CHARSET.index(identifier[2])
    id_d = CHARSET.index(identifier[3])
    idx = CHARSET.index(share_idx)
    return (threshold << 25) | (id_a << 20) | (id_b << 15) | (id_c << 10) | (id_d << 5) | idx

def unpack_header(n: int):
    threshold = (n >> 25) & 31
    id_a = (n >> 20) & 31
    id_b = (n >> 15) & 31
    id_c = (n >> 10) & 31
    id_d = (n >> 5)  & 31
    idx = n & 31

    identifier = ""
    for i in [id_a, id_b, id_c, id_d]:
        identifier += CHARSET[i]

    return threshold, identifier, CHARSET[idx]


def make_path(hrp, byte_len, threshold, identifier, share_idx, ):
    header = pack_header(threshold, identifier, share_idx)
    pth = f"m/83696968h/93h/{VALID_HRP[hrp]}h/{byte_len}h/{header}h"
    return pth

b85_path = make_path("ms", 16, 9, "llll", "l")
print(b85_path)
# m/83696968h/93h/0h/16h/335544319h
print(unpack_header(int(b85_path.split("/")[-1][:-1])))
# (9, 'llll', 'l')
  • removed idx (even tho I have second thoughts, as it is now very different than other BIP-85 apps, but maybe no care as it is already very different as with most other app you only choose index and nothing else. Here, on the other hand, there is plenty to specify)
  • added HRP into the path directly - useful imo - users can see immediately what is it for

@BenWestgate

BenWestgate commented Nov 22, 2025

Copy link
Copy Markdown
Contributor Author

Quoting BIP93: For a fresh master seed to justify my answers:

...the user generates random initial shares, as follows:

  • Choose a bitsize, between 128 and 512, which must be a multiple of 8.
  • Choose a threshold value k between 2 and 9, inclusive
  • Choose a 4 bech32 character identifier
    • We do not define how to choose the identifier, beyond noting that it SHOULD be distinct for every master seed the user may need to disambiguate.
  • k many times, generate a random share by:
    • Take the next available letter from the bech32 alphabet, in alphabetical order, as a, c, d, ..., to be the share index
  • Set the first nine characters to be the prefix ms1, the threshold value k, the 4-character identifier, and then the share index
  • ...

what are CANONICAL_INDICES ?

I renamed that constant to:
IDX_ORDER = "sacdefghjklmnpqrtuvwxyz023456789" # Canonical BIP93 share indices alphabetical order

It is the secret index "s" followed by the 31 share indices (excluding 's') alphabetically sorted. Prepending "s" makes IDX_ORDER.index("s") == 0, simplifying checks for the unshared secret index.

it seems that you limit maximum number of shares to 10 by using them ?

BIP93 prescribes using the first k alphabetical share indices for "random initial shares" (our bip85 entropy application).
k has max value 9. Including unshared secrets which use share_idx = "s", leaves 10 valid.

BIP85 deriving share indices beyond this initial k makes recovery ambiguous where knowing the common share header of a backup (hrp, threshold, identifier), root key and bip85 index is no longer sufficient to recover a unique secret and violates both specs.

Example:
For bip85_index = 0, prefix ms12cash, the initial shares bip85 derived with share_idx = "a" and share_idx = "c" would bip93 recover a different secret with prefix ms12cashs than initial shares with share_idx = "y" and share_idx = "z" recover.

Breaking BIP85's {index} customary rule:

The BIP85 index is a number used to derive unique child secrets from a root key. Each index generates one unique secret, allowing for up to 2^31 secrets based on the same root key.

And BIP93's identifier rule:

...[identifier] SHOULD be distinct for every master seed...

If share_idx == "s", then k MUST be 0 for the same reason. At the same child index and identifier, this presents two secrets: the directly bip85 derived unshared secret at threshold = k, share_idx = "s" and the recovered secret when k threshold = k shares are first bip85 derived then bip93 interpolated to "s".

The additional 31-k shares beyond the first k may be derived using BIP93 interpolation, which is outside spec as it needs no fresh randomness. Outputting interpolated shares would be like our XPRV application producing an extended private key encoding of HMAC-SHA512("Bitcoin seed", S) where S is the payload of our WIF application. We don't do that, we always generate fresh randomness or nothing at all. And I've proven the problem with generating fresh randomness for shares beyond the first k alphabetical indices.

What do you think about below derivation path calculation

My first thought was "OH, that's good!", I like this derivation path, provided we add the share_idx restrictions above.

However, Are you sure you want to eliminate the bip85 {index}?

All deployed BIP85 applications use this derivation path feature and the reference implementation expects one:

As with all applications, you can change the child index from it's default of zero to get a fresh, repeatable secret.

index, 0 to 2³¹ - 1 for millions of unique passwords

def derive_cli(application, number, index, special, xprv, to):

@click.option(
    "-i",
    "--index",
    type=click.IntRange(0, 2**31 - 1),
    default=0,
    help="Child index. Increment for fresh secrets.",

It's recommended headers be distinct for unique seeds, but not required as with no {child index} derivation.

why? imo this should be left out from this spec and left to specific implementations to decide

Identifier needs an assignment to encode the string so it may as well be the most useful deterministic data.
The codex32 authors like this as as a default for "electronic implementations" (which bip85 is):
BlockstreamResearch/codex32#54 (comment)

If the user can specify and we keep a child {index}, we break the "SHOULD be distinct for every seed" rule. I resorted to the fingerprint default based on an assumption bip85 applications MUST have a child index for "millions of unique" "fresh secrets" as per quotes above.

I dislike the least significant bits of {index}' being share_idx as when I think of the phrase "Child index. Increment for fresh secrets" that's not happening if say, share_idx = "s" changes to "3" and my secret output becomes a share output. If it must go in the last child index derivation level, I prefer share_idx and k as the most significant bits in the {index} serialization.

Retaining child index and using a default fingerprint identifier for unshared secrets makes them most like other bip85 apps. It's not required though, we could derive unshared secrets like shares by serializing the identifier into the child index, subject to the restriction k must be 0 if share_idx = s.

This version always requires the specifying the identifier even for secrets:

IDX_ORDER = "sacdefghjk"
VALID_HRP = {
    "ms": 0,
    "cl": 1
}

def bip93_parameters_to_path(hrp, threshold=0, identifier="", share_idx, byte_len, index=0):
    if threshold and share_idx not in IDX_ORDER[1:threshold+1]:
        raise InvalidShareIndex()
    if not identifier:
        raise MissingIdentifier()
    if index > 31:
        raise ValueError("maximum index 31 allowed for codex32 strings")
    index = (
                        + threshold * 2 // 2 + IDX_ORDER.index(share_idx) << 25
                        + index << 20
                        + int.from_bytes(bech32_decode(identifier), 'big')
                      )
    return f"m/83696968'/93'/{VALID_HRP[hrp]}'/{byte_len}'/{index}'"

This allows 32 child index values which combined with a million+ identifiers meets the existing "millions" spec. Incrementing this level changes the identifier as expected by BIP93 for a unique secret.

I still prefer combining hrp, k and share_idx in the first derivation level, and using the BIP32 fingerprint identifier for share_idx = "s" so we have all 2**31 - 1 child indices for fresh secrets and only codex32 shares break the established pattern (by being restricted to 2 ^ 11 - 1 child indices, as 20-bits are allocated for the required identifier).

def bip93_parameters_to_path(hrp, k=0, identifier="", share_idx, byte_len, index=0):
    if share_idx not in IDX_ORDER[min(1, k) : k + 1]:
        raise InvalidShareIndex()
    header_path_int = VALID_HRP[hrp] * 100 + k * 10 + IDX_ORDER.index(share_idx)
    if share_idx != "s":
        if not identifier:
            raise MissingIdentifier()
        if index > 2**11 - 1:
            raise ValueError("maximum index 2047 allowed for codex32 shares")
        index = index << 20 + from_bytes(bech32_decode(identifier), 'big')
    return f"m/83696968'/93'/{header_path_int}'/{byte_len}'/{index}'"

What do you think?

@scgbckbone

Copy link
Copy Markdown
Contributor

BIP93 prescribes using the first k alphabetical share indices for "random initial shares" (our bip85 entropy application)

completely missed that one, thanks for the reminder + examples

However, Are you sure you want to eliminate the bip85 {index}?

no, I'm not. Completely unsure about it tbh. I've got second thought while writing it & even more after I published. Now I think we should just have a proper index ((2**31) -1) at the end.

Identifier needs an assignment to encode the string so it may as well be the most useful deterministic data.
The codex32 authors like this as as a default for "electronic implementations" (which bip85 is):

I like the idea of using master pubkey fingerprint, but is it even possible for bech32 charset where we do not have number 1? (it can only be used as HRP separator in codex32 BIP)

f"m/83696968'/93'/{header_int}'/{byte_len}'/{index}'"

  • like this path (imo we should not use more than 5 derivation steps)
  • as I said, I'm now more inclined to use proper BIP85 index (2**31-1) without any more data encoded in it (but unsure)
  • that leaves us with the need to encode all the res to the header byte, which would not scale as we would (in my case) only have 2 more bits for HRP (good for now but broken in case more HRPs gonna be added).

I your encoding two-way? can you get back to header used from int ?

@BenWestgate

BenWestgate commented Nov 24, 2025

Copy link
Copy Markdown
Contributor Author

Now I think we should just have a proper index ((2**31) -1) at the end.

We have index 0 through 2**32-1 for secrets (k= 0, share_idx = "s") if id = fingerprint() or if id (identifier -> int) is packed in the first derivation level with hrp, k and share_idx.

In codex32 and bip85, id and child index mean the same thing: "unique" outputs. So in various iterations, I've either set the child index index = id, or serialized them together index = index << 20 | id since one has 31 bits and the other 20. From a UX perspective it's better to choose 4 bech32 characters and decode it into the child index than to chose an integer child index that produces the desired 4 character identifier.

For identifier collision resistance, index = id is best, followed by index = index << 20 | id as the first 2^20 child indexes all have unique identifiers before it rolls over. While the fingerprint likely begins to collide (birthday problem) after ~2^10 child indexes used. But the master pubkey fingerprint is far more useful for identifying what the secret is for and harder to accidentally reuse on different child secrets. That practical benefit seems to outweigh the risk of identifier reuse in the first 2**20 child indices.

Since only hrp = "ms" secrets represent a BIP-0032 master seed with an associated master key pair and master pubkey fingerprint perhaps we drop the fingerprint default? Or (my preference) use it only in the ms10 case?

Key identifiers

Extended keys can be identified by the Hash160 of the public key, ignoring the chain code. This corresponds exactly to the data used in traditional Bitcoin addresses.

The first 32 bits of the identifier are called the key fingerprint.

hrp = "cl" secrets are a 32-byte private key so they technically don't have a key fingerprint, and at any rate this means custom per hrp identifier derivation logic.

I like the idea of using master pubkey fingerprint, but is it even possible for bech32 charset where we do not have number 1?

We can bech32-encode the 20-MSB of master pubkey fingerprint into the 4-character identifier instead of 16-bits if we mapped hex characters, which we can't since b, 1 are missing. It's easy to convert it to hex without electronics using the BIP-0173 table.

f"m/83696968'/93'/{header_int}'/{byte_len}'/{index}'"

like this path (imo we should not use more than 5 derivation steps)

Agreed, this simplifies implementations assumptions about path lengths.

that leaves us with the need to encode all the res to the header byte, which would not scale...

There are 45 k and share_idx combos so they consume 6-bits (compressed) and id 20-bits, leaving 2^5 hrp codes, 30 remaining, a narrow margin. BIP-0173 has 288 registered prefixes but codex32 will never have as many since it is for private, not public data.

as I said, I'm now more inclined to use proper BIP85 index (2**31-1) without any more data encoded in it (but unsure)

Here is an example path encoding that uses the 20-LSB of the identifier parameter as the 20-LSB of the child index derivation level, and left-shifts any optional "index" parameter 20-bits to not mangle the identifier part.

I your encoding two-way? can you get back to header used from int ?

Yes.

CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
IDX_ORDER = sorted(CHARSET, key=lambda c: (c != 's', c.isdigit(), c))  # "s" then alphabetical order
VALID_HRP = ["ms", "cl"]

def bip93_parameters_to_path(hrp, k, share_idx, byte_len, ident="", index=0):
    if k == 1 or not (0 <= k <= 9):
        raise ValueError("Invalid threshold parameter")
    if share_idx not in IDX_ORDER[min(1, k) : k + 1]:
        raise ValueError("Invalid share index")
    if len(ident) != 4:
        raise ValueError("Missing unique 4-character bech32 identifier")
    if index > 2**11 - 1:
        raise ValueError("maximum index 2047 allowed for codex32 shares")
    
    header_path_int = VALID_HRP.index(hrp) * 100 + k * 10 + IDX_ORDER.index(share_idx)
    for char in ident:
        index = index << 5 | CHARSET.find(char)

    return f"m/83696968'/93'/{header_path_int}'/{byte_len}'/{index}'"


def bip93_path_to_parameters(path=''):
    header_int, byte_len, index = [int(segment[:-1]) for segment in path.split("/")[3:]]
    hrp = VALID_HRP[header_int // 100]
    k = header_int // 10 % 10
    share_idx = IDX_ORDER[header_int % 10]
    ident = "".join([CHARSET[(index >> s) & 31] for s in (15, 10, 5, 0)])

    return hrp, k, share_idx, byte_len, ident, index >> 20

I left out the custom rule for hrp="ms" and k=0 which would have defaulted the identifier to fingerprint for simplicity.

Here are some test vectors:

assert bip93_path_to_parameters("m/83696968'/93'/0'/16'/0'") == ('ms', 0, 's', 16, 'qqqq', 0)
assert bip93_path_to_parameters("m/83696968'/93'/199'/32'/999999999'") == ('cl', 9, 'k', 32, '4j0l', 953)
assert bip93_path_to_parameters("m/83696968'/93'/199'/64'/2147483647'") == ('cl', 9, 'k', 64, 'llll', 2047)

assert bip93_parameters_to_path("ms", 0, "s", 16, 'test', 0) == "m/83696968'/93'/0'/16'/386571'"
assert bip93_parameters_to_path("ms", 3, "a", 16, 'cash', 0) == "m/83696968'/93'/31'/16'/816663'"
assert bip93_parameters_to_path("ms", 3, "c", 16, 'cash', 0) == "m/83696968'/93'/32'/16'/816663'"

assert bip93_parameters_to_path("ms", 2, "a", 16, 'qqqq', 0) == "m/83696968'/93'/21'/16'/0'"
assert bip93_parameters_to_path("ms", 2, "c", 32, 'qqqp', 0) == "m/83696968'/93'/22'/32'/1'"
assert bip93_parameters_to_path("ms", 3, "a", 16, 'llll', 0) == "m/83696968'/93'/31'/16'/1048575'"
assert bip93_parameters_to_path("ms", 3, "c", 64, 'qqqq', 1) == "m/83696968'/93'/32'/64'/1048576'"
assert bip93_parameters_to_path("ms", 3, "d", 16, 'qqqp', 1) == "m/83696968'/93'/33'/16'/1048577'"

This header_path_int encoding is very human-readable as 100s place is the hrp value, 10s is k and 1s the alphabetical order share index. The last index digit must be <= the k digit to be valid (our limitation discussed last week on valid share indexes to derive).

If we cram identifier into header_int as suggested: we lose this decimal readability AND 20-bits of hrp codes. So I am convinced the identifier needs to set the child index derivation level or, less preferably, be set by the child index value.
Especially given they have a purpose in common: a public label for a unique secret.

Both BIP85 child indices and the codex32 identifier aim to disambiguate or label derived/encoded secrets so you can tell one master seed apart from another.

They're not the same thing but they're close enough we should handle them together for derivation purposes.

@scgbckbone

Copy link
Copy Markdown
Contributor

Since only hrp = "ms" secrets represent a BIP-0032 master seed with an associated master key pair and master pubkey fingerprint perhaps we drop the fingerprint default? Or (my preference) use it only in the ms10 case?

I'm planning to extende the BIP(s) with new HRP, that will encode chaincode+privkey (64bytes) for compatibility with BIP-39. I will use master fingerprint 20 MSB for it in my application as default. Users will have ability to change to custom ID. So up to you.

Here is my xfp to codex32 id converter:

c = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"

def xfp_to_codex32_id(xfp):
     x = (int(xfp, 16) >> 12) & 0xFFFFF  # Extract exactly 20 MSB
     return c[(x >> 15) & 31] + c[(x >> 10) & 31] + c[(x >> 5) & 31] + c[x & 31]

ok, I'm sold. You definitely put much more thought into this than I did. Thanks for lengthy & helpful explanations!

Concept ACK

@BenWestgate

This comment was marked as off-topic.

@scgbckbone

Copy link
Copy Markdown
Contributor

Your design should probably use the 20-MSB of the master key fingerprint as the identifier on master xprvs and master xpubs. That has the awesome property of keeping the same default identifier as the master seed that derives them:

To support this though we must go with the original "s" secret design that does NOT incorporate the ID into the derivation. As you need to know the derived private key to compute the fingerprint ID.

I doubt anyone will start using bech32 encoded extended keys, at least I do not plan to, even tho better readability than base58, that standard is set in stone at this point. If you consider it useful, I do not mind if you optimize this BIP-85 app for it.

@BenWestgate

This comment has been minimized.

@scgbckbone

scgbckbone commented Nov 27, 2025

Copy link
Copy Markdown
Contributor

When I see storing the chain code and private key, I am thinking you're encoding an extended key (you are) so that seems most useful to go all the way.

you're probably right that it make sense to attempt it all the way to proper extended key encoding, but no I wasn't treating it as such, I was just storing secret that consist from chaincode + privkey from which naked/root (without meta) extended private key can be re-assembled

Andrew already has an informal standard for storing BIP39 words in codex32 so we don't need to store the hdseed or extended key.

do you have link ?

@BenWestgate

This comment has been minimized.

@akarve

akarve commented Dec 29, 2025

Copy link
Copy Markdown
Contributor

@BenWestgate howdy and happy holidays. thanks for all of your input so far. i did manage to get an app protocol together so that you and others can hopefully add bip-85 apps to the reference implementation easily:
#1958

i'm sure it needs some improvements as i rushed it but again should be a big step up. lmk if it works for your app and if you desire any protocol changes.

https://github.com/akarve/bipsea/blob/protocol/src/bipsea/app_protocol.py

@murchandamus

Copy link
Copy Markdown
Member

@akarve, @BenWestgate: What’s the status on this one? It sounds to me like you have made progress, but it’s not clear to me whether this is ready to be merged.

@BenWestgate

This comment was marked as resolved.

1 similar comment
@BenWestgate

This comment was marked as duplicate.

@akarve

akarve commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

No reference implementation, was waiting on #2040, but can move ahead without.

Kinda looks to be moving now though? apoelstra is saying he's gonna review so let's give him a short window?

Reviewer suggested converting bytes to strings instead of DRNG selecting bech32 characters.
We should revert that. What do you think @akarve ?

Can you link to the above comment so I can read it in context?

I proposed a default identifier on secrets, yet shares require an identifier parameter. It would simplify implementations to
drop this and require all strings to provide an identifier.

Apologies you've probably already done this but can you show how the respective BIP-85 derivations would differ and what the tradeoff is?

Last, I've considered changing bytes length to bitlength or payload length, so to not constrain a future application that doesn't encode bytes. Not sure if anyone else agrees.

Same request as above. Linking to existing comments is fine.

From my side I did the "app refactor" but didn't push it, I aim to complete that this month so you can submit a codex app to the ref. implementation.

@akarve

akarve commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

@BenWestgate app refactor is in the reference implementation is live. Lmk if a friendly ping to apoelstra here or in another PR would unblock.

@apoelstra

Copy link
Copy Markdown
Contributor

I'm happy to rereview this but @BenWestgate can you clean up the git history? It's hard for me to follow the series of merges and understand what's actually changing. (I also think that the merge policies of this repo want you to have a linear history.)

With #2040 it looks like there is still some back-and-forth between you and Russell so I will let that settle before re-reviewing.

It may make sense to just squash everything into one commit and rebase on master.

@jonatack jonatack added the PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author label Apr 20, 2026
@murchandamus

Copy link
Copy Markdown
Member

I think this is in your court at this time, @BenWestgate. @apoelstra, I think the summary of changes across all commits is fairly readable (https://github.com/bitcoin/bips/pull/1958/changes), but I agree that this might be more accessible for another round of review if it were squashed and rebased.

@murchandamus

Copy link
Copy Markdown
Member

This PR was based on #2040 which was closed after six months without activity. I assume that this PR will therefore also not continue to make progress. I will close this on 2026-07-30 or later, unless I hear otherwise.

@akarve

akarve commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

ACK

@BenWestgate

This comment has been minimized.

Define a deterministic BIP85 derivation for canonical initial codex32
strings as specified by BIP93. Serialize the six-character codex32
header into the final hardened path element so each valid path
identifies one canonical output for a fixed root key.

Reserve profiles for future secret formats and define profile 0 for
BIP32 master seeds using the ms HRP. Keep byte length and zero-padding
rules specific to master seed construction while making random share
payload construction common to assigned profiles.

Include reproducible vectors for an unshared master seed and a
threshold-2 initial basis. Organize the specification around master
seeds and shares so the table of contents exposes the two output
concepts while each example follows the existing BIP85 INPUT/OUTPUT
style.
@BenWestgate

BenWestgate commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

@apoelstra @murchandamus I squashed and rebased and updated PR description. This no longer depends on #2040 as I only defined profile 0 which is the Master seed format.

@akarve I updated the bipsea reference implementation to match the new BIP text. The questions I asked you are resolved. @scgbckbone Share payload characters must be sampled uniformly from the BIP85 DRNG.

I replaced CRC-padding with zero-padding on master seed bytes on ms threshold 0 outputs for easier review and implementation. CRC-padding could help filter candidate repairs by up to a factor of 4 or 16 for a 128- or 256-bit codex32-encoded master seed damaged beyond the codex32 checksum correction capacity. Willing to restore CRC padding if reviewers consider that benefit worth the complexity.

Requesting re-review at 120c923.

@jonatack jonatack removed the PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author label Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Pending acceptance This BIP modification requires sign-off by the champion of the BIP being modified Proposed BIP modification PR by non-owner to update BIP content

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants