diff --git a/age/bech32/bech32.go b/age/bech32/bech32.go new file mode 100644 index 000000000..29310d468 --- /dev/null +++ b/age/bech32/bech32.go @@ -0,0 +1,179 @@ +// Copyright (c) 2017 Takatoshi Nakagawa +// Copyright (c) 2019 Google LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package bech32 is a modified version of the reference implementation of BIP173. +package bech32 + +import ( + "fmt" + "strings" +) + +var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} + +func polymod(values []byte) uint32 { + chk := uint32(1) + for _, v := range values { + top := chk >> 25 + chk = (chk & 0x1ffffff) << 5 + chk = chk ^ uint32(v) + for i := 0; i < 5; i++ { + bit := top >> i & 1 + if bit == 1 { + chk ^= generator[i] + } + } + } + return chk +} + +func hrpExpand(hrp string) []byte { + h := []byte(strings.ToLower(hrp)) + var ret []byte + for _, c := range h { + ret = append(ret, c>>5) + } + ret = append(ret, 0) + for _, c := range h { + ret = append(ret, c&31) + } + return ret +} + +func verifyChecksum(hrp string, data []byte) bool { + return polymod(append(hrpExpand(hrp), data...)) == 1 +} + +func createChecksum(hrp string, data []byte) []byte { + values := append(hrpExpand(hrp), data...) + values = append(values, []byte{0, 0, 0, 0, 0, 0}...) + mod := polymod(values) ^ 1 + ret := make([]byte, 6) + for p := range ret { + shift := 5 * (5 - p) + ret[p] = byte(mod>>shift) & 31 + } + return ret +} + +func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) { + var ret []byte + acc := uint32(0) + bits := byte(0) + maxv := byte(1<>frombits != 0 { + return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) + } + acc = acc<= tobits { + bits -= tobits + ret = append(ret, byte(acc>>bits)&maxv) + } + } + if pad { + if bits > 0 { + ret = append(ret, byte(acc<<(tobits-bits))&maxv) + } + } else if bits >= frombits { + return nil, fmt.Errorf("illegal zero padding") + } else if byte(acc<<(tobits-bits))&maxv != 0 { + return nil, fmt.Errorf("non-zero padding") + } + return ret, nil +} + +// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase, +// the output will be uppercase. +func Encode(hrp string, data []byte) (string, error) { + values, err := convertBits(data, 8, 5, true) + if err != nil { + return "", err + } + if len(hrp)+len(values)+7 > 90 { + return "", fmt.Errorf("too long: hrp length=%d, data length=%d", len(hrp), len(values)) + } + if len(hrp) < 1 { + return "", fmt.Errorf("invalid HRP: %q", hrp) + } + for p, c := range hrp { + if c < 33 || c > 126 { + return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c) + } + } + if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp { + return "", fmt.Errorf("mixed case HRP: %q", hrp) + } + lower := strings.ToLower(hrp) == hrp + hrp = strings.ToLower(hrp) + var ret strings.Builder + ret.WriteString(hrp) + ret.WriteString("1") + for _, p := range values { + ret.WriteByte(charset[p]) + } + for _, p := range createChecksum(hrp, values) { + ret.WriteByte(charset[p]) + } + if lower { + return ret.String(), nil + } + return strings.ToUpper(ret.String()), nil +} + +// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase. +func Decode(s string) (hrp string, data []byte, err error) { + if len(s) > 90 { + return "", nil, fmt.Errorf("too long: len=%d", len(s)) + } + if strings.ToLower(s) != s && strings.ToUpper(s) != s { + return "", nil, fmt.Errorf("mixed case") + } + pos := strings.LastIndex(s, "1") + if pos < 1 || pos+7 > len(s) { + return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s)) + } + hrp = s[:pos] + for p, c := range hrp { + if c < 33 || c > 126 { + return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c) + } + } + s = strings.ToLower(s) + for p, c := range s[pos+1:] { + d := strings.IndexRune(charset, c) + if d == -1 { + return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c) + } + data = append(data, byte(d)) + } + if !verifyChecksum(hrp, data) { + return "", nil, fmt.Errorf("invalid checksum") + } + data, err = convertBits(data[:len(data)-6], 5, 8, false) + if err != nil { + return "", nil, err + } + return hrp, data, nil +} diff --git a/age/bech32/bech32_test.go b/age/bech32/bech32_test.go new file mode 100644 index 000000000..810837eb7 --- /dev/null +++ b/age/bech32/bech32_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2013-2017 The btcsuite developers +// Copyright (c) 2016-2017 The Lightning Network Developers +// Copyright (c) 2019 Google LLC +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package bech32_test + +import ( + "strings" + "testing" + + "github.com/getsops/sops/v3/age/bech32" +) + +func TestBech32(t *testing.T) { + tests := []struct { + str string + valid bool + }{ + {"A12UEL5L", true}, + {"a12uel5l", true}, + {"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true}, + {"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true}, + {"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true}, + {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true}, + + // invalid checksum + {"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false}, + // invalid character (space) in hrp + {"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false}, + {"split1cheo2y9e2w", false}, // invalid character (o) in data part + {"split1a2y9w", false}, // too short data part + {"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp + // invalid character (DEL) in hrp + {"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, + // too long + {"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", false}, + + // BIP 173 invalid vectors. + {"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", false}, + {"pzry9x0s0muk", false}, + {"1pzry9x0s0muk", false}, + {"x1b4n0q5v", false}, + {"li1dgmt3", false}, + {"de1lg7wt\xff", false}, + {"A1G7SGD8", false}, + {"10a06t8", false}, + {"1qzzfhee", false}, + } + + for _, test := range tests { + str := test.str + hrp, decoded, err := bech32.Decode(str) + if !test.valid { + // Invalid string decoding should result in error. + if err == nil { + t.Errorf("expected decoding to fail for invalid string %v", test.str) + } + continue + } + + // Valid string decoding should result in no error. + if err != nil { + t.Errorf("expected string to be valid bech32: %v", err) + } + + // Check that it encodes to the same string. + encoded, err := bech32.Encode(hrp, decoded) + if err != nil { + t.Errorf("encoding failed: %v", err) + } + if encoded != str { + t.Errorf("expected data to encode to %v, but got %v", str, encoded) + } + + // Flip a bit in the string an make sure it is caught. + pos := strings.LastIndexAny(str, "1") + flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:] + if _, _, err = bech32.Decode(flipped); err == nil { + t.Error("expected decoding to fail") + } + } +} diff --git a/age/keysource.go b/age/keysource.go index 35ca024cf..569c6634d 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -297,11 +297,11 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) { sshKeyFilePath, ok := os.LookupEnv(SopsAgeSshPrivateKeyFileEnv) if ok { - identity, err := parseSSHIdentityFromPrivateKeyFile(sshKeyFilePath) + ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshKeyFilePath) if err != nil { errs = append(errs, err) } else { - identities = append(identities, identity) + identities = append(identities, ids...) } } else { unusedLocations = append(unusedLocations, SopsAgeSshPrivateKeyFileEnv) @@ -315,11 +315,11 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) { } else { sshEd25519PrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_ed25519") if _, err := os.Stat(sshEd25519PrivateKeyPath); err == nil { - identity, err := parseSSHIdentityFromPrivateKeyFile(sshEd25519PrivateKeyPath) + ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshEd25519PrivateKeyPath) if err != nil { errs = append(errs, err) } else { - identities = append(identities, identity) + identities = append(identities, ids...) } } else { unusedLocations = append(unusedLocations, sshEd25519PrivateKeyPath) @@ -327,11 +327,11 @@ func loadAgeSSHIdentities() ([]age.Identity, []string, errSet) { sshRsaPrivateKeyPath := filepath.Join(userHomeDir, ".ssh", "id_rsa") if _, err := os.Stat(sshRsaPrivateKeyPath); err == nil { - identity, err := parseSSHIdentityFromPrivateKeyFile(sshRsaPrivateKeyPath) + ids, err := parseSSHIdentitiesFromPrivateKeyFile(sshRsaPrivateKeyPath) if err != nil { errs = append(errs, err) } else { - identities = append(identities, identity) + identities = append(identities, ids...) } } else { unusedLocations = append(unusedLocations, sshRsaPrivateKeyPath) diff --git a/age/keysource_test.go b/age/keysource_test.go index 94b44cdc9..cdce5a628 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -1,6 +1,7 @@ package age import ( + "bytes" "fmt" "os" "path/filepath" @@ -8,7 +9,10 @@ import ( "strings" "testing" + "filippo.io/age" + "filippo.io/age/armor" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -281,6 +285,58 @@ func TestMasterKey_Decrypt(t *testing.T) { assert.EqualValues(t, mockEncryptedKeyPlain, got) }) + // Regression test for https://github.com/getsops/sops/issues/1999 + // Verifies that SSH keys can decrypt data encrypted to age recipients + // derived from the same SSH key (via ssh-to-age or similar tools) + t.Run("ssh key decrypts age recipient", func(t *testing.T) { + tmp := t.TempDir() + overwriteUserConfigDir(t, tmp) + + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + keyPath := filepath.Join(homeDir, ".ssh/id_ed25519_test") + require.True(t, strings.HasPrefix(keyPath, homeDir)) + + require.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o700)) + require.NoError(t, os.WriteFile(keyPath, []byte(mockSshIdentity), 0o644)) + t.Setenv(SopsAgeSshPrivateKeyFileEnv, keyPath) + + // Load identities from SSH key - should include both SSH and age identities + identities, _, errs := loadAgeSSHIdentities() + require.Empty(t, errs) + require.GreaterOrEqual(t, len(identities), 2, "ed25519 SSH key should produce both SSH and age identities") + + // Find the X25519 identity (the one that can work with age recipients) + var x25519Identity *age.X25519Identity + for _, id := range identities { + if xi, ok := id.(*age.X25519Identity); ok { + x25519Identity = xi + break + } + } + require.NotNil(t, x25519Identity, "should have X25519 identity derived from SSH key") + + // Get the corresponding recipient + recipient := x25519Identity.Recipient() + + // Encrypt data to the age recipient (simulating what ssh-to-age users do) + plaintext := []byte("test data for issue #1999") + var encryptedBuf bytes.Buffer + armorWriter := armor.NewWriter(&encryptedBuf) + encWriter, err := age.Encrypt(armorWriter, recipient) + require.NoError(t, err) + _, err = encWriter.Write(plaintext) + require.NoError(t, err) + require.NoError(t, encWriter.Close()) + require.NoError(t, armorWriter.Close()) + + // Now decrypt using SOPS's MasterKey with the SSH key loaded via env var + key := &MasterKey{EncryptedKey: encryptedBuf.String()} + got, err := key.Decrypt() + require.NoError(t, err, "SSH key should decrypt data encrypted to age recipient derived from same key") + assert.EqualValues(t, plaintext, got) + }) + t.Run("no identities", func(t *testing.T) { tmpDir := t.TempDir() overwriteUserConfigDir(t, tmpDir) @@ -441,7 +497,8 @@ func TestMasterKey_loadIdentities(t *testing.T) { key := &MasterKey{} got, unusedLocations, errs := key.loadIdentities() assert.Len(t, errs, 0) - assert.Len(t, got, 1) + // ed25519 SSH keys now return 2 identities: SSH identity + age X25519 identity + assert.Len(t, got, 2) assert.Len(t, unusedLocations, 5) }) diff --git a/age/ssh_parse.go b/age/ssh_parse.go index 467afc278..553944f2d 100644 --- a/age/ssh_parse.go +++ b/age/ssh_parse.go @@ -11,15 +11,49 @@ package age import ( + "crypto/ed25519" + "crypto/sha512" "fmt" "io" "os" + "strings" "filippo.io/age" "filippo.io/age/agessh" + "github.com/getsops/sops/v3/age/bech32" + "golang.org/x/crypto/curve25519" "golang.org/x/crypto/ssh" ) +// ed25519PrivateKeyToCurve25519 converts an ed25519 private key to curve25519. +// This is the same conversion that ssh-to-age performs, allowing SSH ed25519 keys +// to decrypt data encrypted to age recipients derived from the same key. +func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) ([]byte, error) { + h := sha512.New() + _, err := h.Write(pk.Seed()) + if err != nil { + return nil, err + } + out := h.Sum(nil) + return out[:curve25519.ScalarSize], nil +} + +// sshEd25519ToAgeIdentity converts an SSH ed25519 private key to an age X25519 identity. +// This allows decryption of data encrypted to age recipients derived from SSH keys +// (i.e., recipients created using ssh-to-age). +func sshEd25519ToAgeIdentity(privateKey ed25519.PrivateKey) (age.Identity, error) { + curve25519Key, err := ed25519PrivateKeyToCurve25519(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to convert ed25519 to curve25519: %w", err) + } + // Encode as AGE-SECRET-KEY-... and parse + encoded, err := bech32.Encode("AGE-SECRET-KEY-", curve25519Key) + if err != nil { + return nil, fmt.Errorf("failed to encode age identity: %w", err) + } + return age.ParseX25519Identity(strings.ToUpper(encoded)) +} + // readPublicKeyFile attempts to read a public key based on the given private // key path. It assumes the public key is in the same directory, with the same // name, but with a ".pub" extension. If the public key cannot be read, an @@ -42,10 +76,14 @@ func readPublicKeyFile(privateKeyPath string) (ssh.PublicKey, error) { return pubKey, nil } -// parseSSHIdentityFromPrivateKeyFile returns an age.Identity from the given -// private key file. If the private key file is encrypted, it will configure -// the identity to prompt for a passphrase. -func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { +// parseSSHIdentitiesFromPrivateKeyFile returns age identities from the given +// private key file. For ed25519 keys (encrypted or unencrypted), it returns: +// - An SSH identity (for decrypting data encrypted to SSH recipients) +// - An age X25519 identity (for decrypting data encrypted to age recipients +// derived from the same SSH key via ssh-to-age) +// +// For non-ed25519 keys, only the SSH identity is returned. +func parseSSHIdentitiesFromPrivateKeyFile(keyPath string) ([]age.Identity, error) { keyFile, err := os.Open(keyPath) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) @@ -55,30 +93,67 @@ func parseSSHIdentityFromPrivateKeyFile(keyPath string) (age.Identity, error) { if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } - id, err := agessh.ParseIdentity(contents) + + // Create SSH identity using agessh (handles both encrypted and unencrypted) + sshIdentity, err := agessh.ParseIdentity(contents) if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { - pubKey := sshErr.PublicKey - if pubKey == nil { - pubKey, err = readPublicKeyFile(keyPath) - if err != nil { - return nil, err - } - } - passphrasePrompt := func() ([]byte, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", keyPath)) - if err != nil { - return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) + return parseEncryptedSSHKey(keyPath, contents, sshErr) + } + if err != nil { + return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) + } + + identities := []age.Identity{sshIdentity} + + // For ed25519 keys, also create an age X25519 identity so we can decrypt + // data encrypted to age recipients derived from this SSH key (via ssh-to-age). + privateKey, err := ssh.ParseRawPrivateKey(contents) + if err == nil { + if ed25519Key, ok := privateKey.(*ed25519.PrivateKey); ok { + if ageIdentity, err := sshEd25519ToAgeIdentity(*ed25519Key); err == nil { + identities = append(identities, ageIdentity) } - return pass, nil } - i, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, passphrasePrompt) + } + + return identities, nil +} + +// parseEncryptedSSHKey handles passphrase-protected SSH keys. +func parseEncryptedSSHKey(keyPath string, contents []byte, sshErr *ssh.PassphraseMissingError) ([]age.Identity, error) { + pubKey := sshErr.PublicKey + if pubKey == nil { + var err error + pubKey, err = readPublicKeyFile(keyPath) if err != nil { - return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) + return nil, err } - return i, nil } + + passphrase, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", keyPath)) if err != nil { - return nil, fmt.Errorf("malformed SSH identity in %q: %w", keyPath, err) + return nil, fmt.Errorf("could not read passphrase for %q: %v", keyPath, err) } - return id, nil + + // Create SSH identity with cached passphrase + sshIdentity, err := agessh.NewEncryptedSSHIdentity(pubKey, contents, func() ([]byte, error) { + return passphrase, nil + }) + if err != nil { + return nil, fmt.Errorf("could not create encrypted SSH identity: %w", err) + } + + identities := []age.Identity{sshIdentity} + + // For ed25519 keys, also create an age X25519 identity + privateKey, err := ssh.ParseRawPrivateKeyWithPassphrase(contents, passphrase) + if err == nil { + if ed25519Key, ok := privateKey.(*ed25519.PrivateKey); ok { + if ageIdentity, err := sshEd25519ToAgeIdentity(*ed25519Key); err == nil { + identities = append(identities, ageIdentity) + } + } + } + + return identities, nil }