Skip to content

Commit 6b9e673

Browse files
committed
feat(crypto): support SSH key
v0.15.0
1 parent 15767f2 commit 6b9e673

File tree

10 files changed

+239
-12
lines changed

10 files changed

+239
-12
lines changed

README.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@ It provides the following in a single binary:
1414

1515
pago encrypts passwords with one or more public keys using [age](https://github.com/FiloSottile/age) (pronounced with a hard "g").
1616
The public keys are called "recipients".
17+
Recipients can be:
18+
- age recipients
19+
- SSH public keys
20+
1721
A private key matching one of the recipient public keys can decrypt the password.
1822
The private keys are called "identities".
23+
Identities can be:
24+
- age identities
25+
- SSH private keys
26+
1927
The file with the identities is encrypted with a password, also using age.
2028

2129
pago implements an agent like [ssh-agent](https://en.wikipedia.org/wiki/Ssh-agent) or [gpg-agent](https://www.gnupg.org/documentation/manuals/gnupg/Invoking-GPG_002dAGENT.html).
2230
The agent caches the identities.
23-
This mean you don't have to enter the master password again during a session.
31+
This means you don't have to enter the master password again during a session.
2432
pago starts the agent the first time you enter the master password.
2533
You can also start and stop the agent manually.
2634

@@ -78,7 +86,7 @@ You may need to allow pago-agent to [**lock enough memory**](#memory-locking).
7886

7987
## Supported platforms
8088

81-
- pago is used by the developer on Linux, NetBSD, and rarely) OpenBSD.
89+
- pago is used by the developer on Linux, NetBSD, and (rarely) OpenBSD.
8290
- pago is automatically tested on FreeBSD and macOS.
8391
- pago does not build on Windows.
8492

@@ -96,6 +104,45 @@ pago init
96104

97105
This will create a new password store, prompt you for a master password, and commit the recipients file to Git.
98106

107+
### Using SSH keys
108+
109+
To use pago with an SSH key as an identity, follow these steps.
110+
Back up your `identities` file and install age for the command line before proceeding.
111+
112+
Note that the SSH key must not be encrypted, i.e., must not have a password.
113+
If necessary, remove the password with `ssh-keygen`.
114+
pago encrypts `identities` with a password using age encryption.
115+
116+
You may wish to work with secrets in memory or on an encrypted disk.
117+
On Linux with glibc, you normally have `/dev/shm/` available as temporary in-memory storage.
118+
119+
1. Add your SSH _public_ key to `.age-recipients`.
120+
You can have multiple recipients.
121+
122+
```shell
123+
# Repeat for every SSH key.
124+
cat ~/.ssh/id_ed25519.pub >> ~/.local/share/pago/store/.age-recipients
125+
126+
pago rekey
127+
```
128+
129+
2. Add the corresponding SSH _private_ key to the encrypted identities file.
130+
This is not automated and requires decrypting the file manually using the `age` command.
131+
We are going to use a directory in `/dev/shm/` in this example.
132+
133+
```shell
134+
# Edit the identities file.
135+
mkdir -p "/dev/shm/pago-$USER-temp/"
136+
age -d -o "/dev/shm/pago-$USER-temp/identities" ~/.local/share/pago/identities
137+
# Ensure a line break.
138+
echo >> "/dev/shm/pago-$USER-temp/identities"
139+
cat ~/.ssh/id_ed25519 >> "/dev/shm/pago-$USER-temp/identities"
140+
age -a -e -p -o ~/.local/share/pago/identities "/dev/shm/pago-$USER-temp/identities"
141+
142+
# Restart the agent to load the new identities.
143+
pago agent restart
144+
```
145+
99146
### Add passwords
100147

101148
```shell

agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func Run(socket string) error {
113113

114114
identitiesText := string(cmd.Args[1])
115115

116-
newIdentities, err := age.ParseIdentities(strings.NewReader(identitiesText))
116+
newIdentities, err := crypto.ParseIdentities(identitiesText)
117117
if err != nil {
118118
conn.WriteError(`ERR failed to parse identities`)
119119
return

cmd/pago/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ func (cmd *RekeyCmd) Run(config *Config) error {
704704
return err
705705
}
706706

707-
ids, err := age.ParseIdentities(strings.NewReader(identitiesText))
707+
ids, err := crypto.ParseIdentities(identitiesText)
708708
if err != nil {
709709
return fmt.Errorf("failed to parse identities: %v", err)
710710
}

config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ const (
2222
ExitMemlockError = 3
2323
FilePerms = 0o600
2424
NameInvalidChars = `[\n]`
25-
Version = "0.14.0"
25+
Version = "0.15.0"
2626
WaitForSocket = 3 * time.Second
2727

2828
DefaultAgent = "pago-agent"
29-
DefaultGitEmail = "pago password manager"
30-
DefaultGitName = "pago@localhost"
29+
DefaultGitEmail = "pago@localhost"
30+
DefaultGitName = "pago password manager"
3131
DefaultPasswordLength = "20"
3232
DefaultPasswordPattern = "[A-Za-z0-9]"
3333

crypto/crypto.go

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"dbohdan.com/pago/input"
2020

2121
"filippo.io/age"
22+
"filippo.io/age/agessh"
2223
"filippo.io/age/armor"
2324
)
2425

@@ -32,9 +33,17 @@ func ParseRecipients(contents string) ([]age.Recipient, error) {
3233
continue
3334
}
3435

35-
recipient, err := age.ParseX25519Recipient(line)
36+
var recipient age.Recipient
37+
var err error
38+
39+
// First, try to parse as an X25519 recipient.
40+
recipient, err = age.ParseX25519Recipient(line)
3641
if err != nil {
37-
return nil, fmt.Errorf("invalid recipient: %v", err)
42+
// Then try parsing as an SSH public key.
43+
recipient, err = agessh.ParseRecipient(line)
44+
if err != nil {
45+
return nil, fmt.Errorf("invalid recipient: %v", err)
46+
}
3847
}
3948

4049
recips = append(recips, recipient)
@@ -112,6 +121,64 @@ func WrapDecrypt(r io.Reader, identities ...age.Identity) (io.Reader, error) {
112121
return age.Decrypt(r, identities...)
113122
}
114123

124+
func ParseIdentities(identityData string) ([]age.Identity, error) {
125+
var allIdentities []age.Identity
126+
var pemBlock []string
127+
inPEMBlock := false
128+
129+
lines := strings.Split(identityData, "\n")
130+
131+
for _, line := range lines {
132+
trimmedLine := strings.TrimSpace(line)
133+
134+
if strings.HasPrefix(trimmedLine, "-----BEGIN") {
135+
if inPEMBlock {
136+
return nil, errors.New("invalid PEM block: nested BEGIN")
137+
}
138+
139+
inPEMBlock = true
140+
pemBlock = []string{line}
141+
continue
142+
}
143+
144+
if inPEMBlock {
145+
pemBlock = append(pemBlock, line)
146+
if strings.HasPrefix(trimmedLine, "-----END") {
147+
inPEMBlock = false
148+
pemBytes := []byte(strings.Join(pemBlock, "\n"))
149+
150+
id, err := agessh.ParseIdentity(pemBytes)
151+
if err != nil {
152+
return nil, fmt.Errorf("invalid SSH identity in PEM block: %v", err)
153+
}
154+
allIdentities = append(allIdentities, id)
155+
pemBlock = nil
156+
}
157+
158+
continue
159+
}
160+
161+
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
162+
continue
163+
}
164+
165+
// If it's not a PEM block, it must be a native age identity.
166+
id, err := age.ParseX25519Identity(trimmedLine)
167+
if err != nil {
168+
return nil, fmt.Errorf("invalid identity: %v", err)
169+
}
170+
171+
allIdentities = append(allIdentities, id)
172+
fmt.Fprintf(os.Stderr, "allIdentities: %q\n", allIdentities)
173+
}
174+
175+
if inPEMBlock {
176+
return nil, errors.New("invalid PEM block: missing END")
177+
}
178+
179+
return allIdentities, nil
180+
}
181+
115182
func DecryptIdentities(identitiesPath string) (string, error) {
116183
encryptedData, err := os.ReadFile(identitiesPath)
117184
if err != nil {
@@ -158,7 +225,7 @@ func DecryptEntry(identities, passwordStore, name string) (string, error) {
158225
return "", err
159226
}
160227

161-
ids, err := age.ParseIdentities(strings.NewReader(identitiesText))
228+
ids, err := ParseIdentities(identitiesText)
162229
if err != nil {
163230
return "", fmt.Errorf("failed to parse identities: %v", err)
164231
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323

2424
require (
2525
dario.cat/mergo v1.0.2 // indirect
26+
filippo.io/edwards25519 v1.1.0 // indirect
2627
github.com/Microsoft/go-winio v0.6.2 // indirect
2728
github.com/ProtonMail/go-crypto v1.3.0 // indirect
2829
github.com/atotto/clipboard v0.1.4 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
44
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
55
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
66
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
7+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
8+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
79
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
810
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
911
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=

test/e2e_test.go

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"strings"
1717
"testing"
1818

19+
"dbohdan.com/pago"
20+
"dbohdan.com/pago/crypto"
1921
"dbohdan.com/pago/tree"
2022

2123
"filippo.io/age"
@@ -88,7 +90,7 @@ func withPagoDir(test func(dataDir string) (string, error)) (string, error) {
8890
}
8991

9092
func createFakeEntry(dataDir, name string) error {
91-
file, err := os.OpenFile(filepath.Join(dataDir, "store", name+".age"), os.O_CREATE|os.O_RDONLY, 0600)
93+
file, err := os.OpenFile(filepath.Join(dataDir, "store", name+".age"), os.O_CREATE|os.O_RDONLY, pago.FilePerms)
9294
if err != nil {
9395
return err
9496
}
@@ -320,6 +322,106 @@ func TestGenerate(t *testing.T) {
320322
}
321323
}
322324

325+
func getSSHIdentity(t *testing.T) age.Identity {
326+
t.Helper()
327+
328+
privateKey, err := os.ReadFile("id_ed25519")
329+
if err != nil {
330+
t.Fatalf("Failed to read SSH private key: %v", err)
331+
}
332+
333+
ids, err := crypto.ParseIdentities(string(privateKey))
334+
if err != nil {
335+
t.Fatalf("Failed to parse SSH identity: %v", err)
336+
}
337+
338+
return ids[0]
339+
}
340+
341+
func TestRekeyWithSSH(t *testing.T) {
342+
_, err := withPagoDir(func(dataDir string) (string, error) {
343+
for _, name := range []string{"foo", "bar", "baz/qux"} {
344+
stdout, stderr, err := runCommandEnv(
345+
[]string{"PAGO_DIR=" + dataDir},
346+
"add", name, "--length", "32", "--pattern", "[a]", "--random",
347+
)
348+
if err != nil {
349+
return stdout + "\n" + stderr, err
350+
}
351+
}
352+
353+
// Write the SSH public key to .age-recipients.
354+
publicKey, err := os.ReadFile("id_ed25519.pub")
355+
if err != nil {
356+
return "", fmt.Errorf("failed to read test SSH public key: %w", err)
357+
}
358+
359+
recipientsPath := filepath.Join(dataDir, "store/.age-recipients")
360+
err = os.WriteFile(recipientsPath, publicKey, pago.FilePerms)
361+
if err != nil {
362+
return "", fmt.Errorf("failed to write recipients file: %w", err)
363+
}
364+
365+
c, err := expect.NewConsole()
366+
if err != nil {
367+
return "", fmt.Errorf("failed to create console: %w", err)
368+
}
369+
defer c.Close()
370+
371+
cmd := exec.Command(commandPago, "--dir", dataDir, "--socket", "", "rekey")
372+
cmd.Stdin = c.Tty()
373+
cmd.Stdout = c.Tty()
374+
cmd.Stderr = c.Tty()
375+
376+
err = cmd.Start()
377+
if err != nil {
378+
return "", fmt.Errorf("failed to start rekey command: %w", err)
379+
}
380+
381+
_, err = c.ExpectString("Enter password")
382+
if err != nil {
383+
return "", fmt.Errorf("failed to get password prompt: %w", err)
384+
}
385+
_, _ = c.SendLine(password)
386+
387+
err = cmd.Wait()
388+
if err != nil {
389+
return "", fmt.Errorf("rekey failed: %w", err)
390+
}
391+
392+
// Verify we can decrypt the entries using the SSH key.
393+
sshIdentity := getSSHIdentity(t)
394+
395+
for _, name := range []string{"foo", "bar", "baz/qux"} {
396+
encryptedPath := filepath.Join(dataDir, "store", name+".age")
397+
encryptedBytes, err := os.ReadFile(encryptedPath)
398+
if err != nil {
399+
return "", fmt.Errorf("failed to read encrypted file %q: %w", name, err)
400+
}
401+
402+
r, err := age.Decrypt(armor.NewReader(bytes.NewReader(encryptedBytes)), sshIdentity)
403+
if err != nil {
404+
return "", fmt.Errorf("failed to decrypt %q: %w", name, err)
405+
}
406+
407+
decrypted, err := io.ReadAll(r)
408+
if err != nil {
409+
return "", fmt.Errorf("failed to read decrypted content of %q: %w", name, err)
410+
}
411+
412+
if !regexp.MustCompile(`^a{32}$`).Match(decrypted) {
413+
return "", fmt.Errorf("unexpected decrypted content for %q: %q", name, decrypted)
414+
}
415+
}
416+
417+
return "", nil
418+
})
419+
420+
if err != nil {
421+
t.Errorf("SSH rekey test failed: %v", err)
422+
}
423+
}
424+
323425
func TestRekey(t *testing.T) {
324426
_, err := withPagoDir(func(dataDir string) (string, error) {
325427
for _, name := range []string{"foo", "bar", "baz/qux"} {
@@ -339,7 +441,7 @@ func TestRekey(t *testing.T) {
339441

340442
// Write the public key to .age-recipients.
341443
recipientsPath := filepath.Join(dataDir, "store/.age-recipients")
342-
err = os.WriteFile(recipientsPath, []byte(identity.Recipient().String()+"\n"), 0600)
444+
err = os.WriteFile(recipientsPath, []byte(identity.Recipient().String()+"\n"), pago.FilePerms)
343445
if err != nil {
344446
return "", fmt.Errorf("failed to write recipients file: %w", err)
345447
}

test/id_ed25519

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3+
QyNTUxOQAAACBhHkkIBCd9h1kr/T2Mp9mKyHCwUIF65owOYs2PtuOeNwAAAJhKwahRSsGo
4+
UQAAAAtzc2gtZWQyNTUxOQAAACBhHkkIBCd9h1kr/T2Mp9mKyHCwUIF65owOYs2PtuOeNw
5+
AAAED+uxPS3oSzjdeiXiwuJvg6XcSwW9p0qILdiIcBcmAUS2EeSQgEJ32HWSv9PYyn2YrI
6+
cLBQgXrmjA5izY+24543AAAAD2Rib2hkYW5AaG91ZGluaQECAwQFBg==
7+
-----END OPENSSH PRIVATE KEY-----

test/id_ed25519.pub

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGEeSQgEJ32HWSv9PYyn2YrIcLBQgXrmjA5izY+24543 user@localhost

0 commit comments

Comments
 (0)