From db8fcb50a30d9ce52c5fba87369eb487e3b8b7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janne=20He=C3=9F?= Date: Fri, 27 Aug 2021 20:09:28 +0200 Subject: [PATCH] Add support for ssh-generated age keys --- default.nix | 2 +- go.mod | 1 + go.sum | 6 +- modules/sops/default.nix | 16 +- pkgs/bech32/bech32.go | 179 ++++++++++++++++++ pkgs/bech32/bech32_test.go | 94 +++++++++ pkgs/sops-install-secrets/agessh/convert.go | 45 +++++ pkgs/sops-install-secrets/main.go | 46 ++++- pkgs/sops-install-secrets/main_test.go | 36 +++- pkgs/sops-install-secrets/nixos-test.nix | 28 ++- .../test-assets/secrets.yaml | 70 ++++--- .../test-assets/ssh-ed25519-key | 7 + .../test-assets/ssh-ed25519-key.pub | 1 + 13 files changed, 488 insertions(+), 43 deletions(-) create mode 100644 pkgs/bech32/bech32.go create mode 100644 pkgs/bech32/bech32_test.go create mode 100644 pkgs/sops-install-secrets/agessh/convert.go create mode 100644 pkgs/sops-install-secrets/test-assets/ssh-ed25519-key create mode 100644 pkgs/sops-install-secrets/test-assets/ssh-ed25519-key.pub diff --git a/default.nix b/default.nix index 5e9e0f9..150e371 100644 --- a/default.nix +++ b/default.nix @@ -1,5 +1,5 @@ { pkgs ? import {} }: let - vendorSha256 = "sha256-YFy0eIIwrOvAiA+CJNVqY1AgswnPOzxq+GsA82XrT3M="; + vendorSha256 = "sha256:1bizqlj56lka37gbvm37p3yifn7w2z9kfhv486gv40wknzqclq12"; sops-install-secrets = pkgs.callPackage ./pkgs/sops-install-secrets { inherit vendorSha256; diff --git a/go.mod b/go.mod index d932090..6d92ef3 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Mic92/sops-nix go 1.14 require ( + filippo.io/age v1.0.0-rc.3 github.com/ProtonMail/go-crypto v0.0.0-20210707164159-52430bf6b52c github.com/mozilla-services/yaml v0.0.0-20191106225358-5c216288813c go.mozilla.org/sops/v3 v3.7.1 diff --git a/go.sum b/go.sum index 3b0e2b8..1726f6a 100644 --- a/go.sum +++ b/go.sum @@ -4,9 +4,11 @@ cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSR cloud.google.com/go v0.43.0 h1:banaiRPAM8kUVYneOSkhgcDsLzEvL25FinuiSZaH/2w= cloud.google.com/go v0.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= contrib.go.opencensus.io/exporter/ocagent v0.4.12/go.mod h1:450APlNTSR6FrvC3CTRqYosuDstRB9un7SOx2k/9ckA= -filippo.io/age v1.0.0-beta7 h1:RZiSK+N3KL2UwT82xiCavjYw8jJHzWMEUYePAukTpk0= filippo.io/age v1.0.0-beta7/go.mod h1:chAuTrTb0FTTmKtvs6fQTGhYTvH9AigjN1uEUsvLdZ0= +filippo.io/age v1.0.0-rc.3 h1:8JjuJ5ffGKDmC4SS0zoyQxZROZX75so768b7AjulKLw= +filippo.io/age v1.0.0-rc.3/go.mod h1:UjINLBMeA60aGZkHCGsmDzKcaXoTTzpvrqQM+Vo3YHU= filippo.io/edwards25519 v1.0.0-alpha.2/go.mod h1:X+pm78QAUPtFLi1z9PYIlS/bdDnvbCOGKtZ+ACWEf7o= +filippo.io/edwards25519 v1.0.0-beta.3/go.mod h1:X+pm78QAUPtFLi1z9PYIlS/bdDnvbCOGKtZ+ACWEf7o= github.com/Azure/azure-sdk-for-go v31.2.0+incompatible h1:kZFnTLmdQYNGfakatSivKHUfUnDZhqNdchHD4oIhp5k= github.com/Azure/azure-sdk-for-go v31.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= @@ -242,7 +244,6 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -273,6 +274,7 @@ golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaE golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= diff --git a/modules/sops/default.nix b/modules/sops/default.nix index 650fc92..76060e7 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -88,6 +88,7 @@ let gnupgHome = cfg.gnupg.home; sshKeyPaths = cfg.gnupg.sshKeyPaths; ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; }); checkedManifest = let @@ -152,6 +153,17 @@ in { present at the specified location. ''; }; + + sshKeyPaths = mkOption { + type = types.listOf types.path; + default = []; # If we set this like the gnupg option, we would use age by default + description = '' + Path to ssh keys added as age keys during sops description. + This option must be explicitly unset if config.sops.age.keyFile is set. + + Setting this to a non-empty list causes age to be used instead of gnupg. + ''; + }; }; gnupg = { @@ -182,10 +194,10 @@ in { ]; config = mkIf (cfg.secrets != {}) { assertions = [{ - assertion = cfg.age.keyFile == null -> (cfg.gnupg.home == null) != (cfg.gnupg.sshKeyPaths == []); + assertion = (cfg.age.keyFile == null && cfg.age.sshKeyPaths == []) -> (cfg.gnupg.home == null) != (cfg.gnupg.sshKeyPaths == []); message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set for gnupg mode"; } { - assertion = cfg.age.keyFile != null -> (cfg.age.sshKeyPaths != []) != (cfg.age.keyFile != null); + assertion = (cfg.age.keyFile != null || cfg.age.sshKeyPaths != []) -> (cfg.age.sshKeyPaths != []) != (cfg.age.keyFile != null); message = "sops.age.keyFile is mutually exclusive with sops.age.sshKeyPaths"; }] ++ optionals cfg.validateSopsFiles ( concatLists (mapAttrsToList (name: secret: [{ diff --git a/pkgs/bech32/bech32.go b/pkgs/bech32/bech32.go new file mode 100644 index 0000000..29310d4 --- /dev/null +++ b/pkgs/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/pkgs/bech32/bech32_test.go b/pkgs/bech32/bech32_test.go new file mode 100644 index 0000000..72f1ab6 --- /dev/null +++ b/pkgs/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" + + "filippo.io/age/internal/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/pkgs/sops-install-secrets/agessh/convert.go b/pkgs/sops-install-secrets/agessh/convert.go new file mode 100644 index 0000000..e9b978f --- /dev/null +++ b/pkgs/sops-install-secrets/agessh/convert.go @@ -0,0 +1,45 @@ +package agessh + +import ( + "crypto/ed25519" + "crypto/sha512" + "fmt" + "reflect" + "strings" + + "github.com/Mic92/sops-nix/pkgs/bech32" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/ssh" +) + +func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) ([]byte, error) { + h := sha512.New() + _, err := h.Write(pk.Seed()) + if err != nil { + return []byte{}, err + } + out := h.Sum(nil) + return out[:curve25519.ScalarSize], nil +} + +func SSHPrivateKeyToBech32(sshPrivateKey []byte) (string, error) { + privateKey, err := ssh.ParseRawPrivateKey(sshPrivateKey) + if err != nil { + return "", err + } + + ed25519Key, ok := privateKey.(*ed25519.PrivateKey) + if !ok { + return "", fmt.Errorf("Only ED25519 keys are supported, got: %s", reflect.TypeOf(privateKey)) + } + bytes, err := ed25519PrivateKeyToCurve25519(*ed25519Key) + if err != nil { + return "", err + } + + s, err := bech32.Encode("AGE-SECRET-KEY-", bytes) + if err != nil { + return "", err + } + return strings.ToUpper(s), nil +} diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index 7c0d7bc..d3fc5cb 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -15,7 +15,9 @@ import ( "strconv" "strings" "syscall" + "time" + "github.com/Mic92/sops-nix/pkgs/sops-install-secrets/agessh" "github.com/Mic92/sops-nix/pkgs/sops-install-secrets/sshkeys" "github.com/mozilla-services/yaml" @@ -47,6 +49,7 @@ type manifest struct { SSHKeyPaths []string `json:"sshKeyPaths"` GnupgHome string `json:"gnupgHome"` AgeKeyFile string `json:"ageKeyFile"` + AgeSshKeyPaths []string `json:"ageSshKeyPaths"` } type secretFile struct { @@ -516,6 +519,35 @@ func importSSHKeys(keyPaths []string, gpgHome string) error { return nil } +func importAgeSSHKeys(keyPaths []string, ageFilePath string) error { + ageFile, err := os.OpenFile(ageFilePath, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return fmt.Errorf("Cannot create '%s': %w", ageFilePath, err) + } + defer ageFile.Close() + fmt.Fprintf(ageFile, "# generated by sops-nix at %s\n", time.Now().Format(time.RFC3339)) + + for _, p := range keyPaths { + // Read the key + sshKey, err := ioutil.ReadFile(p) + if err != nil { + return fmt.Errorf("Cannot read ssh key '%s': %w", p, err) + } + // Convert the key to bech32 + bech32, err := agessh.SSHPrivateKeyToBech32(sshKey) + if err != nil { + return fmt.Errorf("Cannot convert ssh key '%s': %w", p, err) + } + // Append it to the file + _, err = ageFile.WriteString(bech32 + "\n") + if err != nil { + return fmt.Errorf("Cannot write key to age file: %w", err) + } + } + + return nil +} + type keyring struct { path string } @@ -611,8 +643,18 @@ func installSecrets(args []string) error { defer keyring.Remove() } else if manifest.GnupgHome != "" { os.Setenv("GNUPGHOME", manifest.GnupgHome) - } else if manifest.AgeKeyFile != "" { - os.Setenv("SOPS_AGE_KEY_FILE", manifest.AgeKeyFile) + } else if manifest.AgeKeyFile != "" || len(manifest.AgeSshKeyPaths) != 0 { + if len(manifest.AgeSshKeyPaths) == 0 { + os.Setenv("SOPS_AGE_KEY_FILE", manifest.AgeKeyFile) + } else { + keyfile := filepath.Join(manifest.SecretsMountPoint, "age-keys.txt") + err = importAgeSSHKeys(manifest.AgeSshKeyPaths, keyfile) + if err != nil { + return err + } + fmt.Printf("Wrote keys to %s\n", keyfile) + os.Setenv("SOPS_AGE_KEY_FILE", keyfile) + } } if err := decryptSecrets(manifest.Secrets); err != nil { diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go index 9046d4f..e0bf2ef 100644 --- a/pkgs/sops-install-secrets/main_test.go +++ b/pkgs/sops-install-secrets/main_test.go @@ -219,7 +219,7 @@ func testSSHKey(t *testing.T) { testInstallSecret(t, testdir, &m) } -func testAge(t *testing.T) { +func TestAge(t *testing.T) { assets := testAssetPath() testdir := newTestDir(t) @@ -252,11 +252,43 @@ func testAge(t *testing.T) { testInstallSecret(t, testdir, &m) } +func TestAgeWithSSH(t *testing.T) { + assets := testAssetPath() + + testdir := newTestDir(t) + defer testdir.Remove() + + target := path.Join(testdir.path, "existing-target") + file, err := os.Create(target) + ok(t, err) + file.Close() + + s := secret{ + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: target, + Mode: "0400", + RestartServices: []string{"affected-service"}, + ReloadServices: make([]string, 0), + } + + m := manifest{ + Secrets: []secret{s}, + SecretsMountPoint: testdir.secretsPath, + SymlinkPath: testdir.symlinkPath, + AgeSshKeyPaths: []string{path.Join(assets, "ssh-ed25519-key")}, + } + + testInstallSecret(t, testdir, &m) +} + func TestAll(t *testing.T) { // we can't test in parallel because we rely on GNUPGHOME environment variable testGPG(t) testSSHKey(t) - testAge(t) } func TestValidateManifest(t *testing.T) { diff --git a/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix index 147e9cd..7471f1f 100644 --- a/pkgs/sops-install-secrets/nixos-test.nix +++ b/pkgs/sops-install-secrets/nixos-test.nix @@ -38,10 +38,30 @@ start_all() machine.succeed("cat /run/secrets/test_key | grep -q test_value") ''; - } { - inherit pkgs; - inherit (pkgs) system; - }; + } { + inherit pkgs; + inherit (pkgs) system; + }; + + age-ssh-keys = makeTest { + name = "sops-age-ssh-keys"; + machine = { + imports = [ ../../modules/sops ]; + sops = { + age.sshKeyPaths = [ ./test-assets/ssh-ed25519-key ]; + defaultSopsFile = ./test-assets/secrets.yaml; + secrets.test_key = {}; + }; + }; + + testScript = '' + start_all() + machine.succeed("cat /run/secrets/test_key | grep -q test_value") + ''; + } { + inherit pkgs; + inherit (pkgs) system; + }; pgp-keys = makeTest { name = "sops-pgp-keys"; diff --git a/pkgs/sops-install-secrets/test-assets/secrets.yaml b/pkgs/sops-install-secrets/test-assets/secrets.yaml index 7f36ea1..ff111db 100644 --- a/pkgs/sops-install-secrets/test-assets/secrets.yaml +++ b/pkgs/sops-install-secrets/test-assets/secrets.yaml @@ -1,7 +1,7 @@ -test_key: ENC[AES256_GCM,data:6aaSGYcvIY1+lQ==,iv:voX4IQemcgt0O97oLExy5r2V85nn687cIyWmHNDhUag=,tag:/FSMgXuX8TnbxRxpcuwGEA==,type:str] +test_key: ENC[AES256_GCM,data:2mP+IAdczoEr0g==,iv:voX4IQemcgt0O97oLExy5r2V85nn687cIyWmHNDhUag=,tag:R97qy4fKneU7D9UFhXNvgA==,type:str] a_list: - - ENC[AES256_GCM,data:IBI=,iv:5P+1UQyIYOW8xXgsvTXC17msGcA6IGB3N8n+pstfqjo=,tag:7A4l/SgzgxK9sqi+/15A6w==,type:str] - - ENC[AES256_GCM,data:tLE=,iv:LbGS8DjM6Vnr2nU7QokzQlg0gL+XMWhqbN+ypP7ZIZo=,tag:cmhMaddcY2bhydWrPDWNlQ==,type:str] + - ENC[AES256_GCM,data:oOQ=,iv:5P+1UQyIYOW8xXgsvTXC17msGcA6IGB3N8n+pstfqjo=,tag:ox4rgjbb8c0vYZ2XmwRgpg==,type:str] + - ENC[AES256_GCM,data:mYU=,iv:LbGS8DjM6Vnr2nU7QokzQlg0gL+XMWhqbN+ypP7ZIZo=,tag:CFrhnZv6lYGJOVso+2YBFg==,type:str] sops: kms: [] gcp_kms: [] @@ -11,45 +11,55 @@ sops: - recipient: age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuY2RtdnhrMEtOeEUvRmFr - SU5uRm1xaU12R3F5TnJvbzI5YlNYMDFycGxvCnRBQVNwY2cxMDVicUNZWlpFdStp - ZTYxcUJkNEFHVkxTbGdrdkIxN3ZMNkUKLS0tIHd2K2ZhaFVoTmlhNDBKeXZyVHBh - ZXFnRy9hUTFNQm9rWVA3YnJXaWV0S2cKBpGxzhth36fab8KDKFBoweQO9L0juys4 - cMjz2X/hXVMLvDeCLVBTZTj3K/lXAo4v2qMUGZsR2Idpw3FOPxfSGg== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOcUlPTGtUY2R1Si9SUnpN + dGVMVzMzTXd2Z09UeGdxQlNqeVg3WTFadWtNCkpzaVJqMkZ0b1JUamJsbysrdGll + QVlLNU9xMnZqandMdGxKNlZ2amJFbncKLS0tIFlrbGtyMkZKNUthd0Z5VW5MVjBN + bWFhWGlJaXMzWUJoZGpnMjNoSnlMYTgK2hM/Cc6xN1xkluL69jDaaoaEijAJk+l8 + TwhUG7Qlggod2xCWTC4cpjb+THip2u31tFoSPQZKEG8gDcGNIz2HOw== -----END AGE ENCRYPTED FILE----- - lastmodified: "2021-08-26T22:38:49Z" - mac: ENC[AES256_GCM,data:1TNMF0HIfIOetCF4F268N5k2DFQ28JBYOdjPxfOuw03udJ1eKcTZrlBAGGFEgMdu1FuW+ZC+gLHW/b0GpfZWAtkiLuWP9SUof01rrFPYse6LvqWvlY1mLK6unbMveGeD9My3QTkZmt962I9tEHQW+ph35j80egGnN1f6stiJdlY=,iv:KEa/Q6q+B0F2Dv+/Km+2WeztYij0Of1pCSygGXM2fG4=,tag:TzuiTyYwTor36eGPKvTcwQ==,type:str] + - recipient: age1a8pk4akrdamj7nvqy3zywgtny8dxz7t5xzu7u8v9mhrayp9freqsqatyrs + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCS2NvekFqa2dVeXUzRzZY + emR6NDJqMnptOHpQUDBjTjdaMGp4SGZ1bTJVCk16Tnd3ZFl0dFUzQ1RNRzYyclV3 + d24xeDZMaXFvbEhZWXRObTM4RWRQMk0KLS0tIGs4bjFweEFjblFEalBOLzMyYyt6 + WHJmNkhFVStxRG5PZTZUWnRFTmtzemsKLXKJN3GSJKDI4MYPxDU5HbTzoSAt0jK9 + T9sJbd++By2OC9rl+GJoJcy4aM0uTYy83EDfqBV02Y1CfepRjHLRWQ== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2021-08-27T17:58:39Z" + mac: ENC[AES256_GCM,data:V9QGBTrofAza2LK1hA5cQmuT37BsfRJZZtSmvc5CDnIeWYTLUOkCFRzY+wk3uZNj0aM3BUAjvIM6LPNJ+rR8p0vXwKN37UH/oRmGADuLcu5Ec7hmArCGjKMo0gyaarlvuGmFhjb1gcW2PAbo84lDykxbTyf7Pp7APJhIvGbwBu4=,iv:ufG8sG5NAtVO25kZXrWnSQ/kkbDlwmOWhO4T9UpRvOg=,tag:YidkKTBqH48N7bjBabrAgQ==,type:str] pgp: - created_at: "2020-07-12T08:03:51Z" enc: | -----BEGIN PGP MESSAGE----- - hQEMA/m6nevQP1fAAQf/Zih3JJl/L/ApqkwdhIv7iUQsbebXV+WRwlslwOz6by/t - Sf6CH3b5FHLaBQtPEUh6QNLpQSqm52Rs4MoJxML0Tan8eu6Q313lS09XP5mQP1yc - d1FN4Tsg8V/xaGMGAtJHM/bj+6vfimGtnnQvNZ7N7PW2U+fxpq51NmqIXMmG+jOw - 2Tw+04oNoK6lrG89y0dzJrTvP9ph0wM+hwOgBthfhr0X6/UF7vS9fgEod+HFEqGn - MfAW4CiijXDvFYI5unwseeUE8IosOaq2VTW57eBSteLWIyJqmLUxvVSoMP0emitN - 4T0DkzTxywq5KPUMIEISEZrxYEskatqlTdQBmcjEAdJcAQl/u1OGCn7YRIJbXjMP - 8/UvlFVfaT3L19sgEg8rSVpo3GiwlwbP0KTcb4jVMu0mHhd70VciNKojtDVlZg9n - 0V8Z2LwPHY0+IzcTJu5IS7sO/fuyYJohWQ4ZV7Y= - =1olg + hQEMA/m6nevQP1fAAQgApvcEy9FBr6kag0PkFBabiEhqtKG6CcN/ewbxfDGXbOPI + hyndS7Poc6a7VeYo6cDQwxNqbUbjjn6BRZBFGHxuVInjvtDVm2phh/HOd25IH68s + RGh93wyW637rqJGp8+X3of7b+XBxq0fg0hLqKxR8iMaVF3WnyAMfS/r1tAOuHRGF + geMSQftnWkv1OIl2DPDcv02lqHSKqVZpidzxEdeAqAH/Ml9SoTOEyC8uNz0LIdvP + SQUp5JFp5CEyXaAzeTiypodIjCKOmCNTLuR9VC8O5+P+E62xVmxoFVVfozg2ZBdk + CJrEGR5jxTxAI1IB+ywWOde+cVzQtPXds1d3at2uFtJeAZuS3VYfvL1f4rXNrSBV + 3x7+rDknN8PsFAmmnLdxtbPJAij9eERpoAOsJOy6Ka4OSvOj4sCCU09wb2i/PugU + a7y4M55KzV/8J5aQ4iMVym/9Gkb98XK2Ff5na1jQGQ== + =MU7I -----END PGP MESSAGE----- fp: 7FB89715AADA920D65D25E63F9BA9DEBD03F57C0 - created_at: "2020-07-12T08:03:51Z" enc: | -----BEGIN PGP MESSAGE----- - hQGMA3ulPRkZxd/UAQv+LSFG7a6XqqW7GYdYLb1H0bv3NkxJeBYGohsMrEC4AXnA - RdhJ2B55WqiNyDmVmylalUKAPFpD7RJ4c6HDZcRLx6doy5wgA+oFvKCe8FcYy5YS - abSUhNoT0TwWlDQ4Bc4Q/QSu9kMCica56Bc10tz5Pvb5JEYApoLfL0kTO7mBI+w4 - hKovAxlp9aoT9cky/7WFhmFn1+mYz//ejDKPuyVSYhM/2eIosDE9maPootLQMrIX - cqnsCDCOgMDwIOXt5/W4Cab799Pop4SQcFDSDa4FU4kN12vmNwJu7HPxpR+W80bL - GOUu35gVyykig9XCaTA/UCc85URtOMYmXPBsdTVoMVAgfxkji3ovJCEFBcI7wYgd - XudLQw5tJ9Pwz+5EeNw771ISaTBO7bWazSq+q3HrKW9kKLFJV4kVt7zLr+yZ1kNu - jEjTFmaKYl3p9AYh6yxb6n9PuTosk1ZJE8gv8ME+b4z21BVxfZsDfYWVhT9thZz5 - q2LHw+E/fmf0EXz9tviS0k4BimrDWSnYNyuDqTImB+v7baY0rEoYhQY2ZLAbUsew - wg+KC2RjkapaZ7CFbCMs7xaWzZ5p/nfWOtsb1GvmvCUZcHK5MYeVM5S+Xf0GnYg= - =7cg6 + hQGMA3ulPRkZxd/UAQwAidXXZa5HVHCuI9pULCMVfX25pjYk3CpGdo1jLt20teRu + QVe5Ner7Z3QF8BMk4YRDDaJWlWLbHQE4KYM5/ER/iJyrSIp9wcIx7bQvoCO44KLh + 5wXbmxRnscUaW67+qdnjZBFSIHxtaeRYSBGCk3CGODnVamvGXdv733eG/O2IBHqt + sIE3+cOk6N+gQxYcz5IxJlRJlF6NagD4RxdMzjx6QJ43pZp8tKupDFZ1Teh1c4mY + 8XtVekaWz9ToKiQD3uQoCIwSW/YszuviYf/ar4Bi7j2xTH9vzSxxoRsSjo0JXKyB + EDj2Y1M5KzZAb3OWNINmNt2jqwKF8HS06TrbP6bdmRgHWRnwJLaSHSpxiclT/YpC + En4/ZvjqJdxyJc0nmEyDpEgelpTzm19jzFvsEvj43GnnWjh6/aAb0TF2Ms1E7I5E + VpJFI7l/I1JDacdDlvx1jFMhsya9n356GhZaiJky89hURsHhH5ek8E3f0PpC20dp + J7o8e7N0zXV39iIw6kdT0lABPe8KVRzQOsIKqNGaVwZVQuX4i/C1vbz6yTb+cHc6 + yCYEoi674QYg7ofZV4VkY318XOQz7P5sVlASjADvKF9SzjENadp8Y0SHvuYkXU4W + yQ== + =DqFU -----END PGP MESSAGE----- fp: 2504791468B153B8A3963CC97BA53D1919C5DFD4 unencrypted_suffix: _unencrypted diff --git a/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key b/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key new file mode 100644 index 0000000..75c2b77 --- /dev/null +++ b/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACASNfkPp7cwDXm7Z4nAogGNvbqixljmjhixGvG1KjlZkgAAAJjHY9ZUx2PW +VAAAAAtzc2gtZWQyNTUxOQAAACASNfkPp7cwDXm7Z4nAogGNvbqixljmjhixGvG1KjlZkg +AAAEC5eNs176OO7IO8ap33TVXlOxhhQYcYtv3VW+/5Ft8UohI1+Q+ntzANebtnicCiAY29 +uqLGWOaOGLEa8bUqOVmSAAAAEHNhcmF0QHNhcmF0LWRlbGwBAgMEBQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key.pub b/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key.pub new file mode 100644 index 0000000..f5ab15b --- /dev/null +++ b/pkgs/sops-install-secrets/test-assets/ssh-ed25519-key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBI1+Q+ntzANebtnicCiAY29uqLGWOaOGLEa8bUqOVmS