├── .github ├── dependabot.yml └── workflows │ ├── auto-merge.yaml │ └── update-vendor-hash.yml ├── LICENSE ├── README.md ├── bech32 ├── bech32.go └── bech32_test.go ├── bin └── create-release.sh ├── cmd └── ssh-to-age │ ├── main.go │ ├── main_test.go │ └── test-assets │ ├── id_ed25519 │ ├── id_ed25519.pub │ ├── id_ed25519_passphrase │ ├── id_ed25519_passphrase.pub │ └── keyscan.txt ├── convert.go ├── default.nix ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── renovate.json └── scripts └── update-vendor-hash.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependency Updates 2 | on: 3 | - pull_request_target 4 | jobs: 5 | auto-merge-dependency-updates: 6 | runs-on: ubuntu-latest 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | concurrency: 11 | group: "auto-merge:${{ github.head_ref }}" 12 | cancel-in-progress: true 13 | steps: 14 | - uses: Mic92/auto-merge@main 15 | -------------------------------------------------------------------------------- /.github/workflows/update-vendor-hash.yml: -------------------------------------------------------------------------------- 1 | name: Update vendorHash 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | dependabot: 9 | runs-on: ubuntu-latest 10 | if: ${{ github.actor == 'dependabot[bot]' }} 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | ref: ${{ github.event.pull_request.head.sha }} 15 | fetch-depth: 0 16 | - name: Install Nix 17 | uses: cachix/install-nix-action@v31 18 | with: 19 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 20 | nix_path: nixpkgs=channel:nixos-unstable 21 | - name: Update checksum 22 | run: | 23 | ./scripts/update-vendor-hash.sh 24 | # git push if we have a diff 25 | if [[ -n $(git diff) ]]; then 26 | git add default.nix 27 | git config --global user.email "<49699333+dependabot[bot]@users.noreply.github.com>" 28 | git config --global user.name "dependabot[bot]" 29 | git commit -m "update vendorHash" 30 | git push origin HEAD:${{ github.head_ref }} 31 | fi 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jörg Thalheim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-to-age 2 | Convert SSH Ed25519 keys to [age](https://github.com/FiloSottile/age) keys. 3 | This is useful for usage in [sops-nix](https://github.com/Mic92/sops-nix) and 4 | [sops](https://github.com/mozilla/sops) 5 | 6 | ## Usage 7 | 8 | - Exports the private key: 9 | 10 | ```console 11 | $ ssh-to-age -private-key -i $HOME/.ssh/id_ed25519 -o key.txt 12 | $ cat key.txt 13 | AGE-SECRET-KEY-1K3VN4N03PTHJWSJSCCMQCN33RY5FSKQPJ4KRRTG3JMQUYE0TUSEQEDH6V8 14 | ``` 15 | 16 | If you private key is encrypted, you can export the password in `SSH_TO_AGE_PASSPHRASE` 17 | 18 | ``` console 19 | $ read -s SSH_TO_AGE_PASSPHRASE; export SSH_TO_AGE_PASSPHRASE 20 | $ ssh-to-age -private-key -i $HOME/.ssh/id_ed25519 -o key.txt 21 | ``` 22 | 23 | - Exports the public key: 24 | 25 | ```console 26 | $ ssh-to-age -i $HOME/.ssh/id_ed25519.pub -o pub-key.txt 27 | $ cat pub-key.txt 28 | age17044m9wgakla6pzftf4srtl3h5mcsr85jysgt5fg23zpnta8sfdqhzn452 29 | ``` 30 | 31 | ssh-to-age also supports multiple public keys at once seperated by newlines and ignores unless ssh keys that are not in the ed25519 format. This makes it suiteable in combination with `ssh-keyscan`: 32 | 33 | ```console 34 | $ ssh-keyscan eve.thalheim.io eva.thalheim.io | ssh-to-age 35 | # eve.thalheim.io:22 SSH-2.0-OpenSSH_8.6 36 | ... 37 | age1hjm3aujg9e79f5yth8a2cejzdjg5n9vnu96l05p70uvfpeltnpms7yy3pp 38 | age1v8zjc47jmlqwefyu66s0d4ke98qr4vnuj3cpvs4z9npfdw833dxqwjrhzv 39 | ``` 40 | 41 | ## Install with nix 42 | 43 | ```console 44 | $ nix-shell -p 'import (fetchTarball "https://github.com/Mic92/ssh-to-age/archive/main.tar.gz") {}' 45 | ``` 46 | 47 | ## Install with go 48 | 49 | ```console 50 | $ go install github.com/Mic92/ssh-to-age/cmd/ssh-to-age@latest 51 | ``` 52 | -------------------------------------------------------------------------------- /bech32/bech32.go: -------------------------------------------------------------------------------- 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<>frombits != 0 { 86 | return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) 87 | } 88 | acc = acc<= 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 | } 180 | -------------------------------------------------------------------------------- /bech32/bech32_test.go: -------------------------------------------------------------------------------- 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/Mic92/ssh-to-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 | } 95 | -------------------------------------------------------------------------------- /bin/create-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu -o pipefail 4 | 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 6 | cd $SCRIPT_DIR/.. 7 | 8 | version=${1:-} 9 | if [[ -z "$version" ]]; then 10 | echo "USAGE: $0 version" >&2 11 | exit 1 12 | fi 13 | 14 | if [[ "$(git symbolic-ref --short HEAD)" != "main" ]]; then 15 | echo "must be on main branch" >&2 16 | exit 1 17 | fi 18 | 19 | # ensure we are up-to-date 20 | uncommited_changes=$(git diff --compact-summary) 21 | if [[ -n "$uncommited_changes" ]]; then 22 | echo -e "There are uncommited changes, exiting:\n${uncommited_changes}" >&2 23 | exit 1 24 | fi 25 | git pull git@github.com:Mic92/ssh-to-age main 26 | unpushed_commits=$(git log --format=oneline origin/main..main) 27 | if [[ "$unpushed_commits" != "" ]]; then 28 | echo -e "\nThere are unpushed changes, exiting:\n$unpushed_commits" >&2 29 | exit 1 30 | fi 31 | sed -i -e "s!version = \".*\"!version = \"${version}\"!" default.nix 32 | git add default.nix 33 | nix-build --no-out-link default.nix 34 | git commit -m "bump version ${version}" 35 | git tag -e "${version}" 36 | 37 | echo 'now run `git push --tags origin main`' 38 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "strings" 11 | 12 | sshage "github.com/Mic92/ssh-to-age" 13 | ) 14 | 15 | type options struct { 16 | out, in string 17 | privateKey bool 18 | } 19 | 20 | func parseFlags(args []string) options { 21 | var opts options 22 | f := flag.NewFlagSet(args[0], flag.ExitOnError) 23 | f.BoolVar(&opts.privateKey, "private-key", false, "convert private key instead of public key") 24 | f.StringVar(&opts.in, "i", "-", "Input path. Reads by default from standard input") 25 | f.StringVar(&opts.out, "o", "-", "Output path. Prints by default to standard output") 26 | if err := f.Parse(args[1:]); err != nil { 27 | // should never happen since flag.ExitOnError 28 | panic(err) 29 | } 30 | 31 | return opts 32 | } 33 | 34 | func writeKey(writer io.Writer, key *string) error { 35 | if _, err := writer.Write([]byte(*key)); err != nil { 36 | return err 37 | } 38 | _, err := writer.Write([]byte("\n")) 39 | return err 40 | } 41 | 42 | func convertKeys(args []string) error { 43 | opts := parseFlags(args) 44 | 45 | var sshKey []byte 46 | var err error 47 | if opts.in == "-" { 48 | sshKey, err = ioutil.ReadAll(os.Stdin) 49 | if err != nil { 50 | return fmt.Errorf("error reading stdin: %w", err) 51 | } 52 | } else { 53 | sshKey, err = ioutil.ReadFile(opts.in) 54 | if err != nil { 55 | return fmt.Errorf("error reading %s: %w", opts.in, err) 56 | } 57 | } 58 | 59 | writer := io.WriteCloser(os.Stdout) 60 | if opts.out != "-" { 61 | writer, err = os.Create(opts.out) 62 | if err != nil { 63 | return fmt.Errorf("failed to create %s: %w", opts.out, err) 64 | } 65 | defer writer.Close() 66 | } 67 | if opts.privateKey { 68 | var ( 69 | key *string 70 | err error 71 | ) 72 | 73 | keyPassphrase := os.Getenv("SSH_TO_AGE_PASSPHRASE") 74 | 75 | key, _, err = sshage.SSHPrivateKeyToAge(sshKey, []byte(keyPassphrase)) 76 | if err != nil { 77 | return fmt.Errorf("failed to convert '%s': %w", sshKey, err) 78 | } 79 | if err := writeKey(writer, key); err != nil { 80 | return fmt.Errorf("failed to write key: %w", err) 81 | } 82 | } else { 83 | keys := strings.Split(string(sshKey), "\n") 84 | for _, k := range keys { 85 | // skip empty lines or comments 86 | if len(k) == 0 || strings.HasPrefix(k, "#") { 87 | continue 88 | } 89 | 90 | key, err := sshage.SSHPublicKeyToAge([]byte(k)) 91 | if err != nil { 92 | if errors.Is(err, sshage.UnsupportedKeyType) { 93 | fmt.Fprintf(os.Stderr, "skipped key: %s\n", err) 94 | continue 95 | } 96 | return fmt.Errorf("failed to convert '%s': %w", k, err) 97 | } 98 | if err := writeKey(writer, key); err != nil { 99 | return fmt.Errorf("failed to write key: %w", err) 100 | } 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func main() { 107 | if err := convertKeys(os.Args); err != nil { 108 | fmt.Fprintf(os.Stderr, "%s: %s\n", os.Args[0], err) 109 | os.Exit(1) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "testing" 13 | 14 | "filippo.io/age" 15 | ) 16 | 17 | // ok fails the test if an err is not nil. 18 | func ok(tb testing.TB, err error) { 19 | if err != nil { 20 | _, file, line, _ := runtime.Caller(1) 21 | fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) 22 | tb.FailNow() 23 | } 24 | } 25 | 26 | func Asset(name string) string { 27 | assets := os.Getenv("TEST_ASSETS") 28 | if assets == "" { 29 | assets = "test-assets" 30 | } 31 | return path.Join(assets, name) 32 | } 33 | 34 | func TempDir(t *testing.T) string { 35 | tempdir, err := ioutil.TempDir(os.TempDir(), "testdir") 36 | ok(t, err) 37 | return tempdir 38 | } 39 | 40 | func TestPublicKey(t *testing.T) { 41 | tempdir := TempDir(t) 42 | defer os.RemoveAll(tempdir) 43 | out := path.Join(tempdir, "out") 44 | 45 | err := convertKeys([]string{"ssh-to-age", "-i", Asset("id_ed25519.pub"), "-o", out}) 46 | ok(t, err) 47 | 48 | rawPublicKey, err := ioutil.ReadFile(out) 49 | ok(t, err) 50 | pubKey := strings.TrimSuffix(string(rawPublicKey), "\n") 51 | 52 | fmt.Printf("public key: %s\n", pubKey) 53 | _, err = age.ParseX25519Recipient(pubKey) 54 | ok(t, err) 55 | } 56 | 57 | func TestSshKeyScan(t *testing.T) { 58 | tempdir := TempDir(t) 59 | defer os.RemoveAll(tempdir) 60 | out := path.Join(tempdir, "out") 61 | 62 | err := convertKeys([]string{"ssh-to-age", "-i", Asset("keyscan.txt"), "-o", out}) 63 | ok(t, err) 64 | 65 | file, err := os.Open(out) 66 | ok(t, err) 67 | defer file.Close() 68 | 69 | scanner := bufio.NewScanner(file) 70 | for scanner.Scan() { 71 | pubKey := strings.TrimSuffix(scanner.Text(), "\n") 72 | fmt.Printf("scanned key: %s\n", pubKey) 73 | _, err = age.ParseX25519Recipient(pubKey) 74 | ok(t, err) 75 | } 76 | ok(t, scanner.Err()) 77 | } 78 | 79 | func TestPrivateKey(t *testing.T) { 80 | tempdir := TempDir(t) 81 | defer os.RemoveAll(tempdir) 82 | out := path.Join(tempdir, "out") 83 | 84 | err := convertKeys([]string{"ssh-to-age", "-private-key", "-i", Asset("id_ed25519"), "-o", out}) 85 | ok(t, err) 86 | 87 | rawPrivateKey, err := ioutil.ReadFile(out) 88 | privateKey := strings.TrimSuffix(string(rawPrivateKey), "\n") 89 | ok(t, err) 90 | 91 | fmt.Printf("private key: %s\n", privateKey) 92 | _, err = age.ParseX25519Identity(privateKey) 93 | ok(t, err) 94 | } 95 | 96 | func TestPrivateKeyWithPassphrase(t *testing.T) { 97 | tempdir := TempDir(t) 98 | defer os.RemoveAll(tempdir) 99 | out := path.Join(tempdir, "out") 100 | 101 | passphrase := "test" 102 | 103 | os.Setenv("SSH_TO_AGE_PASSPHRASE", passphrase) 104 | defer os.Unsetenv("SSH_TO_AGE_PASSPHRASE") 105 | 106 | err := convertKeys([]string{"ssh-to-age", "-private-key", "-i", Asset("id_ed25519_passphrase"), "-o", out}) 107 | ok(t, err) 108 | 109 | rawPrivateKey, err := ioutil.ReadFile(out) 110 | privateKey := strings.TrimSuffix(string(rawPrivateKey), "\n") 111 | ok(t, err) 112 | 113 | fmt.Printf("private key: %s\n", privateKey) 114 | _, err = age.ParseX25519Identity(privateKey) 115 | ok(t, err) 116 | } 117 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/test-assets/id_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDh8/lNGLYyDXOeHWYiTXJX5hBYnxe0VwhZTZU27mQY4gAAAJjpEFzo6RBc 4 | 6AAAAAtzc2gtZWQyNTUxOQAAACDh8/lNGLYyDXOeHWYiTXJX5hBYnxe0VwhZTZU27mQY4g 5 | AAAEDsBo0iLkC2U9CS5XgSo7zS/nQvjxaPfhfFCR+BshxoL+Hz+U0YtjINc54dZiJNclfm 6 | EFifF7RXCFlNlTbuZBjiAAAAE2pvZXJnQHR1cmluZ21hY2hpbmUBAg== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/test-assets/id_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOHz+U0YtjINc54dZiJNclfmEFifF7RXCFlNlTbuZBji joerg@turingmachine 2 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/test-assets/id_ed25519_passphrase: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABC83l/B2p 3 | MGlU+7xBT7wzeuAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAID/+AcTjBdIG6Xwk 4 | 4ZiuckvG5xNDlaqX316bKGo6D3a5AAAAkJA09klC9kTXa4VO1n4p3/J0ugw89MNS4eUn2b 5 | 4vbCPGrqZGZBU/Byu4A5g/Z03sGxGJj0GqnkC6I8aS2aTeQriNpdm10NaPVRL9dtL0//rp 6 | NT/WAPFTUavHyBT16tmKKabyKHHf83QdtpbjckXkk8q1Xf8tBKYooZJcieo+22mrmq1Hha 7 | JxU9TKx2Tc2RMymQ== 8 | -----END OPENSSH PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/test-assets/id_ed25519_passphrase.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID/+AcTjBdIG6Xwk4ZiuckvG5xNDlaqX316bKGo6D3a5 2 | -------------------------------------------------------------------------------- /cmd/ssh-to-age/test-assets/keyscan.txt: -------------------------------------------------------------------------------- 1 | # eve.thalheim.io SSH-2.0-OpenSSH_9.8 2 | eve.thalheim.io ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDHD6xarNtERpyo5HTTBuowEw3EH6zGG+oHEUDswDidcWl5YMeU1el50vV4hBVbdFwKv9E3ElYr1A7e/oBBHhRSgrSHA1QMSBgQmJNNPK6aaO42hz/X1ophINIegBrtIGcFRwzJyzvkUAahyP5fGpJbLrJ3KiLBHElaPForxw06ERSFauC2c2iNyhWnqDipbKuqEowBTW3164liD6PBTgv3K7RphpNXBHdMnVpqJVOq8ZlPb2Z06V4c0hfFUV5BxL6CVM4wM2sCH+DVkeynzP5BcddjfdBhWbiWSb+DQ1mz+l7tNi03uxVPF4hnzJuTrG3WVZqm7frI33fOlHsF4MRdNYRForp6B0w3ZkQZQ15jxtcRgvnVvN4cJLS10FxhXsRErueTFrP93tfejfRL5qHoDFKxPvOVOoK1kKPSXAR5NZyVaADOy6gkQNTPPVv0XT2gM8nd9iJkZbY7kw3GQRy1NTD7neqCdzQt5uBJEE5mEGTVO3i+MSTnRUnD6tjixKDfzpDWGqfs5vCqwPGHk3dm7OKSSYi3DSJ8c5zoeGfZZ22yhlFpoZmgNs+WPuZ3y11HcisuUQPrtVssxbv1xHedNfEmrbrJ41dRR9Z1tulo67YrfN77UBEakG5COK/7qwHiSKUwtylsnZVYQY04PNqx7VQqT5bTrmMg0kaorUcl5Q== 3 | # eve.thalheim.io SSH-2.0-OpenSSH_9.8 4 | eve.thalheim.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF/KPU3Z5LSjbBI6hrq25wCcseq2UpqSzUFZRr+ux6LM 5 | # eve.thalheim.io SSH-2.0-OpenSSH_9.8 6 | eva.thalheim.io ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDnKgLuYrFYqmUQUBjz+9bF/TUgHiL8Fq20y/gT96S7yGc/pxe936ikJIYIf5/X3auFqUOcVKWlrZ6X19PS+h/0UOd08+PG7Nd+5EFD5sgaFfIhxHs7oTzBq3v3S++uVT7wn/VfoERQpt181RIc95iBLA0ZOuuM1cF2GDI5vSCXi+cM7WVbxNHpoiwgtNDMgOGEcB//5cCh0PK39AC5PGzvZa6ynJNpEWqzkEjxJ8DWfpb99mBIKrurr4HktP55Zzw57SuGbugK4hA4j81WTOoDyhx0YT2SuVbgqRDXfRQj0vEUspDe0Y/MpqoBAzP4eexE5WD7xHlaDIglS5hBQ4KD0m2qBcri4GCBOa6+nfKdplMo/4iw7XNXAiXq1v3oMVsqli3hs6Mu0Z2rDgNB+TJCSM9daO5XXheb3kGyRT1O4k1X/C4Is4M+pgZ1Mtkjl2E3jExlEFKXwrTtWOE6VuT2q6nvWWNiU9p6Aio1oKr+UsncMHGH9dS8Ip9+Bip/0ORd/HCY3WPi2JN70hQNtESvMuZWVVFG+ZE5/+4LpydN90NJMLtLrFGKlpNqn0eRVZ8PTGYO4/OgCd/+90RUKhaq5p7C+tt20bajRm0GOkbJBvEiayOojMv9UVxbd3LzQBlSRsOcoy+dpUARwcKeKO/bkYaibEb4E1o/fAVdp7AJVQ== 7 | # eve.thalheim.io SSH-2.0-OpenSSH_9.8 8 | eva.thalheim.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIL2vjdqpppuCn7yPdF2muINriAG7zBWrsELKpPWkhn5 9 | -------------------------------------------------------------------------------- /convert.go: -------------------------------------------------------------------------------- 1 | package agessh 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ed25519" 6 | "crypto/sha512" 7 | "errors" 8 | "fmt" 9 | "reflect" 10 | "strings" 11 | 12 | "filippo.io/edwards25519" 13 | "github.com/Mic92/ssh-to-age/bech32" 14 | "golang.org/x/crypto/curve25519" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | var ( 19 | UnsupportedKeyType = errors.New("only ed25519 keys are supported") 20 | ) 21 | 22 | func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) ([]byte, error) { 23 | h := sha512.New() 24 | _, err := h.Write(pk.Seed()) 25 | if err != nil { 26 | return []byte{}, err 27 | } 28 | out := h.Sum(nil) 29 | return out[:curve25519.ScalarSize], nil 30 | } 31 | 32 | func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) { 33 | // See https://blog.filippo.io/using-ed25519-keys-for-encryption and 34 | // https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery. 35 | p, err := new(edwards25519.Point).SetBytes(pk) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return p.BytesMontgomery(), nil 40 | } 41 | 42 | func encodePublicKey(key crypto.PublicKey) (*string, error) { 43 | epk, ok := key.(ed25519.PublicKey) 44 | if !ok { 45 | return nil, fmt.Errorf("BUG! public key is not of type ed25519.PublicKey: %s", reflect.TypeOf(key)) 46 | } 47 | // Convert the key to curve ed25519 48 | mpk, err := ed25519PublicKeyToCurve25519(epk) 49 | if err != nil { 50 | return nil, fmt.Errorf("cannot convert ed25519 public key to curve25519: %w", err) 51 | } 52 | // Encode the key 53 | s, err := bech32.Encode("age", mpk) 54 | if err != nil { 55 | return nil, fmt.Errorf("cannot encode key as bech32: %w", err) 56 | } 57 | return &s, nil 58 | } 59 | 60 | func SSHPrivateKeyToAge(sshKey, passphrase []byte) (*string, *string, error) { 61 | var ( 62 | privateKey interface{} 63 | err error 64 | ) 65 | if len(passphrase) > 0 { 66 | privateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(sshKey, passphrase) 67 | } else { 68 | privateKey, err = ssh.ParseRawPrivateKey(sshKey) 69 | } 70 | if err != nil { 71 | return nil, nil, fmt.Errorf("failed to parse ssh private key: %w", err) 72 | } 73 | 74 | ed25519Key, ok := privateKey.(*ed25519.PrivateKey) 75 | if !ok { 76 | return nil, nil, fmt.Errorf("got %s key type but: %w", reflect.TypeOf(privateKey), UnsupportedKeyType) 77 | } 78 | 79 | pubKey, err := encodePublicKey(ed25519Key.Public()) 80 | if err != nil { 81 | return nil, nil, err 82 | } 83 | bytes, err := ed25519PrivateKeyToCurve25519(*ed25519Key) 84 | if err != nil { 85 | return nil, nil, err 86 | } 87 | 88 | s, err := bech32.Encode("AGE-SECRET-KEY-", bytes) 89 | if err != nil { 90 | return nil, nil, err 91 | } 92 | s = strings.ToUpper(s) 93 | return &s, pubKey, nil 94 | } 95 | 96 | func SSHPublicKeyToAge(sshKey []byte) (*string, error) { 97 | var err error 98 | var pk ssh.PublicKey 99 | if strings.HasPrefix(string(sshKey), "ssh-") { 100 | pk, _, _, _, err = ssh.ParseAuthorizedKey(sshKey) 101 | } else { 102 | _, _, pk, _, _, err = ssh.ParseKnownHosts(sshKey) 103 | } 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse ssh public key: %w", err) 106 | } 107 | // We only care about ed25519 108 | if pk.Type() != ssh.KeyAlgoED25519 { 109 | return nil, fmt.Errorf("got %s key type, but %w", pk.Type(), UnsupportedKeyType) 110 | } 111 | // Get the bytes 112 | cpk, ok := pk.(ssh.CryptoPublicKey) 113 | if !ok { 114 | return nil, errors.New("BUG! public key does not implement ssh.CryptoPublicKey") 115 | } 116 | return encodePublicKey(cpk.CryptoPublicKey()) 117 | } 118 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {}, vendorHash ? "sha256-O4YaBcHWZDjSF/9W7viZ/yDRJqG5VZKWvoct672uBZo=" }: 2 | pkgs.buildGoModule { 3 | pname = "ssh-to-age"; 4 | version = "1.1.11"; 5 | 6 | src = ./.; 7 | 8 | inherit vendorHash; 9 | 10 | # golangci-lint is marked as broken on macOS 11 | nativeBuildInputs = pkgs.lib.optional (!pkgs.stdenv.isDarwin) [ pkgs.golangci-lint ]; 12 | 13 | checkPhase = '' 14 | runHook preCheck 15 | go test ./... 16 | runHook postCheck 17 | ''; 18 | 19 | shellHook = '' 20 | unset GOFLAGS 21 | ''; 22 | 23 | doCheck = true; 24 | 25 | meta = with pkgs.lib; { 26 | description = "Convert ssh private keys in ed25519 format to age keys"; 27 | homepage = "https://github.com/Mic92/ssh-to-age"; 28 | license = licenses.mit; 29 | maintainers = with maintainers; [ mic92 ]; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1748821116, 11 | "narHash": "sha256-F82+gS044J1APL0n4hH50GYdPRv/5JWm34oCJYmVKdE=", 12 | "owner": "hercules-ci", 13 | "repo": "flake-parts", 14 | "rev": "49f0870db23e8c1ca0b5259734a02cd9e1e371a1", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "hercules-ci", 19 | "repo": "flake-parts", 20 | "type": "github" 21 | } 22 | }, 23 | "nixpkgs": { 24 | "locked": { 25 | "lastModified": 1748693115, 26 | "narHash": "sha256-StSrWhklmDuXT93yc3GrTlb0cKSS0agTAxMGjLKAsY8=", 27 | "owner": "NixOS", 28 | "repo": "nixpkgs", 29 | "rev": "910796cabe436259a29a72e8d3f5e180fc6dfacc", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "NixOS", 34 | "ref": "nixos-unstable", 35 | "repo": "nixpkgs", 36 | "type": "github" 37 | } 38 | }, 39 | "root": { 40 | "inputs": { 41 | "flake-parts": "flake-parts", 42 | "nixpkgs": "nixpkgs" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Convert SSH Ed25519 keys to age keys"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-parts.url = "github:/hercules-ci/flake-parts"; 7 | flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 8 | }; 9 | 10 | outputs = inputs@{ self, flake-parts, ... }: 11 | flake-parts.lib.mkFlake { inherit inputs; } ({ lib, ... }: { 12 | systems = [ 13 | "aarch64-linux" 14 | "x86_64-linux" 15 | "riscv64-linux" 16 | 17 | "x86_64-darwin" 18 | "aarch64-darwin" 19 | ]; 20 | perSystem = { config, self', inputs', pkgs, system, ... }: { 21 | packages = { 22 | ssh-to-age = (pkgs.callPackage ./default.nix { }); 23 | default = config.packages.ssh-to-age; 24 | }; 25 | checks = 26 | let 27 | packages = lib.mapAttrs' (n: lib.nameValuePair "package-${n}") self'.packages; 28 | devShells = lib.mapAttrs' (n: lib.nameValuePair "devShell-${n}") self'.devShells; 29 | in 30 | { 31 | cross-build = self'.packages.ssh-to-age.overrideAttrs (old: { 32 | nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.gox ]; 33 | buildPhase = '' 34 | runHook preBuild 35 | HOME=$TMPDIR gox -verbose -osarch '!darwin/386' ./cmd/ssh-to-age/ 36 | runHook postBuild 37 | ''; 38 | doCheck = false; 39 | installPhase = '' 40 | runHook preBuild 41 | touch $out 42 | runHook postBuild 43 | ''; 44 | }); 45 | } // packages // devShells; 46 | }; 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Mic92/ssh-to-age 2 | 3 | go 1.23.0 // tagx:compat 1.16 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | filippo.io/age v1.2.1 9 | filippo.io/edwards25519 v1.1.0 10 | golang.org/x/crypto v0.38.0 11 | ) 12 | 13 | require golang.org/x/sys v0.33.0 // indirect 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= 2 | c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= 3 | filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= 4 | filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= 5 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 6 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 7 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 8 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 9 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 10 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 11 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 12 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":dependencyDashboard"], 4 | "nix": { 5 | "enabled": true 6 | }, 7 | "lockFileMaintenance": { "enabled": true } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/update-vendor-hash.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-shell 2 | #!nix-shell -i bash -p nix -p coreutils -p gnused -p gawk 3 | 4 | set -exuo pipefail 5 | 6 | failedbuild=$(nix build --impure --expr '(with import {}; pkgs.callPackage ./. { vendorHash = ""; })' 2>&1 || true) 7 | echo "$failedbuild" 8 | checksum=$(echo "$failedbuild" | awk '/got:.*sha256/ { print $2 }') 9 | if [[ -z "$checksum" ]]; then 10 | echo "Failed to get checksum" 11 | exit 1 12 | fi 13 | sed -i -e "s|vendorHash ? \".*\"|vendorHash ? \"$checksum\"|" default.nix 14 | --------------------------------------------------------------------------------