Skip to content

Commit b19cafa

Browse files
committed
Add SSH ed25519 to age X25519 identity conversion
Enable SSH ed25519 keys to decrypt data encrypted to age recipients derived from the same key (via ssh-to-age or similar tools). Changes: - Add bech32 package for encoding age secret keys - Convert ed25519 SSH keys to age X25519 identities during key loading - Handle both encrypted and unencrypted SSH keys with single passphrase prompt - Return multiple identities from parseSSHIdentitiesFromPrivateKeyFile Fixes #1999 Signed-off-by: gokselk <gokselk.dev@gmail.com>
1 parent 53cc5fd commit b19cafa

File tree

5 files changed

+434
-29
lines changed

5 files changed

+434
-29
lines changed

age/bech32/bech32.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2017 Takatoshi Nakagawa
2+
// Copyright (c) 2019 Google LLC
3+
//
4+
// Permission is hereby granted, free of charge, to any person obtaining a copy
5+
// of this software and associated documentation files (the "Software"), to deal
6+
// in the Software without restriction, including without limitation the rights
7+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
// copies of the Software, and to permit persons to whom the Software is
9+
// furnished to do so, subject to the following conditions:
10+
//
11+
// The above copyright notice and this permission notice shall be included in
12+
// all copies or substantial portions of the Software.
13+
//
14+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
// THE SOFTWARE.
21+
22+
// Package bech32 is a modified version of the reference implementation of BIP173.
23+
package bech32
24+
25+
import (
26+
"fmt"
27+
"strings"
28+
)
29+
30+
var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
31+
32+
var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
33+
34+
func polymod(values []byte) uint32 {
35+
chk := uint32(1)
36+
for _, v := range values {
37+
top := chk >> 25
38+
chk = (chk & 0x1ffffff) << 5
39+
chk = chk ^ uint32(v)
40+
for i := 0; i < 5; i++ {
41+
bit := top >> i & 1
42+
if bit == 1 {
43+
chk ^= generator[i]
44+
}
45+
}
46+
}
47+
return chk
48+
}
49+
50+
func hrpExpand(hrp string) []byte {
51+
h := []byte(strings.ToLower(hrp))
52+
var ret []byte
53+
for _, c := range h {
54+
ret = append(ret, c>>5)
55+
}
56+
ret = append(ret, 0)
57+
for _, c := range h {
58+
ret = append(ret, c&31)
59+
}
60+
return ret
61+
}
62+
63+
func verifyChecksum(hrp string, data []byte) bool {
64+
return polymod(append(hrpExpand(hrp), data...)) == 1
65+
}
66+
67+
func createChecksum(hrp string, data []byte) []byte {
68+
values := append(hrpExpand(hrp), data...)
69+
values = append(values, []byte{0, 0, 0, 0, 0, 0}...)
70+
mod := polymod(values) ^ 1
71+
ret := make([]byte, 6)
72+
for p := range ret {
73+
shift := 5 * (5 - p)
74+
ret[p] = byte(mod>>shift) & 31
75+
}
76+
return ret
77+
}
78+
79+
func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) {
80+
var ret []byte
81+
acc := uint32(0)
82+
bits := byte(0)
83+
maxv := byte(1<<tobits - 1)
84+
for idx, value := range data {
85+
if value>>frombits != 0 {
86+
return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits)
87+
}
88+
acc = acc<<frombits | uint32(value)
89+
bits += frombits
90+
for bits >= tobits {
91+
bits -= tobits
92+
ret = append(ret, byte(acc>>bits)&maxv)
93+
}
94+
}
95+
if pad {
96+
if bits > 0 {
97+
ret = append(ret, byte(acc<<(tobits-bits))&maxv)
98+
}
99+
} else if bits >= frombits {
100+
return nil, fmt.Errorf("illegal zero padding")
101+
} else if byte(acc<<(tobits-bits))&maxv != 0 {
102+
return nil, fmt.Errorf("non-zero padding")
103+
}
104+
return ret, nil
105+
}
106+
107+
// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase,
108+
// the output will be uppercase.
109+
func Encode(hrp string, data []byte) (string, error) {
110+
values, err := convertBits(data, 8, 5, true)
111+
if err != nil {
112+
return "", err
113+
}
114+
if len(hrp)+len(values)+7 > 90 {
115+
return "", fmt.Errorf("too long: hrp length=%d, data length=%d", len(hrp), len(values))
116+
}
117+
if len(hrp) < 1 {
118+
return "", fmt.Errorf("invalid HRP: %q", hrp)
119+
}
120+
for p, c := range hrp {
121+
if c < 33 || c > 126 {
122+
return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c)
123+
}
124+
}
125+
if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp {
126+
return "", fmt.Errorf("mixed case HRP: %q", hrp)
127+
}
128+
lower := strings.ToLower(hrp) == hrp
129+
hrp = strings.ToLower(hrp)
130+
var ret strings.Builder
131+
ret.WriteString(hrp)
132+
ret.WriteString("1")
133+
for _, p := range values {
134+
ret.WriteByte(charset[p])
135+
}
136+
for _, p := range createChecksum(hrp, values) {
137+
ret.WriteByte(charset[p])
138+
}
139+
if lower {
140+
return ret.String(), nil
141+
}
142+
return strings.ToUpper(ret.String()), nil
143+
}
144+
145+
// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase.
146+
func Decode(s string) (hrp string, data []byte, err error) {
147+
if len(s) > 90 {
148+
return "", nil, fmt.Errorf("too long: len=%d", len(s))
149+
}
150+
if strings.ToLower(s) != s && strings.ToUpper(s) != s {
151+
return "", nil, fmt.Errorf("mixed case")
152+
}
153+
pos := strings.LastIndex(s, "1")
154+
if pos < 1 || pos+7 > len(s) {
155+
return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s))
156+
}
157+
hrp = s[:pos]
158+
for p, c := range hrp {
159+
if c < 33 || c > 126 {
160+
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
161+
}
162+
}
163+
s = strings.ToLower(s)
164+
for p, c := range s[pos+1:] {
165+
d := strings.IndexRune(charset, c)
166+
if d == -1 {
167+
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
168+
}
169+
data = append(data, byte(d))
170+
}
171+
if !verifyChecksum(hrp, data) {
172+
return "", nil, fmt.Errorf("invalid checksum")
173+
}
174+
data, err = convertBits(data[:len(data)-6], 5, 8, false)
175+
if err != nil {
176+
return "", nil, err
177+
}
178+
return hrp, data, nil
179+
}

