Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wallet: NUT-11 P2PK #33

Merged
merged 3 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 77 additions & 13 deletions cashu/cashu.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,52 @@ package cashu

import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"

"github.com/decred/dcrd/dcrec/secp256k1/v4"
)

type SecretKind int

const (
Random SecretKind = iota
P2PK
)

// Cashu BlindedMessage. See https://github.com/cashubtc/nuts/blob/main/00.md#blindedmessage
type BlindedMessage struct {
Amount uint64 `json:"amount"`
B_ string `json:"B_"`
Id string `json:"id"`

// including Witness field for now to avoid throwing error when parsing json
// from clients that include this field even when mint does not support it.
Amount uint64 `json:"amount"`
B_ string `json:"B_"`
Id string `json:"id"`
Witness string `json:"witness,omitempty"`
}

func NewBlindedMessage(id string, amount uint64, B_ *secp256k1.PublicKey) BlindedMessage {
B_str := hex.EncodeToString(B_.SerializeCompressed())
return BlindedMessage{Amount: amount, B_: B_str, Id: id}
}

func SortBlindedMessages(blindedMessages BlindedMessages, secrets []string, rs []*secp256k1.PrivateKey) {
// sort messages, secrets and rs
for i := 0; i < len(blindedMessages)-1; i++ {
for j := i + 1; j < len(blindedMessages); j++ {
if blindedMessages[i].Amount > blindedMessages[j].Amount {
// Swap blinded messages
blindedMessages[i], blindedMessages[j] = blindedMessages[j], blindedMessages[i]

// Swap secrets
secrets[i], secrets[j] = secrets[j], secrets[i]

// Swap rs
rs[i], rs[j] = rs[j], rs[i]
}
}
}
}

type BlindedMessages []BlindedMessage

func (bm BlindedMessages) Amount() uint64 {
Expand All @@ -41,16 +71,50 @@ type BlindedSignatures []BlindedSignature

// Cashu Proof. See https://github.com/cashubtc/nuts/blob/main/00.md#proof
type Proof struct {
Amount uint64 `json:"amount"`
Id string `json:"id"`
Secret string `json:"secret"`
C string `json:"C"`

// including Witness field for now to avoid throwing error when parsing json
// from clients that include this field even when mint does not support it.
Amount uint64 `json:"amount"`
Id string `json:"id"`
Secret string `json:"secret"`
C string `json:"C"`
Witness string `json:"witness,omitempty"`
}

func (p Proof) IsSecretP2PK() bool {
return p.SecretType() == P2PK
}

func (p Proof) SecretType() SecretKind {
var rawJsonSecret []json.RawMessage
// if not valid json, assume it is random secret
if err := json.Unmarshal([]byte(p.Secret), &rawJsonSecret); err != nil {
return Random
}

// Well-known secret should have a length of at least 2
if len(rawJsonSecret) < 2 {
return Random
}

var kind string
if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil {
return Random
}

if kind == "P2PK" {
return P2PK
}

return Random
}

func (kind SecretKind) String() string {
switch kind {
case P2PK:
return "P2PK"
default:
return "random"
}
}

type Proofs []Proof

// Amount returns the total amount from
Expand Down
38 changes: 38 additions & 0 deletions cashu/cashu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,41 @@ func TestTokenToString(t *testing.T) {
}
}
}

func TestSecretType(t *testing.T) {
tests := []struct {
proof Proof
expectedKind SecretKind
expectedIsP2PK bool
}{
{
proof: Proof{Secret: `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`},
expectedKind: P2PK,
expectedIsP2PK: true,
},

{
proof: Proof{Secret: `["DIFFERENT", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[]}]`},
expectedKind: Random,
expectedIsP2PK: false,
},

{
proof: Proof{Secret: `someranadomsecret`},
expectedKind: Random,
expectedIsP2PK: false,
},
}

for _, test := range tests {
kind := test.proof.SecretType()
if kind != test.expectedKind {
t.Fatalf("expected '%v' but got '%v' instead", test.expectedKind.String(), kind.String())
}

isP2PK := test.proof.IsSecretP2PK()
if isP2PK != test.expectedIsP2PK {
t.Fatalf("expected '%v' but got '%v' instead", test.expectedIsP2PK, isP2PK)
}
}
}
53 changes: 53 additions & 0 deletions cashu/nuts/nut10/nut10.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package nut10

import (
"encoding/json"
"errors"
"fmt"

"github.com/elnosh/gonuts/cashu"
)

type WellKnownSecret struct {
Nonce string `json:"nonce"`
Data string `json:"data"`
Tags [][]string `json:"tags"`
}

// SerializeSecret returns the json string to be put in the secret field of a proof
func SerializeSecret(kind cashu.SecretKind, secretData WellKnownSecret) (string, error) {
jsonSecret, err := json.Marshal(secretData)
if err != nil {
return "", err
}

secretKind := kind.String()
secret := fmt.Sprintf("[\"%s\", %v]", secretKind, string(jsonSecret))
return secret, nil
}

