Skip to content
This repository was archived by the owner on Jan 22, 2025. It is now read-only.

Commit 917567a

Browse files
authored
Merge pull request #15 from keybase/david/keybase-ca-sign
Add keybaseca sign command to sign keys manually when keybase is down
2 parents 23f3e00 + d3adaae commit 917567a

14 files changed

Lines changed: 176 additions & 47 deletions

File tree

docs/troubleshooting.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ and on the client run `kssh -p 2222 user@server` and inspect the debug logs.
3939
## Keybase is down
4040

4141
If Keybase is down, the bot will not work since it relies on Keybase chat for communication. In this scenario, you can
42-
manually sign SSH keys with the CA key. Place the CA private key in `~/cakey` and the CA public key in `~/cakey.pub`.
43-
Then run the command:
42+
manually sign SSH keys with the CA key. This can be done via `keybaseca sign --public-key /path/to/key.pub`. Alternatively,
43+
this can be done manually without relying on any of the tooling in this repository. To do so, place the CA private key
44+
in `~/cakey` and the CA public key in `~/cakey.pub`. Then run the command:
4445

4546
```bash
4647
ssh-keygen \
@@ -55,7 +56,7 @@ ssh-keygen \
5556
# Specify the password on the CA key (if exported via `keybaseca backup` there is no password)
5657
-N "" \
5758
# The location of the public key you wish to sign
58-
~/.ssh/id_rsa.pub
59+
/path/to/key.pub
5960
```
6061

61-
You can then use the signed SSH key to SSH via `ssh -i ~/.ssh/id_rsa user@server`.
62+
You can then use the signed SSH key to SSH via `ssh -i /path/to/key.pub user@server`.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/google/uuid v1.1.1
77
github.com/keybase/go-keybase-chat-bot v0.0.0-20190812134859-bc54fd9cf83b
88
github.com/stretchr/testify v1.3.0
9-
github.com/urfave/cli v1.20.0
9+
github.com/urfave/cli v1.21.0
1010
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
1111
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e // indirect
1212
gopkg.in/yaml.v2 v2.2.2 // indirect

go.sum

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
12
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
23
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
34
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
@@ -9,8 +10,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
910
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
1011
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
1112
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
12-
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
13-
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
13+
github.com/urfave/cli v1.21.0 h1:wYSSj06510qPIzGSua9ZqsncMmWE3Zr55KBERygyrxE=
14+
github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ=
1415
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
1516
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
1617
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

src/cmd/keybaseca/keybaseca.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"sync"
1313
"syscall"
1414

15+
"github.com/google/uuid"
16+
1517
"github.com/keybase/bot-ssh-ca/src/keybaseca/bot"
1618
"github.com/keybase/bot-ssh-ca/src/keybaseca/config"
1719
klog "github.com/keybase/bot-ssh-ca/src/keybaseca/log"
@@ -60,6 +62,22 @@ func main() {
6062
Usage: "Start the CA service in the foreground",
6163
Action: serviceAction,
6264
},
65+
{
66+
Name: "sign",
67+
Usage: "Sign a given public key with all permissions without a dependency on Keybase",
68+
Flags: []cli.Flag{
69+
cli.StringFlag{
70+
Name: "public-key",
71+
Usage: "The path to the public key you wish to sign. Eg `~/.ssh/id_rsa.pub`",
72+
Required: true,
73+
},
74+
cli.BoolFlag{
75+
Name: "overwrite",
76+
Usage: "Overwrite the existing certificate on the filesystem",
77+
},
78+
},
79+
Action: signAction,
80+
},
6381
}
6482
app.Action = mainAction
6583
err := app.Run(os.Args)
@@ -126,9 +144,54 @@ func serviceAction(c *cli.Context) error {
126144
return nil
127145
}
128146