age/bech32/bech32_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2013-2017 The btcsuite developers
2+
// Copyright (c) 2016-2017 The Lightning Network Developers
3+
// Copyright (c) 2019 Google LLC
4+
//
5+
// Permission to use, copy, modify, and distribute this software for any
6+
// purpose with or without fee is hereby granted, provided that the above
7+
// copyright notice and this permission notice appear in all copies.
8+
//
9+
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10+
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11+
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12+
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13+
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14+
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15+
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16+
17+
package bech32_test
18+
19+
import (
20+
"strings"
21+
"testing"
22+
23+
"github.com/getsops/sops/v3/age/bech32"
24+
)
25+
26+
func TestBech32(t *testing.T) {
27+
tests := []struct {
28+
str string
29+
valid bool
30+
}{
31+
{"A12UEL5L", true},
32+
{"a12uel5l", true},
33+
{"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true},
34+
{"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true},
35+
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true},
36+
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true},
37+
38+
// invalid checksum
39+
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false},
40+
// invalid character (space) in hrp
41+
{"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false},
42+
{"split1cheo2y9e2w", false}, // invalid character (o) in data part
43+
{"split1a2y9w", false}, // too short data part
44+
{"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp
45+
// invalid character (DEL) in hrp
46+
{"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false},
47+
// too long
48+
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", false},
49+
50+
// BIP 173 invalid vectors.
51+
{"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", false},
52+
{"pzry9x0s0muk", false},
53+
{"1pzry9x0s0muk", false},
54+
{"x1b4n0q5v", false},
55+
{"li1dgmt3", false},
56+
{"de1lg7wt\xff", false},
57+
{"A1G7SGD8", false},
58+
{"10a06t8", false},
59+
{"1qzzfhee", false},
60+
}
61+
62+
for _, test := range tests {
63+
str := test.str
64+
hrp, decoded, err := bech32.Decode(str)
65+
if !test.valid {
66+
// Invalid string decoding should result in error.
67+
if err == nil {
68+
t.Errorf("expected decoding to fail for invalid string %v", test.str)
69+
}
70+
continue
71+
}
72+
73+
// Valid string decoding should result in no error.
74+
if err != nil {
75+
t.Errorf("expected string to be valid bech32: %v", err)
76+
}
77+
78+
// Check that it encodes to the same string.
79+
encoded, err := bech32.Encode(hrp, decoded)
80+
if err != nil {
81+
t.Errorf("encoding failed: %v", err)
82+
}
83+
if encoded != str {
84+
t.Errorf("expected data to encode to %v, but got %v", str, encoded)
85+
}
86+
87+
// Flip a bit in the string an make sure it is caught.
88+
pos := strings.LastIndexAny(str, "1")
89+
flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:]
90+
if _, _, err = bech32.Decode(flipped); err == nil {
91+
t.Error("expected decoding to fail")
92+
}
93+
}
94+
}

age/keysource.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,11 +297,11 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
297297

298298
sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyFileEnv)
299299
if ok {
300-
identity, err := parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath)
300+
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshKeyFilePath)
301301
if err != nil {
302302
errs = append(errs, err)
303303
} else {
304-
identities = append(identities, identity)
304+
identities = append(identities, ids...)
305305
}
306306
} else {
307307
unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyFileEnv)
@@ -315,23 +315,23 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) {
315315
} else {
316316
sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519")
317317
if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil {
318-
identity, err := parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath)
318+
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshEd25519PrivateKeyPath)
319319
if err != nil {
320320
errs = append(errs, err)
321321
} else {
322-
identities = append(identities, identity)
322+
identities = append(identities, ids...)
323323
}
324324
} else {
325325
unusedLocations = append(unusedLocations, sshEd25519PrivateKeyPath)
326326
}
327327

328328
sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa")
329329
if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil {
330-
identity, err := parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath)
330+
ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshRsaPrivateKeyPath)
331331
if err != nil {
332332
errs = append(errs, err)
333333
} else {
334-
identities = append(identities, identity)
334+
identities = append(identities, ids...)
335335
}
336336
} else {
337337
unusedLocations = append(unusedLocations, sshRsaPrivateKeyPath)

0 commit comments

Comments
 (0)