// DeserializeSecret returns Well-known secret struct.
// It returns error if it's not valid according to NUT-10
func DeserializeSecret(secret string) (WellKnownSecret, error) {
var rawJsonSecret []json.RawMessage
if err := json.Unmarshal([]byte(secret), &rawJsonSecret); err != nil {
return WellKnownSecret{}, err
}

// Well-known secret should have a length of at least 2
if len(rawJsonSecret) < 2 {
return WellKnownSecret{}, errors.New("invalid secret: length < 2")
}

var kind string
if err := json.Unmarshal(rawJsonSecret[0], &kind); err != nil {
return WellKnownSecret{}, errors.New("invalid kind for secret")
}

var secretData WellKnownSecret
if err := json.Unmarshal(rawJsonSecret[1], &secretData); err != nil {
return WellKnownSecret{}, fmt.Errorf("invalid secret: %v", err)
}

return secretData, nil
}
54 changes: 54 additions & 0 deletions cashu/nuts/nut10/nut10_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package nut10

import (
"reflect"
"testing"

"github.com/elnosh/gonuts/cashu"
)

func TestSerializeSecret(t *testing.T) {
secretData := WellKnownSecret{
Nonce: "da62796403af76c80cd6ce9153ed3746",
Data: "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e",
Tags: [][]string{
{"sigflag", "SIG_ALL"},
},
}

serialized, err := SerializeSecret(cashu.P2PK, secretData)
if err != nil {
t.Fatalf("got unexpected error: %v", err)
}

expected := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`

if serialized != expected {
t.Fatalf("expected secret:\n%v\n\n but got:\n%v", expected, serialized)
}
}

func TestDeserializeSecret(t *testing.T) {
secret := `["P2PK", {"nonce":"da62796403af76c80cd6ce9153ed3746","data":"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e","tags":[["sigflag","SIG_ALL"]]}]`
secretData, err := DeserializeSecret(secret)
if err != nil {
t.Fatalf("got unexpected error: %v", err)
}

expectedNonce := "da62796403af76c80cd6ce9153ed3746"
if secretData.Nonce != expectedNonce {
t.Fatalf("expected nonce '%v' but got '%v' instead", expectedNonce, secretData.Nonce)
}

expectedData := "033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e"
if secretData.Data != expectedData {
t.Fatalf("expected data '%v' but got '%v' instead", expectedData, secretData.Data)
}

expectedTags := [][]string{
{"sigflag", "SIG_ALL"},
}
if !reflect.DeepEqual(secretData.Tags, expectedTags) {
t.Fatalf("expected tags '%v' but got '%v' instead", expectedTags, secretData.Tags)
}
}
129 changes: 129 additions & 0 deletions cashu/nuts/nut11/nut11.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package nut11

import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/elnosh/gonuts/cashu"
"github.com/elnosh/gonuts/cashu/nuts/nut10"
)

type P2PKWitness struct {
Signatures []string `json:"signatures"`
}

// P2PKSecret returns a secret with a spending condition
// that will lock ecash to a public key
func P2PKSecret(pubkey string) (string, error) {
// generate random nonce
nonceBytes := make([]byte, 32)
_, err := rand.Read(nonceBytes)
if err != nil {
return "", err
}
nonce := hex.EncodeToString(nonceBytes)

secretData := nut10.WellKnownSecret{
Nonce: nonce,
Data: pubkey,
}

secret, err := nut10.SerializeSecret(cashu.P2PK, secretData)
if err != nil {
return "", err
}

return secret, nil
}

func AddSignatureToInputs(inputs cashu.Proofs, signingKey *btcec.PrivateKey) (cashu.Proofs, error) {
for i, proof := range inputs {
hash := sha256.Sum256([]byte(proof.Secret))
signature, err := schnorr.Sign(signingKey, hash[:])
if err != nil {
return nil, err
}
signatureBytes := signature.Serialize()

p2pkWitness := P2PKWitness{
Signatures: []string{hex.EncodeToString(signatureBytes)},
}

witness, err := json.Marshal(p2pkWitness)
if err != nil {
return nil, err
}
proof.Witness = string(witness)
inputs[i] = proof
}

return inputs, nil
}

func AddSignatureToOutputs(
outputs cashu.BlindedMessages,
signingKey *btcec.PrivateKey,
) (cashu.BlindedMessages, error) {
for i, output := range outputs {
msgToSign, err := hex.DecodeString(output.B_)
if err != nil {
return nil, err
}

hash := sha256.Sum256(msgToSign)
signature, err := schnorr.Sign(signingKey, hash[:])
if err != nil {
return nil, err
}
signatureBytes := signature.Serialize()

p2pkWitness := P2PKWitness{
Signatures: []string{hex.EncodeToString(signatureBytes)},
}

witness, err := json.Marshal(p2pkWitness)
if err != nil {
return nil, err
}
output.Witness = string(witness)
outputs[i] = output
}

return outputs, nil
}

func IsSigAll(secret nut10.WellKnownSecret) bool {
for _, tag := range secret.Tags {
if len(tag) == 2 {
if tag[0] == "sigflag" && tag[1] == "SIG_ALL" {
return true
}
}
}

return false
}

func CanSign(secret nut10.WellKnownSecret, key *btcec.PrivateKey) bool {
secretData, err := hex.DecodeString(secret.Data)
if err != nil {
return false
}

publicKey, err := secp256k1.ParsePubKey(secretData)
if err != nil {
return false
}

if reflect.DeepEqual(publicKey.SerializeCompressed(), key.PubKey().SerializeCompressed()) {
return true
}

return false
}
Loading
Loading