diff --git a/dekey.go b/dekey.go new file mode 100644 index 0000000..3961e2a --- /dev/null +++ b/dekey.go @@ -0,0 +1,282 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip44" + "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-name", + 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" + }(), + }, + ), + 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-name") + + // 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 { + deviceSec, err = nostr.SecretKeyFromHex(string(data)) + if err != nil { + return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err) + } + } else { + // 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) + } + } + devicePub := deviceSec.Public() + + // get relays for the user + 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 if kind:4454 is already published + events := sys.Pool.FetchMany(ctx, relays, nostr.Filter{ + Kinds: []nostr.Kind{4454}, + Authors: []nostr.PubKey{userPub}, + Tags: nostr.TagMap{ + "pubkey": []string{devicePub.Hex()}, + }, + }, nostr.SubscriptionOptions{Label: "nak-nip4e"}) + if len(events) == 0 { + // publish kind:4454 + evt := nostr.Event{ + Kind: 4454, + Content: "", + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"client", deviceName}, + {"pubkey", 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 + } + } + + // check for kind:10044 + userKeyEventDate := nostr.Now() + userKeyResult := 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 + if userKeyEvent, ok := userKeyResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""}); !ok { + // generate main secret key + eSec = nostr.Generate() + ePub := eSec.Public() + + // store it + eKeyPath := filepath.Join(configPath, "dekey", "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 user encryption key: %w", err) + } + + // publish kind:10044 + evt10044 := nostr.Event{ + Kind: 10044, + Content: "", + CreatedAt: userKeyEventDate, + 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 { + userKeyEventDate = userKeyEvent.CreatedAt + + // get the pub from the tag + for _, tag := range userKeyEvent.Tags { + if len(tag) >= 2 && tag[0] == "n" { + ePub, _ = nostr.PubKeyFromHex(tag[1]) + break + } + } + if ePub == nostr.ZeroPK { + return fmt.Errorf("invalid kind:10044 event, no 'n' tag") + } + + // check if we have the key + eKeyPath := filepath.Join(configPath, "dekey", "e", ePub.Hex()) + if data, err := os.ReadFile(eKeyPath); err == nil { + 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 user encryption key is corrupted: %w", err) + } + } else { + // try to decrypt from kind:4455 + for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{ + Kinds: []nostr.Kind{4455}, + Tags: nostr.TagMap{ + "p": []string{devicePub.Hex()}, + }, + }, 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 { + // store it + os.MkdirAll(filepath.Dir(eKeyPath), 0700) + os.WriteFile(eKeyPath, []byte(eSecHex), 0600) + break + } + } + } + } + + if eSec == [32]byte{} { + log("main secret key not available, must authorize on another device\n") + return nil + } + + // now we have mainSec, check for other kind:4454 events newer than the 10044 + 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: userKeyEventDate, + }, nostr.SubscriptionOptions{Label: "nak-nip4e"}) { + if keyOrDeviceEvt.Kind == 4455 { + // key event + + // skip ourselves + if keyOrDeviceEvt.Tags.FindWithValue("p", devicePub.Hex()) != nil { + continue + } + + // assume a key msg will always come before its associated devicemsg + // so just store them here: + pubkeyTag := keyOrDeviceEvt.Tags.Find("p") + if pubkeyTag == nil { + continue + } + keyMsgs = append(keyMsgs, pubkeyTag[1]) + } else if keyOrDeviceEvt.Kind == 4454 { + // device event + + // skip ourselves + if keyOrDeviceEvt.Tags.FindWithValue("pubkey", devicePub.Hex()) != nil { + continue + } + + // if this already has a corresponding keyMsg then skip it + pubkeyTag := keyOrDeviceEvt.Tags.Find("pubkey") + if pubkeyTag == nil { + continue + } + if slices.Contains(keyMsgs, pubkeyTag[1]) { + 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 + } + + 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 + } + + publishFlow(ctx, c, kr, evt4455, relayList) + } + } + + return nil + }, +} diff --git a/main.go b/main.go index 6963296..a960443 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ var app = &cli.Command{ bunker, serve, blossomCmd, + dekey, encrypt, decrypt, gift,