Skip to content

Recommendations for Auditable Electronic Codex32 Implementations #57

Open

Description

@BenWestgate

While intended for paper computers, some benefits of codex32 can be maximized through electronic implementations. To enhance this aspect, I propose recommendations for creating auditable codex32 share sets when using electronics.

Inspired by the concept of auditable bitcoin wallets, I have developed an implementation of For a fresh master seed, which ensures full determinism in generating the codex32 share set.

I believe that deterministically choosing every aspect of the codex32 share set enhances protection against potential malicious tampering of shares by detecting any deviation from the determinism.

In issue #54, we discussed that the hash160(public_masterkey) from descriptors serves as a useful deterministic data to use as the default identifier. However, we need to address another aspect that could potentially leak information – the share indices and payloads of the k - 1 independent shares.

Currently, the BIP recommends selecting the next available letter from the bech32 alphabet, in alphabetical order, as share indices:

Take the next available letter from the bech32 alphabet, in alphabetical order, as a, c, d, ..., to be the share index

This approach may inadvertently reduce privacy, as finding share f, g, or h would reveal a backup of at least 5, 6, or 7 shares, respectively. To improve this, I propose an update to the BIP where, during hand creation, dice are rolled to select each new share index. This method is akin to how indexes are created in software like libgfshare-bin.

For electronic implementations, share indices should be determined by the codex32 secret alone. Below is an example of how I chose the share indices in Python:

import random
import hashlib
import hmac
salt=threshold+identifier+str(len(master_seed))
random.seed(a=hmac.digest(master_seed, salt, 'sha512'))
share_index = random.sample(indices_free, n)

The master_seed, threshold, identifier, and seed_length are the only variables in a codex32 secret, so I HMAC these together to seed the selection of share indices. Although a hash of the codex32 secret string would also work.

I think this is the right track but I'm looking to standardize recommendations for electronic implementations. If there's a better way please say so.

Below is an example of how I generated the share payloads in Python:

share_entropy = hashlib.scrypt(password=master_seed, salt=salt, n=2**20, r=8, p=1, maxmem=1025**3, dklen=(int(k)-1)*(seed_length+1))

While this a start, it may not be suitable as a standard recommendation due to scrypt parameters that won't run on hardware wallets. To address this, I am open to exploring alternative variable length secure hash functions. Your input on this matter would be appreciated.

Next, this stretched entropy is divided evenly into k - 1 share payloads. Additionally, an extra byte is allocated for filling the padding bits on the 26th or 52th characters.

Subsequently, the remaining n + 1 - k shares are derived according to the BIP:

    for x in range(int(k) - 1):
        data = list(share_entropy[x*(seed_length+1):x*(seed_length+1)+seed_length+1])
        share_list += [encode("ms", seed_length, k, identifier, share_index[x], data)]
    for x in range(int(k) - 1, n):
        derived_share_list += [derive_new_share(share_list,share_index[x])]

Lastly, if a new set of shares is created for an existing master_seed with the same threshold, I propose incrementing the last character of the identifier by 1, which will cause a completely new set of indicies and share payloads to be chosen.

I welcome any feedback or suggestions for improving the approach. Let's establish standardized and auditable recommendations for electronic codex32 implementations. Thank you for your collaboration.

