Skip to content

Commit b2d3c26

Browse files
authored
Merge pull request #2249 from onflow/cf/extract-keys
Add `flow config extract-key` command
2 parents 8f23115 + b81839c commit b2d3c26

3 files changed

Lines changed: 448 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var Cmd = &cobra.Command{
3131
func init() {
3232
Cmd.AddCommand(addCmd)
3333
Cmd.AddCommand(removeCmd)
34+
extractKeyCommand.AddToParent(Cmd)
3435
}
3536

3637
type result struct {

internal/config/extract-key.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Flow CLI
3+
*
4+
* Copyright Flow Foundation
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package config
20+
21+
import (
22+
"fmt"
23+
"os"
24+
"slices"
25+
26+
"github.com/spf13/cobra"
27+
28+
"github.com/onflow/flowkit/v2"
29+
"github.com/onflow/flowkit/v2/accounts"
30+
"github.com/onflow/flowkit/v2/output"
31+
32+
"github.com/onflow/flow-cli/internal/command"
33+
"github.com/onflow/flow-cli/internal/prompt"
34+
"github.com/onflow/flow-cli/internal/util"
35+
)
36+
37+
type flagsExtractKey struct {
38+
All bool `default:"false" flag:"all" info:"Extract keys for all accounts with inline keys"`
39+
}
40+
41+
var extractKeyFlags = flagsExtractKey{}
42+
43+
var extractKeyCommand = &command.Command{
44+
Cmd: &cobra.Command{
45+
Use: "extract-key [account-name]",
46+
Short: "Extract account private keys to separate key files",
47+
Long: `Extracts inline private keys from flow.json to separate .pkey files for improved security.
48+
49+
This converts accounts from the inline key format:
50+
"my-account": { "address": "...", "key": "deadbeef..." }
51+
52+
To the more secure file-based format:
53+
"my-account": { "address": "...", "key": { "type": "file", "location": "./my-account.pkey" } }
54+
55+
The private key files are automatically added to .gitignore and .cursorignore.`,
56+
Example: `flow config extract-key my-account
57+
flow config extract-key --all`,
58+
Args: cobra.MaximumNArgs(1),
59+
},
60+
Flags: &extractKeyFlags,
61+
RunS: extractKey,
62+
}
63+
64+
func extractKey(
65+
args []string,
66+
globalFlags command.GlobalFlags,
67+
logger output.Logger,
68+
_ flowkit.Services,
69+
state *flowkit.State,
70+
) (command.Result, error) {
71+
hexKeyAccounts := findAccountsWithHexKeys(state)
72+
73+
var accountsToProcess []string
74+
if len(args) == 1 {
75+
accountName := args[0]
76+
77+
_, err := state.Accounts().ByName(accountName)
78+
if err != nil {
79+
return nil, fmt.Errorf("account '%s' not found in configuration", accountName)
80+
}
81+
82+
if !slices.Contains(hexKeyAccounts, accountName) {
83+
return nil, fmt.Errorf("account '%s' already uses a file-based key or has an unsupported key type", accountName)
84+
}
85+
accountsToProcess = []string{accountName}
86+
} else if extractKeyFlags.All {
87+
if len(hexKeyAccounts) == 0 {
88+
return &result{result: "No accounts with inline keys found. All accounts already use file-based keys."}, nil
89+
}
90+
accountsToProcess = hexKeyAccounts
91+
} else {
92+
if len(hexKeyAccounts) == 0 {
93+
return &result{result: "No accounts with inline keys found. All accounts already use file-based keys."}, nil
94+
}
95+
options := append(hexKeyAccounts, "all")
96+
selected, err := prompt.RunSingleSelect(options, "Select an account to extract key (or 'all' for all accounts)")
97+
if err != nil {
98+
return nil, err
99+
}
100+
if selected == "all" {
101+
accountsToProcess = hexKeyAccounts
102+
} else {
103+
accountsToProcess = []string{selected}
104+
}
105+
}
106+
107+
extractedFiles := make([]string, 0, len(accountsToProcess))
108+
for _, accountName := range accountsToProcess {
109+
keyFilePath, err := extractKeyForAccount(state, accountName)
110+
if err != nil {
111+
return nil, fmt.Errorf("failed to extract key for '%s': %w", accountName, err)
112+
}
113+
extractedFiles = append(extractedFiles, keyFilePath)
114+
logger.Info(fmt.Sprintf("%s Extracted key for account '%s' to %s", output.SuccessEmoji(), accountName, keyFilePath))
115+
}
116+
117+
err := state.SaveEdited(globalFlags.ConfigPaths)
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to save configuration: %w", err)
120+
}
121+
122+
return &result{
123+
result: fmt.Sprintf("Successfully extracted keys for %d account(s). Key files added to .gitignore and .cursorignore.", len(accountsToProcess)),
124+
}, nil
125+
}
126+
127+
// findAccountsWithHexKeys returns account names that have inline hex keys (not file-based keys)
128+
func findAccountsWithHexKeys(state *flowkit.State) []string {
129+
var hexKeyAccounts []string
130+
for _, account := range *state.Accounts() {
131+
// Check if the key is a HexKey (inline key) using type assertion
132+
if _, isHexKey := account.Key.(*accounts.HexKey); isHexKey {
133+
hexKeyAccounts = append(hexKeyAccounts, account.Name)
134+
}
135+
}
136+
return hexKeyAccounts
137+
}
138+
139+
func extractKeyForAccount(state *flowkit.State, accountName string) (string, error) {
140+
account, err := state.Accounts().ByName(accountName)
141+
if err != nil {
142+
return "", fmt.Errorf("account '%s' not found", accountName)
143+
}
144+
145+
privateKey, err := account.Key.PrivateKey()
146+
if err != nil {
147+
return "", fmt.Errorf("cannot extract key: %w", err)
148+
}
149+
if privateKey == nil {
150+
return "", fmt.Errorf("account '%s' does not have a private key", accountName)
151+
}
152+
153+
keyFilePath := accounts.PrivateKeyFile(accountName, "")
154+
155+
if _, err := state.ReaderWriter().ReadFile(keyFilePath); err == nil {
156+
return "", fmt.Errorf("key file '%s' already exists. Please remove it first or choose a different account", keyFilePath)
157+
}
158+
159+
err = state.ReaderWriter().WriteFile(keyFilePath, []byte((*privateKey).String()), os.FileMode(0600))
160+
if err != nil {
161+
return "", fmt.Errorf("failed to write key file: %w", err)
162+
}
163+
164+
_ = util.AddToGitIgnore(keyFilePath, state.ReaderWriter())
165+
_ = util.AddToCursorIgnore(keyFilePath, state.ReaderWriter())
166+
167+
account.Key = accounts.NewFileKey(
168+
keyFilePath,
169+
account.Key.Index(),
170+
account.Key.SigAlgo(),
171+
account.Key.HashAlgo(),
172+
state.ReaderWriter(),
173+
)
174+
175+
state.Accounts().AddOrUpdate(account)
176+
177+
return keyFilePath, nil
178+
}

0 commit comments

Comments
 (0)