NUT28 - ECDH-derived Pay-to-Blinded-Key (P2BK) #300
No reviewers
Labels
No labels
breaking change
bug
documentation
enhancement
needs discussion
needs implementation
new nut
ready
wallet-only
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo-admin/nuts!300
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "p2bk-silent"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
CLOSES #290
REPLACES: #291
Implementations:
Summary:
Defines P2BK as an ECDH-derived blinding scheme instead of one using random scalars.
Each proof now includes a per-proof ephemeral pubkey
p2pk_e, from which both parties can derive the same blinding factor(s) deterministically.ECDH shared secret works because:
Importantly, 3rd parties and the mint CANNOT derive the original locking pubkeys. Only the sender and the receiver have the secret keys required to calculate the ECDH shared secret, which can derive both the original pubkeys and the signing secret.
Proofs can be locked to a well known public key, posted in public without compromising privacy, and spent by the recipient without needing any side-channel communication.
Key points:
p2pk_e(33-byte SEC1 pubkey) per proof (stored aspein token v4 format)rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || keyset_id_bytes || i_byte)where Zx is a shared ECDH secret,
keyset_id_bytesis the hex_to_bytes of keyset ID, andi_byteis the P2PK locking key "slot" position.Assumptions
Live Demo:
this file was probably added by accident
You've added a package.json file, propably by accident
Good catch - thanks @d4rp4t. Fixed
@robwoodgate We should derive a
Z_ifor each locking key listed in the secret, so that the knowledge of one blinding factor doesn't necessarily imply knowledge of every other. This way, we can also throw away the slot indices (i \in [0, 10]).Use case example:
I've opened a PR here
We already DO calculate shared secret (Zx) per locking key.
The slot index is just for ADDITIONAL uniqueness, so that if the same key (P) is added to both pubkeys and refund, it will be uniquely blinded by the slot index.
Only the sender knows the ephemeral secret, so only they can derive Zx per locking key (eP)
The receiver(s) only know the ephemeral pubkey (E) and their own secret key (p), so they can only generate the shared secret for their own key (pE).
EDIT: I've added some clarifying note blocks, because it's a crucial point that is easy to overlook.
@robwoodgate I think we can avoid the
p2pk_eentirely and instead use thenonceinside of secret more cleverly by setting itnonce = e*G.Love this idea! That would be the ultimate privacy move because P2BK proofs would be totally indistinguishable from standard P2PK proofs. And making the
Ethe nonce would effectively enforce wallets to ensure uniqueness per proof.The only downside is the privacy benefit lol... there would be no way to know if a proof was blinded or not, so you would have to try signing EVERY P2PK proof that doesn't have your pubkey with both your secret key (p) and both your derived secret keys (p') to be sure it's not yours.
Overall, I think that's probably a tradeoff worth making for the privacy. And very in line with Bitcoin silent payments.
Anyone disagree?
Thinking about this some more this afternoon.... a possible reason to not do this:
if the Mint knows a P2BK
Emight be held in the secret nonce, could it possibly discriminate against P2BK proofs in some way by checking if the nonce is a valid curve x-coordinate?(Though around half of all 32 byte string nonces would naturally be valid x-coordinates in any case...)
@robwoodgate around ~
\frac{1}{2}scalars are the x-coordinate to a public key. If the inputs to a TX aren, that givesnindependent ecash notes all having a valid public key in the nonce field. That has\bigl(\frac{1}{2}\bigr)^nprobability of happening randomly.Though this could be easily fixed if newer wallets always use EC public keys as nonces, even for normal p2pk. The
noncefield as of now isn't used for anything else than guaranteeing uniqueness between secrets locked to the same key.That would alleviate the discrimination concern for sure. It would also go some way to alleviating the related concern that using the
secret.noncewould reveal ALLE's to the mint, not just those in proofs posted publicly.Discussed off proposal:
SIG_ALLwould break under P2BK conditions, because eachProofrequires a uniqueE.SIG_ALLprovides that all fields in the structured secret across all inputs/outputs (apart from thenonce) be the same, Therefore we have a conflict andSIG_ALLP2BK proofs wouldn't be spendable.Two paths to resolution:
Ecan be shared across a batch ofSIG_ALLproofs. This option precludes the possibility of settingnonce = Einside the secret, as everynoncemust be unique. Usingp2pk_edegrades privacy because its possible for third parties (including the Mint) to single out P2BK proofs;SIG_ALLfor P2BK proofs, and always setnonce=e*Gfor every NUT-10 secret (whether they are P2BK or not).I would personally prefer option 2, especially given the Mint can find out who is using silent payments easily through option 1.
We should be more precise about the format of the stuff that goes in the hash function:
Zxis raw bytes or the utf-8 encoded hex representation of the bytes?keyset_id.i1 byte or is it a radix-10 utf-8 stringCalculating the remainder of the division by
nrequires switching to a representation on a multiple precision library (bigint) which are unfit to do operations on secret values and could also potentially introduce a new dependency on some implementations that are not already relying on them.I would suggest rejection sampling instead:
rᵢ = H("Cashu_P2BK_v1" || Zx || keyset_id || i || counter).The Determinism section specifies raw bytes for hash inputs but yes, it could be spelled out here too.
We should also add a full worked example including secret keys to ensure implementations can test against it.
Mod n is unavoidable in the derivation step, so not sure this would help much?
That said, there is a retry built in (again in the Determinism section) so can be easily done.
In any case, we should ensure the NUT makes the details laid out in the Cashu-TS reference implementation explicit.
If you meant when deriving
k = p + r (mod n)then yes... BUT. The secp256k1 library (and relative rust bindings, python bindings etc...) have a notion of secret key and scalars, while noble secp (typescript) does not. In cashu-ts you are FORCED to use BigInt for secret key tweaking operations, but that doesn't mean it's the case for all other implementations.I think it should be laid out here in the derivation scheme by adding a counter at the end.
The
mod nis to indicate it is a valid point on the curve. In the cashu-ts reference implementation, I allowed for a single retry in caser=0. This is what the Determinism section references:@robwoodgate
An invalid key can be
key = 0orkey >= N. So it's not really\frac{1}{N}. I think it makes most sense to add a counter (not just a 0xFF byte) and retry up to 16 times (just like hash-to-curve does)I think this is what makes most sense.
To summarize the dilemma:
Option 1: Carry
Ein theProof.p2pk_efieldPros
SIG_ALLbecause the sameEcould be used across all the proofs to ensure all proofs carry the same tags as perSIG_ALLspec.p2pk_efield is stripped before being sent to the Mint.Cons
p2pk_eis later stripped.)Option 2: Carry
Ein theProof.secret.noncefieldPros
p2bk_efield.p2bk_ebefore sending to the Mint.Cons
SIG_ALLbecause the sameEcannot be used across all the proofs (nonce MUST be unique), and by extension, all proofs would not carry the same tags as perSIG_ALLspec.Eto the mint in ALL cases (inc for proofs never posted publicly). This could possibly allow discrimination unless ALL NUT-10 secret nonces are required to be valid 33 byte points on the SEC1 curve.EDIT - if we can specify NUT-10 nonces SHOULD be random 33-byte SEC1 pubkeys, then am also leaning towards Option 2.
Cool, yes using the hash-to-curve counter idea works for me. Want to PR some amends to incorporate this and polish the spec?
@robwoodgate I've opened a PR here with some small changes I felt necessary.
Discussed again off proposal: The general feeling was to go with
Proof.p2pk_ebecause:Overall, reason 2 (loss of SIG_ALL compatibility) was seen as the main reason to NOT use the nonce as the carrier.
Resolved - PR was merged, and we removed the requirement to mod n the blinding tweak, preferring instead to check the range for broader compatibility.
@robwoodgate We could simplify the parity detection on the receiver side if we compared the x-only of the unblinded public key:
P' - r*G = -/+ p*G + r*G - r*G = -/+ p*G = -/+ PCompare: x-only(P) == x-only(p*G)But this is more of an implementation choice. We should however mention in the NUT that this is possible.
I don't think we need to mention implementation detail in the NUT.
In cashu-ts, the aim was to achieve algorithmic constant time, so both
skcandidates are always calculated and the correct one chosen at the end.You are correct the original pubkey
Pcan be obtained onceris derived. I think you are proposing:It's not much of a simplifcation, as the blinded private key still needs to be derived in any case.
Overall, the parity detection issue is nothing to do with Pubkeys, it depends on whether the receiver secret key
pis stored normalized for Schnorr or not. BIP-340 doesn't mandate (AFAIK) that a secret key should be normalized to the form that always creates even-Y pubkey, it only specifies that pubkeys be even-Y normalized.So a wallet/Nostr client etc might allow a negative-Y generating sk to be stored, because it is flipped 'on the fly'.
We therefore will always need to check both for Schnorr derived pubkeys.
To me, this sounds like a semplification. You trade in 2 point-scalar multiplications and 1 point addition for 1 point-scalar multiplication and 1 addition.
I understand now - yes, you can save a point multiply, and the approach is sound. I will revisit the cashu-ts reference implementation though for optimization.
@lollerfirst - I've now added this as the primary workflow. As we have one in the spec, it may as well be the optimal one!
@lollerfirst - I've aded a comprehensive test vector page which will allow implementors to double-check a concrete example across all slots.
I wonder if the
nut26field should be defined directly inside NUT-18 (as the NUT-10 field is).Otherwise it can be missed by implementors, if it is only in this NUT.
@callebtc @thesimplekid - We now have implementations in review for Cashu-TS and CDK, so this PR is ready for review too. There is one question I have about whether we update NUT-18 to show p2pk_e as a default, LMK if I should add that.
I think it should stay here. Otherwise by the same logic we should modify NUT-00 to include
p2pk_ein theProofandTokenobjects.Formalized to NUT-26, ready for merge.
Added reference to the DotNut implementation (https://github.com/Kukks/DotNut/pull/21)
I hate to even suggest this but do we think there will be other use cases in the future that will want to extend the payment request? Would it make sense to add a tags field that is an array of array of strings this would be more consistent with NUT10 where we have the tags in the secret.
I see the comment here. I'm okay if we keep it as it just gonna leave my comment about the future extension path.
Is there a reason to have both?
Sadly yes... if we don't want NOSTR users hating Cashu.
The problem is that BIP-340 doesn't require secret keys to be normalized. It only requires the pubkey to be lifted to X-Parity. So a Nostr user with a secret key that derives a natural Y-parity pubkey will not be able to derive a working secret key unless that key was normalized for them.
For regular SEC1 pubkey it doesn't matter - the parity is always the natural one.
Added cross references to NUT-14 (HTLC) and noted that the
dataslot (hash) is not to be blinded for HTLC proofs.Agreed, I think this deserves its own PR. @robwoodgate maybe we can edit to NUT-18 after a separate discussion?
I have reversed the changes to NUT-18.
The new NUT-26 still talks about extending it, so we will need to be updated in the new PR if the signalling schema changes for payment request.
I suggest that for future extensions, the setting in the NUT-18 PR should be a JSON.
something like this has a better upgradeability
I applied changes in f16c9c4 before realizing that the NUT-18 specs have moved from the NUT-18 document into the NUT-26 doc. I assumed we're going to define that in a separate PR. I made suggestions because the way it's suggested here will be hard to upgrade in the future. I think it should be left for another day and deserves more thought.
Large parts of the spec read AI generated. Please remove all AI parts. The spec could probably be compressed by 50% by taking out the slop.
redundant
this should probably be a test vector
not sure if this fits the spec.
"P2BK-enhanced"?
Removed all NUT-18 references for now. Let's rethink that part
Have totally re-written from scratch to remove all the cruft and NUT-18 references.
What does 'abort' mean in practice here:
That the sender must abandon this ephemeral key pair and generate a new
eandEand try again?Also, why do we have this two-stage process where we try without the
0xffsuffix and once with? Couldn't we just skip the retry and tell the sender to keep generatingeuntil they find a suitabler_i?(I feel like I might be missing some other justification. So sorry for dumb questions 😀. I'm finally putting blinding into the Spilman channel implementation and am hoping to fully understand this NUT in order to maximize compatibility between this NUT and the channel's blinding system)
Correct!
The two step process is to ensure the app doesn't have to worry about starting over in the unlikely event the value is out of range. It keeps this "problem" in the crypto aware scope rather than pushing it further up the app stack.
It's so astronomically unlikely to ever happen (as N ≈ 2²⁵⁶ × 0.9999999999999999992559580114556583) that a simple one-step retry is plenty to mean an abort is practically impossible.
I did originally spec (and would prefer) the
rcalculationMODULO N, which would have reduced the invalid case to literal0(a 1 in 2^256 chance), but this is apparently problematic in RUST (CDK) at the moment.We have the same discussion around NUT-13.
Rebased to main, added code example.
@callebtc - can you re-review when you get some time please.
Shouldn't
pehere bep2pk_e?No, the token v4 format uses abbreviated key names. See NUT-26 - "Proof Object Extension" section:
Do we need to include the keyset here?
I ask because of a problem in the Spilman Channel, where I think I'd like to remove the keyset_id from this. I have the blinding fully working in the channel today, but not (yet) exactly following the system described in this NUT.
TLDR: If a SIG_ALL swap locks output to a blinded pubkey, and the swap then sends the outputs to an unexpected keyset, then the unblinding won't work because the keyset is wrong. To clarify: I'm not talking about the inputs to this swap, I'm talking about the outputs of the swap, where the outputs are blinded P2BK
If I sign a two-party transaction with SIG_ALL then it commits to the amounts in the outputs, but it does not commit to the keyset in those outputs. Imagine the second party adds their signature and performs the swap. The second party decides what keyset those outputs will be in, and this might not be the keyset that I was expecting. They are free to choose any active keyset with the right unit.
If the outputs commit to blinded pubkeys, then things will be strange. I computed the blinded pubkey on one keyset, because I assumed the swap would create outputs in the same keyset as the inputs to the swap. But then the outputs are in a different keyset, and I would need to write special code to unblind and sign those outputs; special code which uses the 'expected keyset' instead of the actual keyset
The keyset ID ensures the blinding factor is unique between mints (and keysets) to avoid privacy leakage in case of ephemeral key reuse.
How about (instead) including the secret'snoncein this derivation?actually, ignore that particular question from me about the
nonce. It would break SIG_ALL, which requires all the proofs to have the same (blinded) public keyI would still like to remove the keyset_id from the derivation, for the reason described at the start of this thread
@callebtc, @a1denvalu3, @thesimplekid - thoughts?
@robwoodgate I think we can make this sacrifice, because -if there is nonce re-use- putting the keyset ID in the derivation only protects in the case the nonce reuse is across different keysets
Realistically we're not going to have nonce re-use.
I think we will now need to change the number of the NUT because bech got merged first.
Yeah 🤦♂️
Done.
If there is nonce reuse, that is "dOInG-iT-WroNG", so yeah, we probably don't need to protect against that.
Have removed keyset ID from the blinding factor calculation, re-calculated all test vectors and renamed to NUT-28 (as BECH32 got merged first).
Have removed keyset id as requested
Rebased to main to resolve a merge conflict.
Filenames now set for immediate merge as NUT-28
Thanks for the last small changes. Looks good
I suggest to removing this, or we would have to add all possible extensions here (witness for example)
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)we should spell it out once before abbreviating
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)we didn't define what the long-lived public key is yet (next sentence). we only speak about the receiver's pubkey but the sentence sounds like it protects the sender's pubkey. suggest to just remove the sentence.
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)I know this issue (backwards compat) but why not? Every private key should be normalized.
@ -0,0 +49,4 @@rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )```If `rᵢ` is still not in the range `1 ≤ rᵢ ≤ n−1`, abort and discard the ephemeral keypair.It would allow us to delete this part
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +82,4 @@// Astronomically unlikely to get here!throw new Error("P2BK: tweak derivation failed");}}suggest: do not use own normalization, let the crypto lib handle it?
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +119,4 @@The receiver must therefore derive the correct blinded private key (`k`). Because BIP-340 lifts public keys to even-Y parity, there are two possible derivation paths:- Standard derivation: `k = (p + rᵢ) mod n`- Negated derivation: `k = (-p + rᵢ) mod n`kis undefined@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)I agree. I specced it modulo n in the intial draft, but received feedback that RUST (CDK) would have problems doing modulo n calculations.
So that's why it was re-drafted as "tweak-and-single-retry, abandon ephemeral key if still out of range"
Modulo n is the far simpler implementation, if it can be supported consistently.
That's the backwards compatibility issue. Happy to go either way - just make the call.
My concern was that a reader has to know all the NUTS to understand the possible shape of a proof. But happy to remove.
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)@ -0,0 +82,4 @@// Astronomically unlikely to get here!throw new Error("P2BK: tweak derivation failed");}}If RUST can handle the
modulo n, it's a thumbs up from me. (@thesimplekid )@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)The idea here was to highlight to sender that using an ephemeral keypair protects their own (long lived) public key from being linked to the proofs.
We can remove the sentence, but I think it's an important feature of P2BK... it provides privacy for both sender AND receiver.
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)That's exactly right - using an ephemeral keypair does protect the senders pubkey :)
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)modulo
ncould technically give us0right?It's so unlikely, I guess we could ignore it
But if we want to avoid that unlikely event, we could do modulo (n-1) and then add one
i.e.
r = 1 + sha256(...) % (n-1), where%is modulo, in order to ensure that we get a number between1andn-1(inclusive). Although hopefully the cryptography library does something like this already@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)I believe the usual practice is to do standard modulo n and discard/retry if zero.
@ -0,0 +82,4 @@// Astronomically unlikely to get here!throw new Error("P2BK: tweak derivation failed");}}We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
@ -0,0 +49,4 @@rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )```If `rᵢ` is still not in the range `1 ≤ rᵢ ≤ n−1`, abort and discard the ephemeral keypair.We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)clarified
@ -0,0 +1,176 @@# NUT-28: Pay-to-Blinded-Key (P2BK)We agreed offline to keep tweak and retry, as not all implementations can handle modulo n reduction
LGTM