Activity

  1. apoelstra commented on Jul 28, 2023

    @apoelstra
    Contributor

    To improve this, I propose an update to the BIP where, during hand creation, dice are rolled to select each new share index.

    This would actually complicate things a fair bit -- generating shares in order allows us to have a fixed set of tables that users can use to derive additional shares. If you were generating shares in random order you would need to do something akin to the recovery process -- and for each share index we'd need a "recovery wheel" for that index.

    I wouldn't mind releasing a set of "recovery wheels" and suggesting people do this as an additional privacy measure (we've wanted to do this anyway as a "recover the remaining shares from an incomplete set" process), but I think it's too onerous to suggest doing as the default. Especially as the k/n parameters are not something that we think of as secret data. k is even encoded in the share header.

    But I agree that this generation mode be the default for electronic implementations.

    Below is an example of how I generated the share payloads in Python:

    Because you're starting with the master seed, I don't think there's any value in hardening this. If the seed itself is of low-quality randomness then the user is screwed anyway. So I would actually suggest going in the other direction, toward chacha20 rather than scrypt, in the hope of coming up with something computationally simple enough that it could be (theoretically) audited by hand.

    Similarly, with the share indices I don't even think we need cryptographic randomness -- the goal is simply to break the implication "share G implies that shares B,C,D,E,F exist". Though of course, if you are using the master seed as entropy, you want to run that through a cryptographic RNG to make sure that no information leaks into the indices themselves. I also don't think the share indices need to be auditable, though there's no reason for them not to be so we might as well.

    BTW, I've been holding off on PR'ing to the BIP repo because it seems like you (and us) are still iterating on this, but I definitely do intend to incorporate your suggestions once we've nailed everything down. Thanks a ton for digging into this!

  2. apoelstra commented on Jul 28, 2023

    @apoelstra
    Contributor

    @BenWestgate also, if you are on IRC, #volvelle-wizards is our discussion channel for this stuff.

  3. BenWestgate commented on Jul 28, 2023

    @BenWestgate
    ContributorAuthor

    Thank you for your response I will make changes to my implementation to make it closer to a standard recommendation.

    I will get rid of the KDF and use ChaCha20 for filling the secret payload padding, the share payloads and shuffling the indicies so auditing electronic codex32 share sets by hand is possible.

    Share indices MUST be derived by a cryptographically secure randomness if not fixed, and we agree they should shouldn't be fixed by default in electronic implementations for the small "n" privacy gain.

    By my math if the user made 31 shares and numbered them 1 to 31 it can leak a surprising amount of data: 87 bits.
    So even with encrypted payloads, the seed would be effectively leaked by a malicious non-deterministic implementation with enough indices known. Although maybe 2nd level SSS encryption should encrypt the plaintext share's threshold, identifier, and indices for reasons like this. #16

    Without encryption the max threshold is 9, so the maximum data leak we're concerned with is from 8 indices
    log2(31*30*29*28*27*26*25*24) = 39 -bits leaked

    Leaving only 89-bits of security for the master_seed, which is probably guessable by TLAs in our lifetime if not already.

    Regarding the algorithm for the share index selection, using python random's random.seed() and then random.sample() is probably not hand auditable due to the Mersenne Twister.

    Instead I could get at least 87-bits from CHACHA20, convert to bech32, and take the first unique character to be the next share index.

    But how to know how much data to produce from the hash so I don't run out? Is there a better way to deterministically "shuffle" a list by hand computations?

    1. Use Chacha20 with a secret key (master_seed) to generate a keystream.
    2. Take the first byte of the keystream (or any desired number of bits, depending on the number of elements in the list) and use it as an index to select the first symbol in the shuffled list, unless the value is "s" or a previously selected character.
      3.Use the next byte of the keystream (or any desired number of bits) to use as an index to select the next symbol for the shuffled list
    3. Increment the counter for Chacha20 by 1 if out of bytes.
    4. Repeat steps 2 to 4 until all symbols are selected, creating the shuffled list.

    Perhaps the chacha20 hash mod 31, mod 30, ... etc
    What is the right balance between length of secure hash output needed and math operations to derive each index indices that's most efficient for hand auditing and also doesn't introduce modulo bias?

    The share payloads are much easier in this regard as they allow repeating characters and use the full 5 bits per character so the chacha20 output stream can be directly used.

    I joined the IRC channel as benwestgate.

  4. BenWestgate commented on Jul 30, 2023

    @BenWestgate
    ContributorAuthor

    Because you're starting with the master seed, I don't think there's any value in hardening this. If the seed itself is of low-quality randomness then the user is screwed anyway. So I would actually suggest going in the other direction, toward chacha20 rather than scrypt, in the hope of coming up with something computationally simple enough that it could be (theoretically) audited by hand.

    from Cryptodome.Cipher import ChaCha20
    
    padding _length = 32 - len(master_seed)
    
    key = master_seed + bytes(padding_length)
    
    nonce = bytes(hrp+k+identifier+"s", 'utf')
    
    cipher = ChaCha20.new(key=key, nonce=nonce)
    
    cipher_stream = cipher.encrypt(bytes(8))

    Does this look good as the RNG stream for setting the padding bits in the last symbol of the codex32 secret payload, the share indicies and k-1 share payloads?

    The nonce can be incremented by 1 whenever a 512-bit block is exhausted.

    Do you prefer zero padding the decoded master_seed bytes as shown above or directly utf-8 encoding the 32 bech32 characters after "s", ie key = bytes(codex32_secret[9:41], 'utf-8')?

    Unsure which is easier by hand.

    Sad to see double_sha256 go as the padding as 256-bit master_seed's were direct truncation of WIF serialization. But that novelty is a liability for auditing by hand.

    Likewise, it feels wrong to set the secret payload padding bits with a RNG stream that depends on the header, causing the payload for identical master_seed's to vary if the header changes.

    Yet it seems smart to conserve labor for hand auditors by avoiding computing a fixed nonce ChaCha20 block only to set 2-4 padding bits then discard. These bits should be the first data extracted from the stream as a fast first check for malicious tampering. The next should be the share payloads, as these are also recoverable error detecting and auditable data, the last should be the indices as the order the software provided them is meant to be lost during recovery and so they cannot detect tampering.

  5. apoelstra commented on Jul 31, 2023

    @apoelstra
    Contributor

    Share indices MUST be derived by a cryptographically secure randomness if not fixed, and we agree they should shouldn't be fixed by default in electronic implementations for the small "n" privacy gain.

    By my math if the user made 31 shares and numbered them 1 to 31 it can leak a surprising amount of data: 87 bits.

    Heh, yeah, this seems reasonable. It seems pretty implausible that the user would number their shares 1 to 31 like that but why have a leak when we don't need to. If users want to do this by hand they can use dice rolls, which are cryptographically secure.

    Regarding the algorithm for the share index selection, using python random's random.seed() and then random.sample() is probably not hand auditable due to the Mersenne Twister.

    Hmm, true. And I see now the value of auditability, since in theory each share index could be leaking 5 bits (to an attacker who knows the order that the shares was generated anyway).

    But how to know how much data to produce from the hash so I don't run out? Is there a better way to deterministically "shuffle" a list by hand computations?

    Knuth's "Algorithm P" can do this. (The question is about finding a uniform subset, but conveniently this algorithm finds a subset in a uniform order. If you want a sorted order, which is just as good from a security POV, you can use "Algorithm S" or just sort manually.) Howevery, algorithm P assumes you can uniformly sample from the range [i, 31] for each i, which is hard to do without a theoretically indefinite stream of randomness. Though if you use rejection sampling, the probabilities are at least much easier to compute.

    Let me think about this.

    Does this look good as the RNG stream for setting the padding bits in the last symbol of the codex32 secret payload, the share indicies and k-1 share payloads?

    The RNG initialization looks good to me, although

    • I guess identifier comes out of a different RNG?.
    • I also don't know what encrypt() does; I assume it xors the input with the random stream.
    • It's a cool idea to set the nonce uniquely per seed; I'd have probably just left it at all-zeroes...see below comments.

    Also, I wouldn't bother setting the padding bits randomly. You can just leave them as 0. They aren't used and do not feed into BIP32 derivations.

    The nonce can be incremented by 1 whenever a 512-bit block is exhausted.

    Yep, though this is a tiny bit subtle. If you were to produce 256 blocks then you'd be incrementing the identifier part of the nonce, and would produce the same output as you would've if you'd simply started with that identifier.

    But since we're only producing 31 shares, each of which only takes half a block, this is fine.

    Do you prefer zero padding the decoded master_seed bytes as shown above or directly utf-8 encoding the 32 bech32 characters after "s", ie key = bytes(codex32_secret[9:41], 'utf-8')?

    Unsure which is easier by hand.

    Directly using the bytes is definitely easier, and I think is also more elegant.

    Sad to see double_sha256 go as the padding as 256-bit master_seed's were direct truncation of WIF serialization. But that novelty is a liability for auditing by hand.

    Yeah, that is a cool property. Though TBH I don't think users frequently see WIF serialization of xprivs so it's something of a curiosity.

    Likewise, it feels wrong to set the secret payload padding bits with a RNG stream that depends on the header, causing the payload for identical master_seed's to vary if the header changes.

    Yeah, hmm. There's two ways you could think of this:

    • Changing the threshold or identifier is a "reset the world" change and we should put this stuff into the RNG. This prevents users from re-sharing the same secret in different contexts while producing the same shares, which is good because the different share sets would interact algebraically in possibly surprising ways.
    • Changing the threshold or identifier is a "relabelling" which we ought to support. I actually considered producing a worksheet for this because in the early days I found myself relabelling stuff a lot (to get encrypted shares with distinct but human-readable headers).

    The threshold value should definitely go into the RNG. Under no circumstances should you "relabel" the threshold, unless you are doing something dangerous and way off-script in which case the spec doesn't matter anyway. After thinking about it for a bit, I also think we should do this with the identifier. It creates a nice workflow where "if you want to re-split the secret, you have to change the identifier" and conversely "if the identifiers are the same (and the they come from the same seed), they're compatible shares".

    Re-sharing secrets is a safe thing to do as long as the initial shares are independently random, and there are lots of reasons that users might want to do it.

  6. BenWestgate commented on Jul 31, 2023

    @BenWestgate
    ContributorAuthor

    I guess identifier comes out of a different RNG?.

    #54 (comment)

    identifier is either selected by the user or defaults to the hash160(master_pubkey), making it possible to tell which share set recovers spending a watch only wallet, as it is prepended to child xpubs in descriptors. Of various options this one seems most useful for these 4 characters.

    I also don't know what encrypt() does; I assume it xors the input with the random stream.

    That's exactly what it does, the stream is being XOR'd with zeros in order to print the cipher stream directly.

    It's a cool idea to set the nonce uniquely per seed; I'd have probably just left it at all-zeroes...see below comments.

    It is an essential feature.

    Having the nonce be a function of the header allows for creating new backups for the same master_seed that don't reuse the original randomness. For example if a share or two was compromised, the old set could be destroyed and replaced with a share set using a new header (new randomness) to restore security without sweeping funds. (Whether it's advisable to "rotate" shares given an attacker may reach a threshold before you've Completely Destroyed the partially-compromised share set is another question.)

    If the nonce was fixed to bytes(8), you'd get the same independent shares for the same master_seed every time, even if you changed the threshold or identifier. This even helps with access control, if you want one group to be able to recover at threshold 3 and another group to recover at threshold 2, make two share sets with each threshold and one set will NOT leak payload data of the other set.

  7. apoelstra commented on Jul 31, 2023

    @apoelstra
    Contributor

    It is an essential feature.

    This is a great argument. Agreed on all counts.

  8. BenWestgate commented on Jul 31, 2023

    @BenWestgate
    ContributorAuthor

    Also, I wouldn't bother setting the padding bits randomly. You can just leave them as 0. They aren't used and do not feed into BIP32 derivations.

    I dislike wasted space, especially in handwriting. But I agree padding should not be set randomly. Let's see how to make those bits do some work:

    1. These bits are as secret as the secret, it's a waste to set them with a non-linear secure hash function. double_sha256(seed) etc with no efficient erasure correction ability.
    2. These bits are as secret as the secret, it's a waste to set them to some public data like the hash160(master_pubkey) as you can't see them until you can already compute the pubkey, this is just another form of 1 with extra wasted work.
    3. But they could be parity bits or linear binary error coding. These algorithms are pretty fast to do on the master_seed bytes by hand.

    Do you have any suggestions?

    Naive idea is upper half and lower half parity, which has nice property of keeping the same 64-bit chunk length for 128/256-bit seeds. But falls apart at 512-bit seeds which have 3-bits of padding (170 bit chunks?)
    This is not the most efficient error coding. Proper ECC should correct contiguous bit erasures anywhere in the master_seed that are the length of the checksum and is what should be used. This offers a little extra help in the absolute worst case scenario, where you have threshold shares but some are invalid beyond correction by the codex32 BCH code but are less than a symbol away from getting the right correction, potentially making brute forcing your valid codex32 secret 4 to 16 times cheaper.

    What is the best ECC to put here? Will it wastefully repeat the same info as the codex32 secret checksum or can it supplement it?

    Agreed, regarding re-sharing the same secret:

    The BIP already says only shares with a matching identifier can be combined. Allowing "relabeling" is not very useful because it changes the checksum, even if share payloads do not change. And then you'd create a situation that the BIP says is not supported: two shares of different identifiers can be combined, but only if you ignore their checksum and identifier and just combine payloads. It's neat to just increment the LSB of the identifier to re-share an existing master_seed, then it still retains 3 symbols of the master_pubkey fingerprint, while allowing 32 re-shares. And setting a custom identifier to reshare should also be supported as long as it's unique from all previously used identifiers at that threshold.

    On this topic of identifier uniqueness for re-sharing, it's not clear a user would know all previously used identifiers besides the one they have just combined with to do a reshare. Especially if they are not destroying old sets, they may combine with an older share set, and if we naively increment that, we will re-use the same nonce! So I think the default for "re-shares" needs to generate some independent randomness when users don't know all outstanding identifiers for this seed and threshold. Then we have a birthday problem and just pray for no collisions... The alternative is to not support useful cases:

    Can you think of any reason to have 2 outstanding share sets of the same threshold but different identifiers? I did: prevent one set from combining with another set to defend against custodian collusion.

    I might make a 2-of-3 with my dad and executor and a 2-of-3 with my mom and executor, each with unique identifier of same master_seed, and now recovering the master_seed requires me or my executor and 1 parent, but both my parents cannot combine shares to recover without me or my executor. Making them less attractive targets for a physical attack than if this was a 2-of-4 between my parents, myself and my executor. Useful.

    How much randomness do we have to inject into the identifier if we don't know all the old identifiers to be pretty safe we don't reuse one? Can we still keep some bech32 encoding of the public key fingerprint, to identify the underlying wallet master_seed and connection between unique share sets of it or must inject all the entropy we can to be safe?

  9. apoelstra commented on Jul 31, 2023

    @apoelstra
    Contributor

    I dislike wasted space, especially in handwriting. But I agree padding should not be set randomly. Let's see how to make those bits do some work:

    This is an interesting direction. We can definitely stick a checksum in here and we can definitely make it "independent" of the existing checksum in the sense that it will meaningfully add error correction. But it will be a bit-level error correcting code which has some unintuitive properties when applied to data that's normally encoded as 5-bit characters.

    In particular I think a 2-bit code will only be able to detect one error (I guess, two, if they're consecutive), so it would mean we could detect certain character errors but not others. This is not zero value but it's fairly limited. I think we should keep thinking whether there's a better use of 2 bits before committing to this.

    Allowing "relabeling" is not very useful because it changes the checksum, even if share payloads do not change.

    It does, but you can compute what the checksum will change to, far more quickly than recomputing the whole thing. You can even use a computer to do most of the work without exposing any secret data to the computer.

    On this topic of identifier uniqueness for re-sharing, it's not clear a user would know all previously used identifiers besides the one they have just combined with to do a reshare. Especially if they are not destroying old sets, they may combine with an older share set, and if we naively increment that, we will re-use the same nonce! So I think the default for "re-shares" needs to generate some independent randomness when users don't know all outstanding identifiers for this seed and threshold. Then we have a birthday problem and just pray for no collisions... The alternative is to not support useful cases:

    Agreed. Unfortunately I think we just need to live with the birthday problem. In practice I think it'd be okay, both because the chance of collisions is fairly low (you need close to 1024 re-shares before the probabilites get high, and like, what are you doing with your secrets then..) and because the failure mode is that you'll generate exactly the same share set as before. Which is not much worse than having the original set still out there.

    Can you think of any reason to have 2 outstanding share sets of the same threshold but different identifiers? I did: prevent one set from combining with another set to defend against custodian collusion.

    Yeah, this is sorta what I'm imagining. That, or having "backup shares" that might have the same/overlapping custodian sets, but nonetheless shouldn't be mixable. Unfortunately these are the exact cases where collisions would undermine your security model.

    How much randomness do we have to inject into the identifier if we don't know all the old identifiers to be pretty safe we don't reuse one? Can we still keep some bech32 encoding of the public key fingerprint, to identify the underlying wallet master_seed and connection between unique share sets of it or must inject all the entropy we can to be safe?

    The birthday paradox shows up at around sqrt(N). So if you used the entire 20-bit identifier you'd expect collisions around the time you had 10 outstanding bits (1024). The curve is pretty steep so you have very low probabilities for quite a while, so it's fine to use 1000 as a rough estimate. If you used only half the identifier, 10 bits, you'd expect collisions after around 30 ... and have nontrivial probability of collisions after only a few.

    My feeling is that you'd need to use the entire identifier for this.

  10. BenWestgate commented on Aug 1, 2023

    @BenWestgate
    ContributorAuthor

    I think using the entire identifier is fine. This is a sub-case of the support custom identifier case, where we MUST randomize it when the user and software can't recall all previously generated share set identifiers for this master_seed.

    Otherwise implementations can default to suggesting to increment it, preserving most of the hash160(master_pubkey) fingerprint or as always the user can pick anything unique: "faml" "moma" "dada" "lawr" "acct" "aunt" etc. Wallets should store the identifiers they have created to prevent the user from repetition or at least warn in event it is deliberate to repair or extend an existing share set.

    Since this "I forgot" case requires new randomness that cannot be derived from master_seed the following procedure from auditable bitcoin wallets is needed so that the new random identifier can't leak seed data or deliberately repeat an identifier to break security:

    identifier = convertbits(hashlib.scrypt(password=user_data, salt=app_entropy, n=2**20, r=8, p=1, maxmem=1025**3, dklen=3), 8, 5)[:4]
    • These parameters will need to be reduced to support HWWs and hand auditing (see below)

    Where the user provides randomness and the machine generates 20-bits and unconditionally displays it and they are mixed with a one-way compression function.

    Process of creating the new identifier should be functionally equivalent to the following steps:

    1. Wallet generates a pseudo-random number (20 bits or more) that we call app entropy.
    2. Wallet unconditionally displays app entropy to the user (in bech32, hex etc).
    3. User is then asked for some data to be entered that allows to improve security in case app entropy generator has a backdoor (see Dual EC DRBG). This could be a one-time text entered by the user only for this purpose, or a passphrase containing enough entropy that is used later for other purposes.
    4. Wallet uses computationally expensive KDF to deterministically mix app entropy and user-entered text to produce the final identifier.

    Important considerations:

    • App entropy must be displayed unconditionally so that wallet cannot know if user is going to audit it or not.
    • User-entered data should be visible and reproducible by the user to be able to arrive to the same data in an auditing software. Good: text or pattern of button presses. Bad: accelerometer data, mouse movements (hard to record and reproduce).
    • User-entered data should contain enough entropy and should be unique. If the wallet has a backdoored RNG which does not generate unique numbers, we're relying on user-entered data to avoid nonce reuse and if the wallet is trying to leak the master_seed we're relying on user entropy for secure key mixing.
    • Unless necessary, wallet should not rely solely on user input to generate identifier because they may inadvertently give a non-unique input. In such case, honest wallet with correct RNG implementation would with strong probability generate a unique nonce.
    • Process of mixing app entropy and user input must use computationally expensive KDF to compensate for non-uniqueness OR low-entropy in user input.

    On second thought, it may not need to be an expensive KDF, but it may need to be a one way compression function. HMAC-SHA256 or our ChaCha20 hash. With the user input as key and app_entropy as nonce. I believe simple XOR won't provide protection from master_seed leakage when users enter less than 20-bits of entropy, which is likely when limited to 4 bech32 characters.

    The worst case is the attacker predicts the user's input they can recover 20-bits of leaked master_seed. My KDF above costs $0.0002 to check all possible 20-bits with known user data. So there's no way to secure this other than that the user should be able to type more than 4 characters to maximize their chances of a unique string w/ 20-bits entropy.

    from Cryptodome.Cipher import ChaCha20
    from Cryptodome.Random import get_random_bytes
    from segwit_addr import convertbits
    
    # user data is limited to 32 bytes
    padding _length = 32 - len(user_data)
    key = user_data + bytes(padding_length)
    
    nonce = get_random_bytes(8)
    cipher = ChaCha20.new(key=key, nonce=nonce)
    
    identifier = convertbits(cipher.encrypt(bytes(3)),8,5)[:4]

    For auditing by hand, provide 8 de-biased dice rolls to select the custom identifier avoids doing a chacha20 hash just for this.

  11. apoelstra commented on Aug 1, 2023

    @apoelstra
    Contributor

    Heh, your comment inspired me to actually read dual-EC-DBRG. It's quite elegant, other than the obvious backdoor, and also because it's obscenely slow. My read of it is that backdoor allows an attacker who has seen 30 (aligned) bytes of output can learn the internal state of the RNG, with a little bit (16 bits) of grinding. Once they know the internal state they can predict every future random number. However they cannot learn previous states of the RNG, and in particular there is no way for them to learn the seed, assuming the discrete log problem is hard.

    So for this particular usecase, dual-ec-drbg would actually be perfectly fine :P.

    But regarding scrypt parameters, let's back up a bit. I spoke on the phone with @roconnor-blockstream and we briefly touched on this. One observation he made is that any considerations applied to the identifier also applies to the shares (we should assume our attacker has at least one share; arguably we should assume they have k-1 shares). So I think we should do the same thing in both cases, and in this case the 20-bit ID is the least of our problem next to the key-length-many-bits shares.

    Our attack scenario is: an attacker has access to the 20-bit ID, a share, multiple shares, whatever. And they are trying to access the actual master secret. The scenarios are:

    • If the data was generated maliciously then the seed could just be leaked outright.
    • If the data was generated correctly but with a fast RNG, then an attacker can grind out the secret far faster (by a large constant factor) by re-generating the data than by deriving addresses.
    • If the data is generated by a slow RNG/KDF, then an attacker with the data has no advantage over one without.

    Having auditability forces us into the middle choice. If we don't worry about auditability then we'll be in either the "all good, no attacker advantage to having share data" case or the "all bad, the seed was totally leaked" case. And we can bias toward "all good" with the usual techniques. Open source software, independent audits, etc.

    Given this, my feeling is that if somebody cares enough to audit their random data by hand, they care enough to just generate all their data by hand (which would actually be much faster than auditing, no matter what RNG we chose). And if they don't care enough, they'd be better off if we used scrypt everywhere rather than chacha everywhere.

  12. BenWestgate commented on Aug 2, 2023

    @BenWestgate
    ContributorAuthor

    Auditing a wallet always requires a computer to verify the public keys. So hand auditable share sets are not very important considering as you say the share set could be generated by hand faster for the paranoid.

    That said, it makes sense for a given codex32 secret to always produce the same shares and share index order on electronics, but it is fine to require electronics to compute the shares with an expensive KDF.

    I initially used Scrypt because it was built in hashlib, but the current state-of-the-art for memory hard KDFs is Argon2id. Its main advantage is being able to set the time cost and memory cost independently, while with Scrypt the time cost depends on memory cost.

  13. apoelstra commented on Aug 2, 2023

    @apoelstra
    Contributor

    Agreed. I don't have much of an opinion between scrypt and argon2id. I'd be tempted to use scrypt because it's more widely supported and has been tested pretty heavily by the various cryptocurrencies using it a PoW. I'm also not sure there's a ton of value, for us, in being able to set time and memory separately. (And we could emulate that somewhat by just iterating scrypt.)

  14. BenWestgate commented on Aug 2, 2023

    @BenWestgate
    ContributorAuthor
    • If the data was generated correctly but with a fast RNG, then an attacker can grind out the secret far faster (by a large constant factor) by re-generating the [share] data than by deriving addresses.

    • If the data is generated by a slow RNG/KDF, then an attacker with the data has no advantage over one without.

    This means the KDF hardness parameters only need to cost more than deriving an address? The cheapest "address" is the master pubkey hash we suggest to use as default identifier.

    Cost to beat is: HMAC-SHA512, produce a pubkey, then ripemd160(sha256(master_pubkey))

    That has 1 in 2^20 chance of a false positive. (Or it's faked) In that case, a known address must be derived.

    Setting time cost and memory cost an order of magnitude (or more) higher than an address derivation should be safe and able to run on HWWs

  15. apoelstra commented on Aug 2, 2023

    @apoelstra
    Contributor

    This means the KDF hardness parameters only need to cost more than deriving an address? The cheapest "address" is the master pubkey hash we suggest to use as default identifier.

    Yeah, that's my thinking.

    Setting time cost and memory cost an order of magnitude (or more) higher than an address derivation should be safe and able to run on HWWs

    +1

  16. 77 remaining items

  17. BenWestgate commented on Aug 15, 2023

    @BenWestgate
    ContributorAuthor
        date = datetime.date.today().strftime("%Y%m%d") if forgot else '19700101'
        # must be displayed unconditionally for auditing purposes if forgot=True
        fingerprint = fingerprint_ident([master_seed])
        salt = codex32_secret[:9] + fingerprint + date
        derived_key = hashlib.pbkdf2_hmac('sha512', password=master_seed, salt=salt,
                            iterations=2048, dklen=64)
        if reshare:
            encryption_key = hashlib.pbkdf2_hmac('sha512', password=date, salt=salt,
                            iterations=2048, dklen=3)
    
            new_ident = old_ident ^ encryption_key

    Optionally, the hash_160(master_pubkey) may be dropped from salt as the wallet might not have access to it. Then the identifier can be decrypted / error detected with the correct fingerprint, the header used to generate the reshare and the birthdate.

  18. apoelstra commented on Aug 16, 2023

    @apoelstra
    Contributor

    If a secret S, share A, C have ecc padding, the threshold is 3 and the user finds D, E, F but F has an uncorrectable errors, can he ms32_interpolate() using the candidate share F over-corrections to secret S, share A and share C and then check if their ECC padding is valid to save work?

    I'm not sure what this means but I don't think so.

    I used the 8 digit year date with zero padding to maximize the chances someone would be able to recover the salt. While if someone forgets an identifier within 24 hours and reuses it they don't deserve security.

    They don't need to forget it to reuse it. They need to fail to actively override their wallet's default ident computation, because they are aware of this "24 hour" rule that doesn't exist in any other scheme. I think this is too much of a burden on users. And yes, users will want to do this more than once per day (likely more than once per second, if their hardware can compute fast enough).

    You also still have the problem that HWWs don't know the time, even to 24-hour precision.

    Who can write and confirm 2 codex32 share backups in 1 second?

    You don't need to write and confirm them before you start generating the next set. You might generate all the sets you need and then write/confirm them all.

    If HWWs don't have a clock, why don't we just ask the user for the date (or other unique string they've never typed before, avoids trusting the hardware to not leak data by chosen nonce.) And if the hardware has a clock, display the date used unconditionally so it can be audited.

    Because this is extra work for the user and has a nonobvious application (we are asking for a current date, which seems like an unnecessary and privacy-reducing thing to answer honestly, but actually we just want a unique string). And we can accomplish it by just generating random bytes.

    I really don't like any of these date-based ideas.

  19. BenWestgate commented on Aug 16, 2023

    @BenWestgate
    ContributorAuthor

    They need to fail to actively override their wallet's default ident computation...

    A wallet implementation should store the identifiers and bip32 fingerprints, ideally persistently. So that it never reuses them. Perhaps incrementing ident LSB or throwing error messages on collisions.

    You also still have the problem that HWWs don't know the time, even to 24-hour precision.

    Looks like HWW need (optional?) user provided uniqueness during reshares if all past IDs aren't stored/remembered.

    It's the only auditable way. But the odds they'll input a unique string is higher than directly choosing a new ident.

    You don't need to write and confirm them before you start generating the next set. You might generate all the sets you need and then write/confirm them all.

    Without a persistent counter, complete record of previously used identifiers or a unique input, ident uniqueness and determinism for auditability can not be simultaneously guaranteed (to 1/2^10).

    but actually we just want a unique string). And we can accomplish it by just generating random bytes.

    Agree completely it should be "unique string" and this is a strange hassle to protect users from forgetfulness. But on the other hand, it's less work than for a fresh master seed. 20-bits of unique user entropy fully protects the reshare vs 128-bits for fresh seeds.

    It's unsafe to let a wallet alone generate a random nonce for reshares, it could grind for ones that make your shares and/or the ident leak parts of the secret.

    If the random bytes are generated by KDF/HMAC(app_entropy, user_unique_string) with app entropy unconditionally displayed before the user types

    This is totally fine, the string "42" or "we" may be unique enough for the purpose.

    Anything never typed before is unique to 1/2^10, and perfect uniqueness is only achieved by storing and/or remembering every ident.

    I really don't like any of these date-based ideas.

    Can date, all we need is a unique input.

  20. apoelstra commented on Aug 16, 2023

    @apoelstra
    Contributor

    A wallet implementation should store the identifiers and bip32 fingerprints, ideally persistently

    Then these would need to be backed up along with the seed data?

    If we just used 96 random bits there would be no need for anyone to store anything, persistently or otherwise.

    Without a persistent counter, complete record of previously used identifiers or unique input, ident uniqueness and determinism for auditability can not be simultaneously guaranteed (1/2^10).

    Right. We should just drop auditability for re-sharing. It's not a common case and all the proposed solutions seem to have serious usability problems.

    If the random bytes are generated by HMAC(app_entropy, user_unique_string) with app entropy unconditionally displayed before the user types

    This seems good to me. Though I do think we should query for "a unique string" rather than trying to give it any other semantic content. And maybe suggest the user roll dice ten times to get the unique string, or something. If it's too short/predictable the wallet could theoretically still grind.

  21. BenWestgate commented on Aug 16, 2023

    @BenWestgate
    ContributorAuthor

    Then these would need to be backed up along with the seed data?

    In a digital backup, yes. On paper or metal, no.

    The list helps prevent ident reuse but it's not essential data, treat it like wallet metadata (ex: prevents address reuse)

    it's okay for ident to collide, they're likely to every 2^10 sets generated. It's only bad if payloads collide.

    I edited that to mention the app_entropy and unique_user_string should be hardened by PBKDF2 to slow grinding.

    How about encrypting (still using PBKDF2) the bip32 fingerprint by the unique string and using cipher text as new_ident?

    Then we don't lose the error correction and other benefits as long as user remembers the unique string.

  22. apoelstra commented on Aug 16, 2023

    @apoelstra
    Contributor

    How about encrypting (still using PBKDF2) the bip32 fingerprint by the unique string and using cipher text as new_ident?

    This is a neat idea. Yeah, let's do it. No need to use fancy encryption; just xor with the first 20 bits is fine.

  23. BenWestgate commented on Aug 16, 2023

    @BenWestgate
    ContributorAuthor

    This is a neat idea. Yeah, let's do it. No need to use fancy encryption; just xor with the first 20 bits is fine.

    XOR is good for encryption algorithm but "unique string" shouldn't be recoverable if someone knows bip32 fingerprint and the ciphertext so PBKDF2("unique string") is needed for key derivation.

    Non-zero chance people would enter names, birthdates, street numbers, PINs as a "unique string" and give a thief extra help.

    Share payloads, unlike new_ident would incorporate both app entropy and unique_string so those will never collide (if RNG Honest) even if ident collides.

  24. apoelstra commented on Aug 16, 2023

    @apoelstra
    Contributor

    Yep, agreed -- we'd xor with the (truncated) output of the PBKDF2.

  25. BenWestgate commented on Aug 16, 2023

    @BenWestgate
    ContributorAuthor

    Re-Share payloads derivation unlike new_ident would incorporate both app entropy (machine's random number) and user unique_string so those will not collide (if RNG Honest) even if ident does.

    This seems like the best way to be able to recover the bip32 fingerprint with no risk of payload collision (if honest RNG or unique string).

    Can't recover payloads with only master seed, header, and unique string. (Could brute force missing app entropy with a candidate share)

    But it's a niche case:
    Made a reshare set, lost some shares AND want to regenerate an identical set (without gathering a threshold from said set) to expand/repair it.

    It's not a useless feature but unique payloads for all sets is far more important.

    Reshare ident and its app_entropy could be stored as metadata to provide it.

    Reshare app_entropy only needs to be long enough to be unique for all times a user should be able to safely reshare a master_seed and fail to provide a unique string.

    1st reshare is always unique as no "unique string" was asked by fresh_master_seed.

    A single random word from a word list is enough to allow a user resharing a master seed to fail 90 times to provide a unique string before they get into trouble.

    Larger reshare app entropy is more bandwidth to attempt to leak secrets by grinding if unique_string is low entropy and predictable. Fortunately, the most redundant channel, ident, is under full user "unique string" control.

    So a balance seems important.

  26. apoelstra commented on Aug 17, 2023

    @apoelstra
    Contributor

    This seems like the best way to be able to recover the bip32 fingerprint with no risk of payload collision (if honest RNG or unique string).

    Yep, agreed with everything in this comment. I think it's more important to be resilient and avoid collisions than to support increasingly-niche regeneration usecases. Ultimately, if the user has the master seed, if need be they can just redo everything. (And if they don't, none of our regeneration schemes would help them anyway.)

  27. BenWestgate commented on Aug 18, 2023

    @BenWestgate
    ContributorAuthor

    Encrypting the bip32 fingerprint, all I have to salt "unique string" with is ms1<threshold> and codex32 string length. It's going to be easy to brute force low entropy "unique strings" if attacker knows the bip32 fingerprint. Is that okay? Might need to emphasize if the string is if low entropy it is public (writing it along w/ the share makes the share as good as fingerprint labeled). Nothing secret, private, personally identifiable or useful to an attacker should be used for it. Or sufficient dice rolls must be appended to protect it.

    Once it contains over 20-bits entropy, exhaustive attack begins to return 2^ (entropy - 20) possible passwords. Easiest to not use information they wouldn't mind telling a thief or adversary.

    The inclusion of length and threshold in salt gives the re-shares some error detection for these when the "unique string" and fingerprint are known. Corrections can be quickly brute forced as there's under 9-bits total entropy for every length and threshold 4.6-bits if lengths are limited to 16, 32, 64.

    Is this relationship between "unique string", fingerprint, length and threshold useful enough original shares should have it? (by using '' as the unique string.) or is the ID being the direct bech32 encoding of the fingerprint directly most useful?

  28. apoelstra commented on Aug 18, 2023

    @apoelstra
    Contributor

    You need to know more than the bip32 fingerprint - you need to know app_entropy as well, no?

  29. BenWestgate commented on Aug 18, 2023

    @BenWestgate
    ContributorAuthor

    You need to know more than the bip32 fingerprint - you need to know app_entropy as well, no?

    It's possible to use password = unique_string + app_entropy, to encrypt fingerprint.

    But it's not necessary since the ident doesn't need to be random, it gives an evil wallet an opportunity to leak secrets into the ident by grinding and it would require storing/remembering the app entropy to decrypt the fingerprint to know what seed shares belonged to.

    So I only used app_entropy for payload generation and index shuffling. The identifier is set from fingerprint and user selected info only (unique string, threshold, share length).

  30. apoelstra commented on Aug 18, 2023

    @apoelstra
    Contributor

    But it's not necessary since the ident doesn't need to be random, it gives an evil wallet an opportunity to leak secrets into the ident by grinding and it would require storing/remembering the app entropy to decrypt the fingerprint to know what seed shares belonged to.

    True, but it also puts the onus on the user to produce an actually-unique string, without the wallet "bailing him out" by providing its own unique input. I guess this is okay. It feels a little bit fragile. But the tradeoff is that it avoids grinding attacks. So I could go either way.

    Nothing secret, private, personally identifiable or useful to an attacker should be used for it. Or sufficient dice rolls must be appended to protect it.

    Probably best to just say "An attacker can learn this data! Do not use secrets." rather than trying to communicate something more nuanced. We could word it in a friendlier way ("This data is not treated as secret. If you input secret data it will harm your security" or something).

  31. BenWestgate commented on Aug 18, 2023

    @BenWestgate
    ContributorAuthor

    But it's not necessary since the ident doesn't need to be random, it gives an evil wallet an opportunity to leak secrets into the ident by grinding and it would require storing/remembering the app entropy to decrypt the fingerprint to know what seed shares belonged to.

    True, but it also puts the onus on the user to produce an actually-unique string, without the wallet "bailing him out" by providing its own unique input. I guess this is okay. It feels a little bit fragile. But the tradeoff is that it avoids grinding attacks. So I could go either way.

    Not just grinding secrets into the ident, it makes the password to decrypt the fingerprint easier to remember & store. The worst case is they reuse unique_string for the same master_seed and get the same ident, but if shares from different sets of the master_seed are combined, they'll produce a fingerprint that doesn't match the ident and unique_string.

    Like I said earlier, the device could choose & display a random word or 20-bits of entropy before user types unique_string to protect the user from reused ident, or password cracking, it's not that bad either way.

    I'll code it both ways since they differ by a couple lines and a third opinion or alpha tester feedback can decide.

    It might be confusing "what's this extra word all about?" Now suddenly language and wordlists become a (possible) part of resharing, if app_entropy is 4 random bech32 characters, I'm positive it'll be hard to remember, begs the question too: why not just memorize or write the 4 char encryption key of the fingerprint directly.

    So the only useful option seems to be deriving the fingerprint encryption key from:

    1. Less than 20-bits length (saves writing) or
    2. Memorable strings (increase odds of recovering fingerprint) or
    3. prepended app_entropy so small it doesn't interfere with the above, like 1-2 bech32 characters, or a single hex byte.
      • significantly protects against unique_string & master_seed reuse at least once.

    The best protection against reuse is keeping a list of already used ident.

    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.

    Either solution complies, and checking ident reuse against a list is better but harder to audit and backup. This is how Bitcoin wallets solved the similar "address reuse" problem.

    ident list needs to be unconditionally displayed for auditability. (ie so it would not blacklist every ident except one leaking 20-bits of secret.), but now how do you know the list is correct, it ends up being something the auditor has to store and update independent of the wallet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions