mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-24 19:38:52 +00:00
436 lines
14 KiB
Go
436 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"fiatjaf.com/nostr/nip19"
|
|
"fiatjaf.com/nostr/nip44"
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/fatih/color"
|
|
"github.com/urfave/cli/v3"
|
|
)
|
|
|
|
var dekey = &cli.Command{
|
|
Name: "dekey",
|
|
Usage: "handles NIP-4E decoupled encryption keys",
|
|
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
|
|
DisableSliceFlagSeparator: true,
|
|
Flags: append(defaultKeyFlags,
|
|
&cli.StringFlag{
|
|
Name: "device",
|
|
Usage: "name of this device that will be published and displayed on other clients",
|
|
Value: func() string {
|
|
if hostname, err := os.Hostname(); err == nil {
|
|
return "nak@" + hostname
|
|
}
|
|
return "nak@unknown"
|
|
}(),
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "rotate",
|
|
Usage: "force the creation of a new decoupled encryption key, effectively invalidating any previous ones",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "authorize-all",
|
|
Aliases: []string{"yolo"},
|
|
Usage: "do not ask for confirmation, just automatically send the decoupled encryption key to all devices that exist",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "reject-all",
|
|
Usage: "do not ask for confirmation, just not send the decoupled encryption key to any device",
|
|
},
|
|
),
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
userPub, err := kr.GetPublicKey(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user public key: %w", err)
|
|
}
|
|
|
|
configPath := c.String("config-path")
|
|
deviceName := c.String("device")
|
|
|
|
log("handling device key for %s as %s\n",
|
|
color.YellowString(deviceName),
|
|
color.CyanString(nip19.EncodeNpub(userPub)),
|
|
)
|
|
// check if we already have a local-device secret key
|
|
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
|
|
var deviceSec nostr.SecretKey
|
|
if data, err := os.ReadFile(deviceKeyPath); err == nil {
|
|
log(color.GreenString("found existing device key\n"))
|
|
deviceSec, err = nostr.SecretKeyFromHex(string(data))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
|
|
}
|
|
} else {
|
|
log(color.YellowString("generating new device key\n"))
|
|
// create one
|
|
deviceSec = nostr.Generate()
|
|
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
|
|
if err := os.WriteFile(deviceKeyPath, []byte(deviceSec.Hex()), 0600); err != nil {
|
|
return fmt.Errorf("failed to write device key: %w", err)
|
|
}
|
|
log(color.GreenString("device key generated and stored\n"))
|
|
}
|
|
devicePub := deviceSec.Public()
|
|
|
|
// get relays for the user
|
|
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
|
|
relays := sys.FetchWriteRelays(ctx, userPub)
|
|
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
|
|
if len(relayList) == 0 {
|
|
return fmt.Errorf("no relays to use")
|
|
}
|
|
|
|
// check for kind:10044
|
|
log("- checking for decoupled encryption key (kind:10044)\n")
|
|
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
|
Kinds: []nostr.Kind{10044},
|
|
Authors: []nostr.PubKey{userPub},
|
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
|
var eSec nostr.SecretKey
|
|
var ePub nostr.PubKey
|
|
|
|
var generateNewEncryptionKey bool
|
|
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""})
|
|
if !ok {
|
|
log("- no decoupled encryption key found, generating new one\n")
|
|
generateNewEncryptionKey = true
|
|
} else {
|
|
// 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 fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
|
}
|
|
|
|
log(". a decoupled encryption public key already exists: %s\n", color.CyanString(ePub.Hex()))
|
|
if c.Bool("rotate") {
|
|
log(color.GreenString("rotating it by generating a new one\n"))
|
|
generateNewEncryptionKey = true
|
|
}
|
|
}
|
|
|
|
if generateNewEncryptionKey {
|
|
// generate main secret key
|
|
eSec = nostr.Generate()
|
|
ePub = eSec.Public()
|
|
|
|
// store it
|
|
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
|
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
|
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
|
|
return fmt.Errorf("failed to write decoupled encryption key: %w", err)
|
|
}
|
|
log("decoupled encryption key generated and stored, public key: %s\n", color.CyanString(ePub.Hex()))
|
|
|
|
// publish kind:10044
|
|
log("publishing decoupled encryption public key (kind:10044)\n")
|
|
evt10044 := nostr.Event{
|
|
Kind: 10044,
|
|
Content: "",
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{
|
|
{"n", ePub.Hex()},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &evt10044); err != nil {
|
|
return fmt.Errorf("failed to sign kind:10044: %w", err)
|
|
}
|
|
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// check if we have the key
|
|
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
|
if data, err := os.ReadFile(eKeyPath); err == nil {
|
|
log(color.GreenString("- and we have it locally already\n"))
|
|
eSec, err = nostr.SecretKeyFromHex(string(data))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid main key: %w", err)
|
|
}
|
|
if eSec.Public() != ePub {
|
|
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
|
}
|
|
} else {
|
|
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
|
|
|
|
// check if our kind:4454 is already published
|
|
log("- checking for existing device announcement (kind:4454)\n")
|
|
ourDeviceAnnouncementEvents := make([]nostr.Event, 0, 1)
|
|
for evt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
|
Kinds: []nostr.Kind{4454},
|
|
Authors: []nostr.PubKey{userPub},
|
|
Tags: nostr.TagMap{
|
|
"P": []string{devicePub.Hex()},
|
|
},
|
|
Limit: 1,
|
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
|
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt.Event)
|
|
}
|
|
if len(ourDeviceAnnouncementEvents) == 0 {
|
|
log(". no device announcement found, publishing kind:4454 for %s\n", color.YellowString(deviceName))
|
|
// publish kind:4454
|
|
evt := nostr.Event{
|
|
Kind: 4454,
|
|
Content: "",
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{
|
|
{"client", deviceName},
|
|
{"P", devicePub.Hex()},
|
|
},
|
|
}
|
|
|
|
// sign with main key
|
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
|
return fmt.Errorf("failed to sign device event: %w", err)
|
|
}
|
|
|
|
// publish
|
|
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
|
|
return err
|
|
}
|
|
log(color.GreenString(". device announcement published\n"))
|
|
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt)
|
|
} else {
|
|
log(color.GreenString(". device already registered\n"))
|
|
}
|
|
|
|
// see if some other device has shared the key with us from kind:4455
|
|
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
|
Kinds: []nostr.Kind{4455},
|
|
Tags: nostr.TagMap{
|
|
"p": []string{devicePub.Hex()},
|
|
},
|
|
Since: keyAnnouncementEvent.CreatedAt + 1,
|
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
|
var senderPub nostr.PubKey
|
|
for _, tag := range eKeyMsg.Tags {
|
|
if len(tag) >= 2 && tag[0] == "P" {
|
|
senderPub, _ = nostr.PubKeyFromHex(tag[1])
|
|
break
|
|
}
|
|
}
|
|
if senderPub == nostr.ZeroPK {
|
|
continue
|
|
}
|
|
ss, err := nip44.GenerateConversationKey(senderPub, deviceSec)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
eSecHex, err := nip44.Decrypt(eKeyMsg.Content, ss)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
eSec, err = nostr.SecretKeyFromHex(eSecHex)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// check if it matches mainPub
|
|
if eSec.Public() == ePub {
|
|
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
|
|
// store it
|
|
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
|
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
|
|
|
// delete our 4454 if we had one, since we received the key
|
|
if len(ourDeviceAnnouncementEvents) > 0 {
|
|
log("deleting our device announcement (kind:4454) since we received the decoupled encryption key\n")
|
|
deletion4454 := nostr.Event{
|
|
CreatedAt: nostr.Now(),
|
|
Kind: 5,
|
|
Tags: nostr.Tags{
|
|
{"e", ourDeviceAnnouncementEvents[0].ID.Hex()},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &deletion4454); err != nil {
|
|
log(color.RedString("failed to sign 4454 deletion: %v\n"), err)
|
|
} else if err := publishFlow(ctx, c, kr, deletion4454, relayList); err != nil {
|
|
log(color.RedString("failed to publish 4454 deletion: %v\n"), err)
|
|
} else {
|
|
log(color.GreenString("- device announcement deleted\n"))
|
|
}
|
|
}
|
|
|
|
// delete the 4455 we just decrypted
|
|
log("deleting the key message (kind:4455) we just decrypted\n")
|
|
deletion4455 := nostr.Event{
|
|
CreatedAt: nostr.Now(),
|
|
Kind: 5,
|
|
Tags: nostr.Tags{
|
|
{"e", eKeyMsg.ID.Hex()},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &deletion4455); err != nil {
|
|
log(color.RedString("failed to sign 4455 deletion: %v\n"), err)
|
|
} else if err := publishFlow(ctx, c, kr, deletion4455, relayList); err != nil {
|
|
log(color.RedString("failed to publish 4455 deletion: %v\n"), err)
|
|
} else {
|
|
log(color.GreenString("- key message deleted\n"))
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if eSec == [32]byte{} {
|
|
log("decoupled encryption secret key not available, must be sent from another device to %s first\n",
|
|
color.YellowString(deviceName))
|
|
return nil
|
|
}
|
|
log(color.GreenString("- decoupled encryption key ready\n"))
|
|
|
|
// now we have mainSec, check for other kind:4454 events newer than the 10044
|
|
log("- checking for other devices and key messages so we can send the key\n")
|
|
keyMsgs := make([]string, 0, 5)
|
|
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
|
Kinds: []nostr.Kind{4454, 4455},
|
|
Authors: []nostr.PubKey{userPub},
|
|
Since: keyAnnouncementEvent.CreatedAt + 1,
|
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
|
if keyOrDeviceEvt.Kind == 4455 {
|
|
// got key event
|
|
keyEvent := keyOrDeviceEvt
|
|
|
|
// assume a key msg will always come before its associated devicemsg
|
|
// so just store them here:
|
|
pubkeyTag := keyEvent.Tags.Find("p")
|
|
if pubkeyTag == nil {
|
|
continue
|
|
}
|
|
keyMsgs = append(keyMsgs, pubkeyTag[1])
|
|
} else if keyOrDeviceEvt.Kind == 4454 {
|
|
// device event
|
|
deviceEvt := keyOrDeviceEvt
|
|
|
|
// skip ourselves
|
|
if deviceEvt.Tags.FindWithValue("P", devicePub.Hex()) != nil {
|
|
continue
|
|
}
|
|
|
|
// if there is a clock skew (current time is earlier than the time of this device's announcement) skip it
|
|
if nostr.Now() < deviceEvt.CreatedAt {
|
|
continue
|
|
}
|
|
|
|
// if this already has a corresponding keyMsg then skip it
|
|
pubkeyTag := deviceEvt.Tags.Find("P")
|
|
if pubkeyTag == nil {
|
|
continue
|
|
}
|
|
|
|
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
|
continue
|
|
}
|
|
|
|
deviceTag := deviceEvt.Tags.Find("client")
|
|
if deviceTag == nil {
|
|
continue
|
|
}
|
|
|
|
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
|
|
// so we have to build a keyMsg for them
|
|
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if c.Bool("authorize-all") {
|
|
// will proceed
|
|
} else if c.Bool("reject-all") {
|
|
log(" - skipping %s\n", color.YellowString(deviceTag[1]))
|
|
continue
|
|
} else {
|
|
var proceed bool
|
|
if err := survey.AskOne(&survey.Confirm{
|
|
Message: fmt.Sprintf("share decoupled encryption key with %s"+colors.bold("?"),
|
|
color.YellowString(deviceTag[1])),
|
|
}, &proceed); err != nil {
|
|
return err
|
|
}
|
|
if proceed {
|
|
// will proceed
|
|
} else {
|
|
// won't proceed
|
|
var deleteDevice bool
|
|
if err := survey.AskOne(&survey.Confirm{
|
|
Message: fmt.Sprintf(" delete %s"+colors.bold("'s announcement?"), color.YellowString(deviceTag[1])),
|
|
}, &deleteDevice); err != nil {
|
|
return err
|
|
}
|
|
|
|
if deleteDevice {
|
|
log(" - deleting %s\n", color.YellowString(deviceTag[1]))
|
|
deletion := nostr.Event{
|
|
CreatedAt: nostr.Now(),
|
|
Kind: 5,
|
|
Tags: nostr.Tags{
|
|
{"e", deviceEvt.ID.Hex()},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &deletion); err != nil {
|
|
return fmt.Errorf("failed to sign deletion '%s': %w", deletion.GetID().Hex(), err)
|
|
}
|
|
if err := publishFlow(ctx, c, kr, deletion, relayList); err != nil {
|
|
return fmt.Errorf("publish flow failed: %w", err)
|
|
}
|
|
} else {
|
|
log(" - skipped\n")
|
|
}
|
|
|
|
continue
|
|
}
|
|
}
|
|
|
|
log("- sending decoupled encryption key to new device %s\n", color.YellowString(deviceTag[1]))
|
|
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ciphertext, err := nip44.Encrypt(eSec.Hex(), ss)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
evt4455 := nostr.Event{
|
|
Kind: 4455,
|
|
Content: ciphertext,
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{
|
|
{"p", theirDevice.Hex()},
|
|
{"P", devicePub.Hex()},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &evt4455); err != nil {
|
|
continue
|
|
}
|
|
|
|
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
|
|
log(color.RedString("failed to publish key message: %v\n"), err)
|
|
} else {
|
|
log(" - decoupled encryption key sent to %s\n", color.GreenString(deviceTag[1]))
|
|
}
|
|
}
|
|
}
|
|
|
|
stdout(ePub.Hex())
|
|
return nil
|
|
},
|
|
}
|