gift: unwrap tries both decoupled and identity keys, wrap defaults to decoupled but accepts flags to change that.

This commit is contained in:
fiatjaf
2025-12-30 15:28:06 -03:00
parent 8334474f96
commit 81524de04f
3 changed files with 140 additions and 54 deletions

188
gift.go
View File

@@ -24,13 +24,7 @@ var gift = &cli.Command{
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`, a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true, DisableSliceFlagSeparator: true,
Flags: append( Flags: defaultKeyFlags,
defaultKeyFlags,
&cli.BoolFlag{
Name: "use-direct",
Usage: "Use the key given to --sec directly even when a decoupled key exists.",
},
),
Commands: []*cli.Command{ Commands: []*cli.Command{
{ {
Name: "wrap", Name: "wrap",
@@ -40,6 +34,14 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
Aliases: []string{"p", "tgt", "target", "pubkey", "to"}, Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true, Required: true,
}, },
&cli.BoolFlag{
Name: "use-our-identity-key",
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
},
&cli.BoolFlag{
Name: "use-their-identity-key",
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
},
}, },
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient", Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
Description: `example: Description: `example:
@@ -56,18 +58,39 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
return fmt.Errorf("failed to get sender pubkey: %w", err) return fmt.Errorf("failed to get sender pubkey: %w", err)
} }
var using bool
var cipher nostr.Cipher = kr var cipher nostr.Cipher = kr
// use decoupled key if it exists // use decoupled key if it exists
configPath := c.String("config-path") using = false
eSec, has, err := getDecoupledEncryptionKey(ctx, configPath, sender) if !c.Bool("use-our-identity-key") {
if has { configPath := c.String("config-path")
if err != nil { eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
return fmt.Errorf("decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err) if has {
if err != nil {
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
}
cipher = keyer.NewPlainKeySigner(eSec)
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
using = true
} }
cipher = keyer.NewPlainKeySigner(eSec) }
if !using {
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
} }
recipient := getPubKey(c, "recipient-pubkey") recipient := getPubKey(c, "recipient-pubkey")
using = false
if !c.Bool("use-their-identity-key") {
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
recipient = theirEPub
using = true
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
}
}
if !using {
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
}
// read event from stdin // read event from stdin
for eventJSON := range getJsonsOrBlank() { for eventJSON := range getJsonsOrBlank() {
@@ -137,14 +160,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
Name: "unwrap", Name: "unwrap",
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).", Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
Description: `example: Description: `example:
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`, nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
Flags: []cli.Flag{
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c) kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil { if err != nil {
@@ -157,19 +173,18 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
return err return err
} }
var cipher nostr.Cipher = kr ciphers := []nostr.Cipher{kr}
// use decoupled key if it exists // use decoupled key if it exists
configPath := c.String("config-path") configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionKey(ctx, configPath, receiver) eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
if has { if has {
if err != nil { if err != nil {
return fmt.Errorf("decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err) return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
} }
cipher = keyer.NewPlainKeySigner(eSec) ciphers = append(ciphers, kr)
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
} }
sender := getPubKey(c, "sender-pubkey")
// read gift-wrapped event from stdin // read gift-wrapped event from stdin
for wrapJSON := range getJsonsOrBlank() { for wrapJSON := range getJsonsOrBlank() {
if wrapJSON == "{}" { if wrapJSON == "{}" {
@@ -185,36 +200,79 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind) return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
} }
ephemeralPubkey := wrap.PubKey // decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
var cipher nostr.Cipher
// decrypt seal
sealJSON, err := cipher.Decrypt(ctx, wrap.Content, ephemeralPubkey)
if err != nil {
return fmt.Errorf("failed to decrypt seal: %w", err)
}
var seal nostr.Event var seal nostr.Event
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
return fmt.Errorf("invalid seal JSON: %w", err) // try both the receiver identity key and decoupled key
err = nil
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
case 1:
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
}
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
if thisErr != nil {
err = thisErr
continue
}
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
continue
}
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID {
// if both ciphers failed above we'll reach here
return fmt.Errorf("failed to decrypt seal: %w", err)
} }
if seal.Kind != 13 { if seal.Kind != 13 {
return fmt.Errorf("not a seal event (kind %d)", seal.Kind) return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
} }
// decrypt rumor senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
rumorJSON, err := cipher.Decrypt(ctx, seal.Content, sender) if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
if err != nil { senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
}
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
switch s {
case 0:
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
case 1:
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
}
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
if thisErr != nil {
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
continue
}
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
continue
}
break
}
if rumor.ID == nostr.ZeroID {
return fmt.Errorf("failed to decrypt rumor: %w", err) return fmt.Errorf("failed to decrypt rumor: %w", err)
} }
var rumor nostr.Event
if err := easyjson.Unmarshal([]byte(rumorJSON), &rumor); err != nil {
return fmt.Errorf("invalid rumor JSON: %w", err)
}
// output the unwrapped event (rumor) // output the unwrapped event (rumor)
stdout(rumorJSON) stdout(rumor.String())
} }
return nil return nil
@@ -230,18 +288,18 @@ func randomNow() nostr.Timestamp {
return nostr.Timestamp(now - randomOffset) return nostr.Timestamp(now - randomOffset)
} }
func getDecoupledEncryptionKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) { func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
relays := sys.FetchWriteRelays(ctx, pubkey) relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{ keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044}, Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"}) }, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
var eSec nostr.SecretKey
var ePub nostr.PubKey
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""}) keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok { if ok {
var ePub nostr.PubKey
// get the pub from the tag // get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags { for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" { if len(tag) >= 2 && tag[0] == "n" {
@@ -256,8 +314,7 @@ func getDecoupledEncryptionKey(ctx context.Context, configPath string, pubkey no
// check if we have the key // check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex()) eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil { if data, err := os.ReadFile(eKeyPath); err == nil {
log(color.GreenString("- and we have it locally already\n")) eSec, err := nostr.SecretKeyFromHex(string(data))
eSec, err = nostr.SecretKeyFromHex(string(data))
if err != nil { if err != nil {
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err) return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
} }
@@ -271,3 +328,32 @@ func getDecoupledEncryptionKey(ctx context.Context, configPath string, pubkey no
return [32]byte{}, false, nil return [32]byte{}, false, nil
} }
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return nostr.ZeroPK, false
}
return ePub, true
}
return nostr.ZeroPK, false
}

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25
require ( require (
fiatjaf.com/lib v0.3.1 fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1 github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6 github.com/btcsuite/btcd/btcec/v2 v2.3.6

4
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA= fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g= fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb h1:GuqPn1g0JRD/dGxFRxEwEFxvbcT3vyvMjP3OoeLIIh0= fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=