147+
// The action for the `keybaseca sign` subcommand
148+
func signAction(c *cli.Context) error {
149+
// Skip validation of the config since that relies on Keybase's servers
150+
conf := config.EnvConfig{}
151+
err := config.ValidateConfig(conf, true)
152+
if err != nil {
153+
return fmt.Errorf("Invalid config: %v", err)
154+
}
155+
principals := strings.Join(conf.GetTeams(), ",")
156+
expiration := conf.GetKeyExpiration()
157+
randomUUID, err := uuid.NewRandom()
158+
if err != nil {
159+
return fmt.Errorf("Failed to generate unique key ID: %v", err)
160+
}
161+
162+
// Read the public key from the specified file
163+
filename := c.String("public-key")
164+
pubKey, err := ioutil.ReadFile(filename)
165+
if err != nil {
166+
return fmt.Errorf("Failed to read file at %s to get the public key: %v", filename, err)
167+
}
168+
169+
// Sign the public key
170+
signature, err := sshutils.SignKey(conf.GetCAKeyLocation(), randomUUID.String()+":keybaseca-sign", principals, expiration, string(pubKey))
171+
if err != nil {
172+
return fmt.Errorf("Failed to sign key: %v", err)
173+
}
174+
175+
// Either store it in a file or print it to stdout
176+
certPath := shared.KeyPathToCert(shared.PubKeyPathToKeyPath(filename))
177+
_, err = os.Stat(certPath)
178+
if os.IsNotExist(err) || c.Bool("overwrite") {
179+
err = ioutil.WriteFile(certPath, []byte(signature), 0600)
180+
if err != nil {
181+
return fmt.Errorf("Failed to write certificate to file: %v", err)
182+
}
183+
fmt.Printf("Provisioned new certificate in %s\n", certPath)
184+
} else {
185+
fmt.Printf("Provisioned new certificate. Place this in %s in order to use it with ssh.\n", certPath)
186+
fmt.Printf("\n```\n%s```\n", signature)
187+
}
188+
return nil
189+
}
190+
129191
// The action for the `keybaseca` command. Only used for hidden and unlisted flags.
130192
func mainAction(c *cli.Context) error {
131-
if c.Bool("wipe-all-configs") {
193+
switch {
194+
case c.Bool("wipe-all-configs"):
132195
teams, err := shared.KBFSList("/keybase/team/")
133196
if err != nil {
134197
return err
@@ -157,8 +220,7 @@ func mainAction(c *cli.Context) error {
157220
}(team)
158221
}
159222
semaphore.Wait()
160-
}
161-
if c.Bool("wipe-logs") {
223+
case c.Bool("wipe-logs"):
162224
conf, err := loadServerConfig()
163225
if err != nil {
164226
return err
@@ -176,6 +238,8 @@ func mainAction(c *cli.Context) error {
176238
}
177239
}
178240
fmt.Println("Wiped existing log file at " + logLocation)
241+
default:
242+
cli.ShowAppHelpAndExit(c, 1)
179243
}
180244
return nil
181245
}
@@ -257,7 +321,7 @@ func captureControlCToDeleteClientConfig(conf config.Config) {
257321
// Load and validate a server config object from the environment
258322
func loadServerConfig() (config.Config, error) {
259323
conf := config.EnvConfig{}
260-
err := config.ValidateConfig(conf)
324+
err := config.ValidateConfig(conf, false)
261325
if err != nil {
262326
return nil, fmt.Errorf("Failed to validate config: %v", err)
263327
}

src/keybaseca/config/config.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,23 @@ type Config interface {
2525
GetStrictLogging() bool
2626
}
2727

28-
func ValidateConfig(conf EnvConfig) error {
28+
// Validate the given config file. If offline, do so without connecting to keybase (used in code that is meant
29+
// to function without any reliance on Keybase).
30+
func ValidateConfig(conf EnvConfig, offline bool) error {
2931
if len(conf.GetTeams()) == 0 {
3032
return fmt.Errorf("must specify at least one team via the TEAMS environment variable")
3133
}
3234
if conf.GetKeyExpiration() != "" && !strings.HasPrefix(conf.GetKeyExpiration(), "+") {
3335
// Only a basic check for this since ssh will error out later on if it is bogus
3436
return fmt.Errorf("KEY_EXPIRATION must be of the form `+<number><unit> where unit is one of `m`, `h`, `d`, `w`. Eg `+1h`. ")
3537
}
36-
if conf.GetLogLocation() != "" {
38+
if conf.GetLogLocation() != "" && !offline {
3739
err := validatePath(conf.GetLogLocation())
3840
if err != nil {
3941
return fmt.Errorf("LOG_LOCATION '%s' is not a valid path: %v", conf.GetLogLocation(), err)
4042
}
4143
}
42-
if conf.getChatChannel() != "" {
44+
if conf.getChatChannel() != "" && !offline {
4345
team, channel, err := splitTeamChannel(conf.getChatChannel())
4446
if err != nil {
4547
return fmt.Errorf("Failed to parse CHAT_CHANNEL=%s: %v", conf.getChatChannel(), err)

src/keybaseca/log/log.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func Log(conf config.Config, str string) {
1616
strWithTs := fmt.Sprintf("[%s] %s", time.Now().String(), str)
1717

1818
if conf.GetLogLocation() == "" {
19-
fmt.Print(strWithTs)
19+
fmt.Print(strWithTs + "\n")
2020
} else {
2121
err := appendToFile(conf.GetLogLocation(), strWithTs)
2222
if err != nil {

src/keybaseca/sshutils/generate_unix.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package sshutils
55
import (
66
"fmt"
77
"os/exec"
8+
"strings"
89
)
910

1011
// Generate a new SSH key. Places the private key at filename and the public key at filename.pub. If `overwrite`,
@@ -15,7 +16,7 @@ func generateNewSSHKey(filename string) error {
1516
cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", filename, "-m", "PEM", "-N", "")
1617
bytes, err := cmd.CombinedOutput()
1718
if err != nil {
18-
return fmt.Errorf("ssh-keygen failed: %s (%v)", string(bytes), err)
19+
return fmt.Errorf("ssh-keygen failed: %s (%v)", strings.TrimSpace(string(bytes)), err)
1920
}
2021
return nil
2122
}

src/keybaseca/sshutils/sshutils.go

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,37 +79,57 @@ func ProcessSignatureRequest(conf config.Config, sr shared.SignatureRequest) (re
7979
return
8080
}
8181

82+
// The key ID uniquely identifies the certificate by encoding the UUID of the request, a new UUID, and the username
83+
// Use both their uuid and our uuid to ensure it is unique
84+
keyID := sr.UUID + ":" + randomUUID.String() + ":" + sr.Username
85+
86+
log.Log(conf, fmt.Sprintf("Processing SignatureRequest from user=%s on device='%s' keyID:%s, principals:%s, expiration:%s, pubkey:%s",
87+
sr.Username, sr.DeviceName, keyID, principals, conf.GetKeyExpiration(), sr.SSHPublicKey))
88+
signature, err := SignKey(conf.GetCAKeyLocation(), keyID, principals, conf.GetKeyExpiration(), sr.SSHPublicKey)
89+
90+
return shared.SignatureResponse{SignedKey: signature, UUID: sr.UUID}, nil
91+
}
92+
93+
// Sign an SSH public key with the given data. Do so without any operations that rely on Keybase in order to ensure
94+
// that running `keybaseca sign` works even if Keybase is down.
95+
func SignKey(caKeyLocation, keyID, principals, expiration, publicKey string) (signature string, err error) {
96+
// Just a little bit of validation to give a nice error message
97+
if strings.Contains(publicKey, "PRIVATE KEY") {
98+
return "", fmt.Errorf("SignKey expects a public key (not a private key)")
99+
}
100+
101+
// Write the public key to a temporary file
82102
tempFilename, err := getTempFilename("keybase-ca-signed-key")
83103
if err != nil {
84104
return
85105
}
86-
err = ioutil.WriteFile(shared.KeyPathToPubKey(tempFilename), []byte(sr.SSHPublicKey), 0600)
106+
err = ioutil.WriteFile(shared.KeyPathToPubKey(tempFilename), []byte(publicKey), 0600)
87107
if err != nil {
88108
return
89109
}
90110

91-
// The key ID uniquely identifies the certificate by encoding the UUID of the request, a new UUID, and the username
92-
keyID := sr.UUID + ":" + randomUUID.String() + ":" + sr.Username
93-
94-
log.Log(conf, fmt.Sprintf("Processing SignatureRequest from user=%s on device='%s' keyID:%s, principals:%s, expiration:%s, pubkey:%s",
95-
sr.Username, sr.DeviceName, keyID, principals, conf.GetKeyExpiration(), sr.SSHPublicKey))
111+
// Note that we use ssh-keygen rather than Go's builtin SSH library since Go's SSH library does not support ed25519
112+
// SSH keys.
96113
cmd := exec.Command("ssh-keygen",
97-
"-s", conf.GetCAKeyLocation(), // The CA key
98-
"-I", keyID, // The ID of the signed key. Use their uuid and our uuid to ensure it is unique
114+
"-s", caKeyLocation, // The CA key
115+
"-I", keyID, // A unique key ID
99116
"-n", principals, // The allowed principals
100-
"-V", conf.GetKeyExpiration(), // The configured key expiration
117+
"-V", expiration, // The expiration period for the key
101118
"-N", "", // No password on the key
102-
shared.KeyPathToPubKey(tempFilename), // The location of where to put the key
119+
shared.KeyPathToPubKey(tempFilename), // The location of the public key
103120
)
104-
err = cmd.Run()
121+
bytes, err := cmd.CombinedOutput()
105122
if err != nil {
106-
return
123+
return "", fmt.Errorf("ssh-keygen error: %s (%v)", strings.TrimSpace(string(bytes)), err)
107124
}
108125

109-
data, err := ioutil.ReadFile(shared.KeyPathToCert(tempFilename))
126+
// Read the certificate from the file
127+
signatureBytes, err := ioutil.ReadFile(shared.KeyPathToCert(tempFilename))
110128
if err != nil {
111129
return
112130
}
131+
132+
// Delete the certificate and the pub key from the filesystem
113133
err = os.Remove(shared.KeyPathToPubKey(tempFilename))
114134
if err != nil {
115135
return
@@ -118,7 +138,8 @@ func ProcessSignatureRequest(conf config.Config, sr shared.SignatureRequest) (re
118138
if err != nil {
119139
return
120140
}
121-
return shared.SignatureResponse{SignedKey: string(data), UUID: sr.UUID}, nil
141+
142+
return string(signatureBytes), nil
122143
}
123144

124145
// Get the principals that should be placed in the signed certificate.

src/kssh/ssh.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package kssh
33
import (
44
"fmt"
55
"os/exec"
6+
"strings"
67
)
78

89
func AddKeyToSSHAgent(keyPath string) error {
910
cmd := exec.Command("ssh-add", keyPath)
1011
bytes, err := cmd.CombinedOutput()
1112
if err != nil {
12-
return fmt.Errorf("failed to add SSH key to the ssh-agent (is it running?): %s (%v)", string(bytes), err)
13+
return fmt.Errorf("failed to add SSH key to the ssh-agent (is it running?): %s (%v)", strings.TrimSpace(string(bytes)), err)
1314
}
1415
return nil
1516
}

src/shared/kbfs.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func KBFSFileExists(kbfsFilename string) (bool, error) {
4343
if strings.Contains(string(bytes), "ERROR file does not exist") {
4444
return false, nil
4545
}
46-
return false, fmt.Errorf("failed to stat %s: %s (%v)", kbfsFilename, string(bytes), err)
46+
return false, fmt.Errorf("failed to stat %s: %s (%v)", kbfsFilename, strings.TrimSpace(string(bytes)), err)
4747
}
4848

4949
func KBFSRead(kbfsFilename string) ([]byte, error) {
@@ -54,7 +54,7 @@ func KBFSRead(kbfsFilename string) ([]byte, error) {
5454
cmd := exec.Command("keybase", "fs", "read", kbfsFilename)
5555
bytes, err := cmd.CombinedOutput()
5656
if err != nil {
57-
return nil, fmt.Errorf("failed to read %s: %s (%v)", kbfsFilename, string(bytes), err)
57+
return nil, fmt.Errorf("failed to read %s: %s (%v)", kbfsFilename, strings.TrimSpace(string(bytes)), err)
5858
}
5959
return bytes, nil
6060
}
@@ -63,7 +63,7 @@ func KBFSDelete(filename string) error {
6363
cmd := exec.Command("keybase", "fs", "rm", filename)
6464
bytes, err := cmd.CombinedOutput()
6565
if err != nil {
66-
return fmt.Errorf("failed to delete the file at %s: %s (%v)", filename, string(bytes), err)
66+
return fmt.Errorf("failed to delete the file at %s: %s (%v)", filename, strings.TrimSpace(string(bytes)), err)
6767
}
6868
return nil
6969
}
@@ -87,7 +87,7 @@ func KBFSWrite(filename string, contents string, appendToFile bool) error {
8787
cmd.Stdin = strings.NewReader(string(contents))
8888
bytes, err := cmd.CombinedOutput()
8989
if err != nil {
90-
return fmt.Errorf("failed to write to file at %s: %s (%v)", filename, string(bytes), err)
90+
return fmt.Errorf("failed to write to file at %s: %s (%v)", filename, strings.TrimSpace(string(bytes)), err)
9191
}
9292
return nil
9393
}
@@ -96,7 +96,7 @@ func KBFSList(path string) ([]string, error) {
9696
cmd := exec.Command("keybase", "fs", "ls", "-1", "--nocolor", path)
9797
output, err := cmd.CombinedOutput()
9898
if err != nil {
99-
return nil, fmt.Errorf("failed to list files in /keybase/team/: %s (%v)", string(output), err)
99+
return nil, fmt.Errorf("failed to list files in /keybase/team/: %s (%v)", strings.TrimSpace(string(output)), err)
100100
}
101101
var ret []string
102102
for _, s := range strings.Split(string(output), "\n") {

0 commit comments

Comments
 (0)