├── cmd └── melt │ ├── testdata │ ├── not-a-key │ ├── id_ed25519 │ ├── pwd_id_ed25519 │ └── id_rsa │ ├── main_test.go │ └── main.go ├── .github ├── CODEOWNERS ├── workflows │ ├── build.yml │ ├── dependabot-sync.yml │ ├── nightly.yml │ ├── lint.yml │ ├── lint-soft.yml │ └── goreleaser.yml └── dependabot.yml ├── .gitignore ├── Dockerfile ├── .goreleaser.yml ├── .golangci.yml ├── .golangci-soft.yml ├── melt.go ├── melt_test.go ├── LICENSE.md ├── go.mod ├── go.sum └── README.md /cmd/melt/testdata/not-a-key: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @caarlos0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | completions/ 3 | manpages/ 4 | ./melt 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | COPY melt /usr/local/bin/melt 3 | ENTRYPOINT [ "/usr/local/bin/melt" ] 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | uses: charmbracelet/meta/.github/workflows/build.yml@main 8 | 9 | snapshot: 10 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 11 | secrets: 12 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} -------------------------------------------------------------------------------- /cmd/melt/testdata/id_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDJQSoksziSwoefzN4O7jzRv6vJpyOW9CnGXgkUjVajHAAAAIgvWUstL1lL 4 | LQAAAAtzc2gtZWQyNTUxOQAAACDJQSoksziSwoefzN4O7jzRv6vJpyOW9CnGXgkUjVajHA 5 | AAAEAHa+hzPanJO/Ef7G03pLLub2KNYh+szqeY/1e7ADjdy8lBKiSzOJLCh5/M3g7uPNG/ 6 | q8mnI5b0KcZeCRSNVqMcAAAAAAECAwQF 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | includes: 2 | - from_url: 3 | url: charmbracelet/meta/main/goreleaser-full.yaml 4 | 5 | variables: 6 | main: "./cmd/melt" 7 | description: "Backup and restore Ed25519 SSH keys with seed words" 8 | github_url: "https://github.com/charmbracelet/melt" 9 | maintainer: "Carlos A Becker " 10 | brew_commit_author_name: "Carlos A Becker" 11 | brew_commit_author_email: "carlos@charm.sh" 12 | 13 | 14 | -------------------------------------------------------------------------------- /cmd/melt/testdata/pwd_id_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBnlzGNfk 3 | mM3f0qJgdEHufbAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIC7fLAHYurOuCxI9 4 | f9cNQnoB9ErLerbYPebfpw+DapxBAAAAkDC4nF/SF4oHZFMOHh+Up17Y1iXcOyDyfoGD9g 5 | 0rZMTeuzm5ftpXuVmyUeDZjc17KEU5q9+zguI60XACuYO+JI10rhK/1ZaWIE9ucGhr5Gka 6 | 7dqOp7HUydHlvU2tiMqkpdlxVHA8jErHY0rWElN3awOpmkPA1AxSotZhDCmi6o9+EsPrTP 7 | xx8dnflg5zZE2KRA== 8 | -----END OPENSSH PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | include: 6 | - EXC0001 7 | - EXC0005 8 | - EXC0011 9 | - EXC0012 10 | - EXC0013 11 | 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | 15 | linters: 16 | enable: 17 | - bodyclose 18 | - goimports 19 | - gosec 20 | - nilerr 21 | - predeclared 22 | - revive 23 | - rowserrcheck 24 | - sqlclosecheck 25 | - tparallel 26 | - unconvert 27 | - unparam 28 | - whitespace 29 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 16 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 17 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 18 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 19 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 9 | pull-requests: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: ^1 20 | 21 | - uses: actions/checkout@v6 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v9 24 | with: 25 | # Optional: golangci-lint command line arguments. 26 | #args: 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | only-new-issues: true 29 | -------------------------------------------------------------------------------- /.golangci-soft.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | include: 6 | - EXC0001 7 | - EXC0005 8 | - EXC0011 9 | - EXC0012 10 | - EXC0013 11 | 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | 15 | linters: 16 | enable: 17 | - exhaustive 18 | - goconst 19 | - godot 20 | - godox 21 | - gomnd 22 | - gomoddirectives 23 | - goprintffuncname 24 | - misspell 25 | - nakedret 26 | - nestif 27 | - noctx 28 | - nolintlint 29 | - prealloc 30 | - wrapcheck 31 | 32 | # disable default linters, they are already enabled in .golangci.yml 33 | disable: 34 | - deadcode 35 | - errcheck 36 | - gosimple 37 | - govet 38 | - ineffassign 39 | - staticcheck 40 | - structcheck 41 | - typecheck 42 | - unused 43 | - varcheck 44 | -------------------------------------------------------------------------------- /.github/workflows/lint-soft.yml: -------------------------------------------------------------------------------- 1 | name: lint-soft 2 | on: 3 | push: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 9 | pull-requests: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint-soft 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: ^1 20 | 21 | - uses: actions/checkout@v6 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v9 24 | with: 25 | # Optional: golangci-lint command line arguments. 26 | args: --config .golangci-soft.yml --issues-exit-code=0 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | only-new-issues: true 29 | -------------------------------------------------------------------------------- /melt.go: -------------------------------------------------------------------------------- 1 | // Package melt provides function to create a mnemonic set of keys from a 2 | // ed25519 private key, and restore that key from the same mnemonic set of 3 | // words. 4 | package melt 5 | 6 | import ( 7 | "crypto/ed25519" 8 | "fmt" 9 | 10 | "github.com/tyler-smith/go-bip39" 11 | ) 12 | 13 | // ToMnemonic takes a ed25519 private key and returns the list of words. 14 | func ToMnemonic(key *ed25519.PrivateKey) (string, error) { 15 | return toMnemonic(key.Seed()) 16 | } 17 | 18 | func toMnemonic(seed []byte) (string, error) { 19 | words, err := bip39.NewMnemonic(seed) 20 | if err != nil { 21 | return "", fmt.Errorf("could not create a mnemonic set of words: %w", err) 22 | } 23 | 24 | return words, nil 25 | } 26 | 27 | // FromMnemonic takes a mnemonic list of words and returns an ed25519 28 | // private key. 29 | func FromMnemonic(mnemonic string) (ed25519.PrivateKey, error) { 30 | seed, err := bip39.EntropyFromMnemonic(mnemonic) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to get seed from mnemonic: %w", err) 33 | } 34 | return ed25519.NewKeyFromSeed(seed), nil 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: goreleaser 4 | 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | 10 | concurrency: 11 | group: goreleaser 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | goreleaser: 16 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 17 | secrets: 18 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 20 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 21 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 22 | aur_key: ${{ secrets.AUR_KEY }} 23 | fury_token: ${{ secrets.FURY_TOKEN }} 24 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 25 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 26 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 27 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 28 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 29 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 30 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 31 | -------------------------------------------------------------------------------- /melt_test.go: -------------------------------------------------------------------------------- 1 | package melt 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "crypto/rand" 6 | "testing" 7 | 8 | "github.com/matryer/is" 9 | ) 10 | 11 | func TestToMnemonic(t *testing.T) { 12 | t.Run("invalid", func(t *testing.T) { 13 | is := is.New(t) 14 | w, err := toMnemonic([]byte{}) 15 | is.Equal(w, "") 16 | is.True(err != nil) 17 | }) 18 | 19 | t.Run("valid", func(t *testing.T) { 20 | is := is.New(t) 21 | _, k, err := ed25519.GenerateKey(rand.Reader) 22 | is.NoErr(err) 23 | w, err := ToMnemonic(&k) 24 | is.NoErr(err) 25 | is.True(w != "") 26 | }) 27 | } 28 | 29 | func TestFromMnemonic(t *testing.T) { 30 | t.Run("invalid", func(t *testing.T) { 31 | is := is.New(t) 32 | key, err := FromMnemonic("nope nope nope") 33 | is.Equal(key, nil) 34 | is.True(err != nil) 35 | }) 36 | 37 | t.Run("valid", func(t *testing.T) { 38 | is := is.New(t) 39 | key, err := FromMnemonic(` 40 | alter gap broom kitten orient over settle work honey rule 41 | coach system wage effort mask void solid devote divert 42 | quarter quote broccoli jaguar lady 43 | `) 44 | is.NoErr(err) 45 | is.True(key != nil) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Charmbracelet, Inc 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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | groups: 17 | all: 18 | patterns: 19 | - "*" 20 | ignore: 21 | - dependency-name: github.com/charmbracelet/bubbletea/v2 22 | versions: 23 | - v2.0.0-beta1 24 | 25 | - package-ecosystem: "github-actions" 26 | directory: "/" 27 | schedule: 28 | interval: "weekly" 29 | day: "monday" 30 | time: "05:00" 31 | timezone: "America/New_York" 32 | labels: 33 | - "dependencies" 34 | commit-message: 35 | prefix: "chore" 36 | include: "scope" 37 | groups: 38 | all: 39 | patterns: 40 | - "*" 41 | 42 | - package-ecosystem: "docker" 43 | directory: "/" 44 | schedule: 45 | interval: "weekly" 46 | day: "monday" 47 | time: "05:00" 48 | timezone: "America/New_York" 49 | labels: 50 | - "dependencies" 51 | commit-message: 52 | prefix: "chore" 53 | include: "scope" 54 | groups: 55 | all: 56 | patterns: 57 | - "*" 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/melt 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/lipgloss v1.1.0 7 | github.com/matryer/is v1.4.1 8 | github.com/mattn/go-isatty v0.0.20 9 | github.com/mattn/go-tty v0.0.7 10 | github.com/muesli/mango-cobra v1.3.0 11 | github.com/muesli/reflow v0.3.0 12 | github.com/muesli/roff v0.1.0 13 | github.com/muesli/termenv v0.16.0 14 | github.com/spf13/cobra v1.10.2 15 | github.com/tyler-smith/go-bip39 v1.1.0 16 | golang.org/x/crypto v0.46.0 17 | golang.org/x/term v0.38.0 18 | golang.org/x/text v0.32.0 19 | ) 20 | 21 | require ( 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 24 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 26 | github.com/charmbracelet/x/term v0.2.1 // indirect 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 29 | github.com/mattn/go-runewidth v0.0.16 // indirect 30 | github.com/muesli/mango v0.2.0 // indirect 31 | github.com/muesli/mango-pflag v0.1.0 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/spf13/pflag v1.0.9 // indirect 34 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 35 | golang.org/x/sys v0.39.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /cmd/melt/testdata/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAYEAu5AX4ojAaJuOWXjmvW5PpnU7b5kHSgaEBpbXl9DAamlojlWrYSol 4 | ZcrtyJmehlqhTvVFuGbFamE9CkA+EJ1RMcpguik6aUReVualHWfNsvs4US9JUA8oF7OuYC 5 | k+DfM7nagUqJekv80VP9QxE4xAMR0DQH8dDhJEmfY23ePLaokJnxTQPS18691XeMQ6yv6z 6 | mifqXbvHtefpSA+Bk3S0+GOHwnkKnXtqUyBxLKiqe+Ft6Yj8tK/TIPvMWj8AhX14Wwdufr 7 | stljkyl7tNnmfQ3OkayPOR/dbqv5tBVYYzUM8Oikk5t6DeaJT7hNAEpP7e1s55U1TXym6D 8 | pUHlbRTltn2cbmtb/yEOtTkoypt/5yXEBzOgsvU88GA+Xi7z0TjpqsYC9Ma5m5hnto8UwL 9 | 82BBZttpzR026cB4GpsQlzF0Hm9X4/Dzs9he//vOubD21flIuv6m3O7zrPEbYbvPXFKCSR 10 | pWLEsMRvy8cuadBD0SrCAdCNC+2IeHA3Dq1WX0eBAAAFiFl9AYJZfQGCAAAAB3NzaC1yc2 11 | EAAAGBALuQF+KIwGibjll45r1uT6Z1O2+ZB0oGhAaW15fQwGppaI5Vq2EqJWXK7ciZnoZa 12 | oU71RbhmxWphPQpAPhCdUTHKYLopOmlEXlbmpR1nzbL7OFEvSVAPKBezrmApPg3zO52oFK 13 | iXpL/NFT/UMROMQDEdA0B/HQ4SRJn2Nt3jy2qJCZ8U0D0tfOvdV3jEOsr+s5on6l27x7Xn 14 | 6UgPgZN0tPhjh8J5Cp17alMgcSyoqnvhbemI/LSv0yD7zFo/AIV9eFsHbn67LZY5Mpe7TZ 15 | 5n0NzpGsjzkf3W6r+bQVWGM1DPDopJObeg3miU+4TQBKT+3tbOeVNU18pug6VB5W0U5bZ9 16 | nG5rW/8hDrU5KMqbf+clxAczoLL1PPBgPl4u89E46arGAvTGuZuYZ7aPFMC/NgQWbbac0d 17 | NunAeBqbEJcxdB5vV+Pw87PYXv/7zrmw9tX5SLr+ptzu86zxG2G7z1xSgkkaVixLDEb8vH 18 | LmnQQ9EqwgHQjQvtiHhwNw6tVl9HgQAAAAMBAAEAAAGAUEElly7AdYIp9KrAwElVF3qOBg 19 | BKmCgVkeQ1N6aAzodvz4dkn1yzR8z+1Zi1tfNNlkVoobCHxC77OUmnxOArf8yCeuVtMuGo 20 | JDLob56c63qG7GX5TqJNm2astESxYrKyzZC/1iucuNz9vKQEo8KdLOanH4/EVOMIK4ColW 21 | UFAv7D+SekuqieDSZWaTw9k+JL4yg1JgpVN8aVkNnhrcCANtAPpOy52AyYwBzfkCZTXFJm 22 | /0g4KPAavMBA/lR62qOGgpJL1vYocXEz2VW7nstOMwWDmtXx8rUB+wgSw9lCTCDpN56+GV 23 | qheVyR9df60rFXD4ZkbNwCjfnMuq6lv0/+1Rogo29zcC2SfOsELeT8xuWQl/9XSpXAvI50 24 | DIC80v4UOFvQUxLvARXrn2nG3BCYFzJetYEoM46tYaidZrBYRCPm/skVFrJice+WWuU1F3 25 | K+3mfhZpm4rdrbC50Ya0u12RnsmbpcRfjUupCvlYN9U7fQbhP+mrTUJOHubtkct46pAAAA 26 | wAPIuQgQZlojhsvXCZBq7afZUxT63GB6viXQS3Buv613cL6Rv3Qlf75bqvsRWyvkiSRuvg 27 | RJRundKO/LqheMYwILvSH4dhYY5ij+swpkJc/8cGAZPOs7nnpsqxWXQb/LysSi89S5qven 28 | fTspeKS7WGt/NjK3B5oiJKyPr54tLobbXynTd8Nu5ev+Tr6oUSfBhYmg8LNrOMwh1W1RKy 29 | E1Ye3kWYkn6yFcGdynxT2iH7/YKKUzAbHeo2ebGISP9dLXrgAAAMEA3jyasTt0kdvCB2up 30 | CeLRsK1GhxR6C8ti4AyPjDAeDHH+a5pJ8eSNebDHkGqZv4nV0eX0zRnfakeop7VvfHu/db 31 | uK6amh5ovymf1lrN/oLoUUN/K08003dj3rminCFhqiL8KtAUxAGC43uYUOnNRTOzE5LLUW 32 | CjiFgV2L6KAn3tJLEg+DLHsOeaxiw+XgQK0g1njIC7jB+fDm+Qn8Zy7pLlI0WqGwXPJEGg 33 | 7D9IZI1a+b5/FkusKwoV3ozZZ7k8JnAAAAwQDYDu1aV/rS3rJl+U8Np7JgElvXJ9iHQvma 34 | obi1A7fq9bC3Gq560KX7A7kXMnPWNcEOIjuSJik8KVBZvZuyy9ChMJak31DSaA93lq26Mi 35 | yjHcLNX6NM3/1Gudyk0/B/E5+gsqoKdEQgNXgdaqmufCXQHxXBnNKC1e5aRC/BYljRh88i 36 | HNizC+itaWxrYNbBUHb3tWNrbqKEF2CrioIunItW9aRpT1R78VTMmhsNBi33gjH4Txx/Xb 37 | ioVFowJlLNBdcAAAAPY2FybG9zQGRhcmtzdGFyAQIDBA== 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 4 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 8 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 9 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 10 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 11 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 12 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 17 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 18 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 19 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 20 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 21 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 22 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 24 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= 26 | github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= 27 | github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= 28 | github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 29 | github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= 30 | github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= 31 | github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 32 | github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 33 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 34 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 35 | github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 36 | github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 37 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 38 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 39 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 41 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 42 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 43 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 44 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 45 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 46 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 47 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 48 | github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= 49 | github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= 50 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 52 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 53 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 54 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 55 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 56 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 57 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 58 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 59 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 60 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 61 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 62 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 64 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 65 | golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 66 | golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 67 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 68 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 69 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Melt 2 | 3 |

4 | Melt Mascot
5 | Latest Release 6 | Build Status 7 |

8 | 9 | Backup and restore SSH private keys using memorizable seed phrases. 10 | 11 | Melt example 12 | 13 | ## Installation 14 | 15 | ### Package Manager 16 | 17 | ```bash 18 | # macOS or Linux 19 | brew install charmbracelet/tap/melt 20 | 21 | # Arch Linux (btw) 22 | yay -S melt-bin 23 | 24 | # Windows (with Scoop) 25 | scoop bucket add https://github.com/charmbracelet/scoop-bucket.git 26 | scoop install melt 27 | 28 | # Nix 29 | nix-env -iA nixpkgs.melt 30 | 31 | # Debian/Ubuntu 32 | sudo mkdir -p /etc/apt/keyrings 33 | curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg 34 | echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list 35 | sudo apt update && sudo apt install melt 36 | 37 | # Fedora/RHEL 38 | echo '[charm] 39 | name=Charm 40 | baseurl=https://repo.charm.sh/yum/ 41 | enabled=1 42 | gpgcheck=1 43 | gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo 44 | sudo yum install melt 45 | ``` 46 | 47 | You can download a binary or package from the [releases][releases] page. 48 | 49 | ### Go 50 | 51 | Or just install it with `go`: 52 | ```bash 53 | go install github.com/charmbracelet/melt/cmd/melt@latest 54 | ``` 55 | 56 | ## Build (requires Go 1.17+) 57 | 58 | ```bash 59 | git clone https://github.com/charmbracelet/melt.git 60 | cd melt 61 | go build ./cmd/melt/ 62 | ``` 63 | 64 | [releases]: https://github.com/charmbracelet/melt/releases 65 | 66 | ## Usage 67 | 68 | The CLI usage looks like the following: 69 | 70 | ```shell 71 | # Generate a seed phrase from an SSH key 72 | melt ~/.ssh/id_ed25519 73 | 74 | # Generate a seed phrase from a SSH key from standard input 75 | cat ~/.ssh/id_ed25519 | melt 76 | 77 | # Rebuild the key from the seed phrase 78 | melt restore ./my-key --seed "seed phrase" 79 | 80 | # Rebuild the key and print it to standard output 81 | cat words | melt restore - 82 | ``` 83 | 84 | You can also pipe to and from a file: 85 | 86 | ```shell 87 | melt ~/.ssh/id_ed25519 > words 88 | melt restore ./recovered_id_ed25519 < words 89 | ``` 90 | 91 | ## How it Works 92 | 93 | It all comes down to the private key __seed__: 94 | 95 | > Ed25519 keys start life as a 32-byte (256-bit) uniformly random binary seed (e.g. the output of SHA256 on some random input). The seed is then hashed using SHA512, which gets you 64 bytes (512 bits), which is then split into a “left half” (the first 32 bytes) and a “right half”. The left half is massaged into a curve25519 private scalar “a” by setting and clearing a few high/low-order bits. The pubkey is generated by multiplying this secret scalar by “B” (the generator), which yields a 32-byte/256-bit group element “A”.[^1] 96 | 97 | Knowing that, we open the key and extract its seed, and use it as __entropy__ for the [bip39][] algorithm, which states: 98 | 99 | > The mnemonic must encode entropy in a multiple of 32 bits. With more entropy security is improved but the sentence length increases. We refer to the initial entropy length as ENT. The allowed size of ENT is 128-256 bits.[^2] 100 | 101 | Doing that, we get the __mnemonic__ set of words back. 102 | 103 | To restore, we: 104 | 105 | - get the __entropy__ from the __mnemonic__ 106 | - the __entropy__ is effectively the key __seed__, so we use it to create a SSH key pair 107 | - the key is effectively the same that was backed up, as the key is the same. 108 | You can verify the keys by checking the public key fingerprint, which should be 109 | the same in the original and _restored_ key. 110 | 111 | [^1]: Warner, Brian. [How do Ed5519 keys work?](https://blog.mozilla.org/warner/2011/11/29/ed25519-keys/) (2011) 112 | [^2]: Palatinus, Marek et al. [Mnemonic code for generating deterministic keys](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) (2013) 113 | 114 | [bip39]: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki 115 | 116 | ## Caveats 117 | 118 | - At this time, only `ed25519` keys are supported. 119 | - If your public key has a memo (usually the user@host in which it was 120 | generated), it'll be lost. 121 | That info (or any other) can be added to the public key manually later, 122 | as it's effectively not used for signing/verifying. 123 | - Some bytes of your private key might change, due to their random block. 124 | The key is effectively the same though. 125 | 126 | ### Restoring the memo 127 | 128 | We can use `ssh-keygen` to add a memo to our restored keys. 129 | 130 | If you ran the following command to restore a key: 131 | 132 | ```shell 133 | melt restore ./my-key --seed "witness shoe deputy celery debate myth \ 134 | title sign dish bone powder velvet reveal midnight blast mobile \ 135 | valid cycle announce valid item interest cinnamon cake" 136 | Restoring key to ./my-key and ./my-key.pub... 137 | Enter new passphrase (empty for no passphrase): 138 | Enter same passphrase again: 139 | 140 | Successfully restored keys to ./my-key and ./my-key.pub 141 | ``` 142 | 143 | You can verify that the memo is not there: 144 | 145 | ```shell 146 | cat my-key.pub 147 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKThPEoe20Wi5zAfyI+gTrTMnODbRtYtQRUZYIvfV19C 148 | ``` 149 | 150 | Run this command on the restored key: 151 | 152 | ```shell 153 | ssh-keygen -c -C melted-again@charm.sh -f ./my-key 154 | Old comment: 155 | Comment 'melted-again@charm.sh' applied 156 | ``` 157 | 158 | And check to see your memo is set: 159 | 160 | ```shell 161 | cat my-key.pub 162 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKThPEoe20Wi5zAfyI+gTrTMnODbRtYtQRUZYIvfV19C melted-again@charm.sh 163 | ``` 164 | 165 | ## Contributing 166 | 167 | See [contributing][contribute]. 168 | 169 | [contribute]: https://github.com/charmbracelet/melt/contribute 170 | 171 | ## Feedback 172 | 173 | We’d love to hear your thoughts on this project. Feel free to drop us a note! 174 | 175 | * [Twitter](https://twitter.com/charmcli) 176 | * [The Fediverse](https://mastodon.social/@charmcli) 177 | * [Discord](https://charm.sh/chat) 178 | 179 | ## License 180 | 181 | [MIT](https://github.com/charmbracelet/melt/raw/main/LICENSE) 182 | 183 | *** 184 | 185 | Part of [Charm](https://charm.sh). 186 | 187 | The Charm logo 188 | 189 | Charm热爱开源 • Charm loves open source 190 | -------------------------------------------------------------------------------- /cmd/melt/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/matryer/is" 14 | "github.com/tyler-smith/go-bip39/wordlists" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | func TestBackupRestoreKnownKey(t *testing.T) { 19 | const expectedMnemonic = ` 20 | alter gap broom kitten orient over settle work honey rule 21 | coach system wage effort mask void solid devote divert 22 | quarter quote broccoli jaguar lady 23 | ` 24 | const expectedSum = "ba34175ef608633b29f046b40cce596dd221347b77abba40763eef2e7ae51fe9" 25 | const expectedFingerprint = "SHA256:tX0ZrsNLIB/ZlRK3vy/HsWIIkyBNhYhCSGmtqtxJcWo" 26 | 27 | t.Run("backup", func(t *testing.T) { 28 | mnemonic, err := backup("testdata/id_ed25519", nil) 29 | is := is.New(t) 30 | is.NoErr(err) 31 | is.Equal(mnemonic, strings.Join(strings.Fields(expectedMnemonic), " ")) 32 | }) 33 | 34 | t.Run("backup file that does not exist", func(t *testing.T) { 35 | _, err := backup("nope", nil) 36 | is.New(t).True(err != nil) 37 | }) 38 | 39 | t.Run("backup invalid ssh key", func(t *testing.T) { 40 | _, err := backup("testdata/not-a-key", nil) 41 | is.New(t).True(err != nil) 42 | }) 43 | 44 | t.Run("backup key of another type", func(t *testing.T) { 45 | _, err := backup("testdata/id_rsa", nil) 46 | is.New(t).True(err != nil) 47 | }) 48 | 49 | t.Run("backup key without password", func(t *testing.T) { 50 | if runtime.GOOS == "windows" { 51 | t.Skipf("it keeps waiting on a tty for the password") 52 | } 53 | _, err := backup("testdata/pwd_id_ed25519", nil) 54 | is := is.New(t) 55 | is.True(err != nil) 56 | }) 57 | 58 | t.Run("backup key with password", func(t *testing.T) { 59 | const expectedMnemonic = `assume knee laundry logic soft fit quantum 60 | puppy vault snow author alien famous comfort neglect habit 61 | emerge fabric trophy wine hold inquiry clown govern` 62 | 63 | mnemonic, err := backup("testdata/pwd_id_ed25519", []byte("asd")) 64 | is := is.New(t) 65 | is.NoErr(err) 66 | is.Equal(mnemonic, strings.Join(strings.Fields(expectedMnemonic), " ")) 67 | }) 68 | 69 | t.Run("restore", func(t *testing.T) { 70 | is := is.New(t) 71 | path := filepath.Join(t.TempDir(), "key") 72 | is.NoErr(restore(expectedMnemonic, staticPass(nil), restoreToFiles(path))) 73 | is.Equal(expectedSum, sha256sum(t, path+".pub")) 74 | 75 | bts, err := os.ReadFile(path) 76 | is.NoErr(err) 77 | 78 | k, err := ssh.ParsePrivateKey(bts) 79 | is.NoErr(err) 80 | 81 | is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey())) 82 | }) 83 | 84 | t.Run("restore to writer", func(t *testing.T) { 85 | is := is.New(t) 86 | 87 | var b bytes.Buffer 88 | is.NoErr(restore(expectedMnemonic, staticPass(nil), restoreToWriter(&b))) 89 | 90 | k, err := ssh.ParsePrivateKey(b.Bytes()) 91 | is.NoErr(err) 92 | 93 | is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey())) 94 | }) 95 | 96 | t.Run("restore key with password", func(t *testing.T) { 97 | path := filepath.Join(t.TempDir(), "key") 98 | is := is.New(t) 99 | pass := staticPass([]byte("asd")) 100 | is.NoErr(restore(expectedMnemonic, pass, restoreToFiles(path))) 101 | 102 | bts, err := os.ReadFile(path) 103 | is.NoErr(err) 104 | 105 | k, err := ssh.ParsePrivateKeyWithPassphrase(bts, []byte("asd")) 106 | is.NoErr(err) 107 | 108 | is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey())) 109 | }) 110 | } 111 | 112 | func TestGetWordlist(t *testing.T) { 113 | for lang, wordlist := range map[string][]string{ 114 | "cHinese": wordlists.ChineseSimplified, 115 | "simplified-cHinese": wordlists.ChineseSimplified, 116 | "zH": wordlists.ChineseSimplified, 117 | "zH_haNs": wordlists.ChineseSimplified, 118 | "tradITIONAL-cHinese": wordlists.ChineseTraditional, 119 | "zH_hanT": wordlists.ChineseTraditional, 120 | "cZech": wordlists.Czech, 121 | "cS": wordlists.Czech, 122 | "eN": wordlists.English, 123 | "eN-gb": wordlists.English, 124 | "eNglish": wordlists.English, 125 | "american-eNglish": wordlists.English, 126 | "british-eNglish": wordlists.English, 127 | "fRench": wordlists.French, 128 | "fR": wordlists.French, 129 | "iTaliaN": wordlists.Italian, 130 | "iT": wordlists.Italian, 131 | "jApanesE": wordlists.Japanese, 132 | "jA": wordlists.Japanese, 133 | "kORean": wordlists.Korean, 134 | "kO": wordlists.Korean, 135 | "sPanish": wordlists.Spanish, 136 | "eS": wordlists.Spanish, 137 | "eS-ER": wordlists.Spanish, 138 | "european-spanish": wordlists.Spanish, 139 | "ES": wordlists.Spanish, 140 | "zz": nil, 141 | "sOmething": nil, 142 | } { 143 | t.Run(lang, func(t *testing.T) { 144 | is := is.New(t) 145 | is.Equal(wordlist, getWordlist(lang)) 146 | }) 147 | } 148 | } 149 | 150 | func TestBackupRestoreKnownKeyInJapanse(t *testing.T) { 151 | const expectedMnemonic = ` 152 | いきおい ざるそば えもの せんめんじょ てあみ ていねい はったつ 153 | ろこつ すあし のぞく かまう ほくろ らくご けぶかい たおす よゆう 154 | ひめじし くたびれる ぐんたい なわばり にかい えほん せなか 155 | そいとげる 156 | ` 157 | const expectedSum = "ba34175ef608633b29f046b40cce596dd221347b77abba40763eef2e7ae51fe9" 158 | const expectedFingerprint = "SHA256:tX0ZrsNLIB/ZlRK3vy/HsWIIkyBNhYhCSGmtqtxJcWo" 159 | 160 | // set language to Japanse 161 | setLanguage("ja") 162 | 163 | // set language back to English 164 | t.Cleanup(func() { 165 | setLanguage("en") 166 | }) 167 | 168 | t.Run("backup", func(t *testing.T) { 169 | mnemonic, err := backup("testdata/id_ed25519", nil) 170 | is := is.New(t) 171 | is.NoErr(err) 172 | is.Equal(mnemonic, strings.Join(strings.Fields(expectedMnemonic), " ")) 173 | }) 174 | 175 | t.Run("restore", func(t *testing.T) { 176 | is := is.New(t) 177 | path := filepath.Join(t.TempDir(), "key") 178 | is.NoErr(restore(expectedMnemonic, staticPass(nil), restoreToFiles(path))) 179 | is.Equal(expectedSum, sha256sum(t, path+".pub")) 180 | 181 | bts, err := os.ReadFile(path) 182 | is.NoErr(err) 183 | 184 | k, err := ssh.ParsePrivateKey(bts) 185 | is.NoErr(err) 186 | 187 | is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey())) 188 | }) 189 | } 190 | 191 | func TestMaybeFile(t *testing.T) { 192 | t.Run("is a file", func(t *testing.T) { 193 | is := is.New(t) 194 | path := filepath.Join(t.TempDir(), "f") 195 | content := "test content" 196 | is.NoErr(os.WriteFile(path, []byte(content), 0o644)) //nolint: gomnd 197 | is.Equal(content, maybeFile(path)) 198 | }) 199 | 200 | t.Run("not a file", func(t *testing.T) { 201 | is := is.New(t) 202 | is.Equal("strings", maybeFile("strings")) 203 | }) 204 | 205 | t.Run("stdin", func(t *testing.T) { 206 | is := is.New(t) 207 | is.Equal("", maybeFile("-")) 208 | }) 209 | } 210 | 211 | func sha256sum(tb testing.TB, path string) string { 212 | tb.Helper() 213 | is := is.New(tb) 214 | 215 | bts, err := os.ReadFile(path) 216 | is.NoErr(err) 217 | 218 | digest := sha256.New() 219 | _, err = digest.Write(bts) 220 | is.NoErr(err) 221 | 222 | return hex.EncodeToString(digest.Sum(nil)) 223 | } 224 | 225 | func staticPass(b []byte) func() ([]byte, error) { 226 | return func() ([]byte, error) { 227 | return b, nil 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /cmd/melt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ed25519" 6 | "encoding/pem" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/charmbracelet/melt" 15 | "github.com/mattn/go-isatty" 16 | "github.com/mattn/go-tty" 17 | mcobra "github.com/muesli/mango-cobra" 18 | "github.com/muesli/reflow/wordwrap" 19 | "github.com/muesli/roff" 20 | "github.com/muesli/termenv" 21 | "github.com/spf13/cobra" 22 | "github.com/tyler-smith/go-bip39" 23 | "github.com/tyler-smith/go-bip39/wordlists" 24 | "golang.org/x/crypto/ssh" 25 | "golang.org/x/term" 26 | lang "golang.org/x/text/language" 27 | "golang.org/x/text/language/display" 28 | ) 29 | 30 | const ( 31 | maxWidth = 72 32 | ) 33 | 34 | var ( 35 | baseStyle = lipgloss.NewStyle().Margin(0, 0, 1, 2) //nolint: gomnd 36 | violet = lipgloss.Color(completeColor("#6B50FF", "63", "12")) 37 | cmdStyle = lipgloss.NewStyle(). 38 | Foreground(lipgloss.AdaptiveColor{Light: "#FF5E8E", Dark: "#FF5E8E"}). 39 | Background(lipgloss.AdaptiveColor{Light: completeColor("#ECECEC", "255", "7"), Dark: "#1F1F1F"}). 40 | Padding(0, 1) 41 | mnemonicStyle = baseStyle. 42 | Foreground(violet). 43 | Background(lipgloss.AdaptiveColor{Light: completeColor("#EEEBFF", "255", "7"), Dark: completeColor("#1B1731", "235", "8")}). 44 | Padding(1, 2) //nolint: gomnd 45 | keyPathStyle = lipgloss.NewStyle().Foreground(violet) 46 | 47 | mnemonic string 48 | language string 49 | 50 | rootCmd = &cobra.Command{ 51 | Use: "melt", 52 | Example: ` melt ~/.ssh/id_ed25519 53 | melt ~/.ssh/id_ed25519 > seed 54 | melt restore --seed "seed phrase" ./restored_id25519 55 | melt restore ./restored_id25519 < seed`, 56 | Short: "Generate a seed phrase from an SSH key", 57 | Long: `melt generates a seed phrase from an SSH key. That phrase can 58 | be used to rebuild your public and private keys.`, 59 | Args: cobra.MaximumNArgs(1), 60 | SilenceUsage: true, 61 | RunE: func(_ *cobra.Command, args []string) error { 62 | if err := setLanguage(language); err != nil { 63 | return err 64 | } 65 | 66 | var keyPath string 67 | if len(args) > 0 { 68 | keyPath = args[0] 69 | } 70 | 71 | mnemonic, err := backup(keyPath, nil) 72 | if err != nil { 73 | return err 74 | } 75 | if isatty.IsTerminal(os.Stdout.Fd()) { 76 | b := strings.Builder{} 77 | w := getWidth(maxWidth) 78 | 79 | b.WriteRune('\n') 80 | meltCmd := cmdStyle.Render(os.Args[0]) 81 | renderBlock(&b, baseStyle, w, fmt.Sprintf("OK! Your key has been melted down to the seed phrase below. Store it somewhere safe. You can use %s to recover your key at any time.", meltCmd)) 82 | renderBlock(&b, mnemonicStyle, w, mnemonic) 83 | renderBlock(&b, baseStyle, w, "To recreate this key run:") 84 | 85 | // Build formatted restore command 86 | const cmdEOL = " \\" 87 | var lang string 88 | if language != "en" { 89 | lang = fmt.Sprintf(" --language %s", language) 90 | } 91 | cmd := wordwrap.String( 92 | os.Args[0]+` restore`+lang+` ./my-key --seed "`+mnemonic+`"`, 93 | w-lipgloss.Width(cmdEOL)-baseStyle.GetHorizontalFrameSize()*2, 94 | ) 95 | leftPad := strings.Repeat(" ", baseStyle.GetMarginLeft()) 96 | cmdLines := strings.Split(cmd, "\n") 97 | for i, l := range cmdLines { 98 | b.WriteString(leftPad) 99 | b.WriteString(l) 100 | if i < len(cmdLines)-1 { 101 | b.WriteString(cmdEOL) 102 | b.WriteRune('\n') 103 | } 104 | } 105 | b.WriteRune('\n') 106 | 107 | fmt.Println(b.String()) 108 | } else { 109 | fmt.Print(mnemonic) 110 | } 111 | return nil 112 | }, 113 | } 114 | 115 | restoreCmd = &cobra.Command{ 116 | Use: "restore", 117 | Short: "Recreate a key using the given seed phrase", 118 | Example: ` melt restore --seed "seed phrase" ./restored_id25519 119 | melt restore ./restored_id25519 < seed`, 120 | Aliases: []string{"res", "r"}, 121 | Args: cobra.ExactArgs(1), 122 | RunE: func(cmd *cobra.Command, args []string) error { 123 | if err := setLanguage(language); err != nil { 124 | return err 125 | } 126 | 127 | switch args[0] { 128 | case "-": 129 | _, _ = fmt.Fprint(os.Stderr, "Restoring key to STDOUT...\n") 130 | return restore(maybeFile(mnemonic), askNewPassphrase, restoreToWriter(cmd.OutOrStdout())) 131 | default: 132 | name := args[0] 133 | _, _ = fmt.Fprintf(os.Stderr, "Restoring key to %s and %[1]s.pub...\n", name) 134 | if err := restore(maybeFile(mnemonic), askNewPassphrase, restoreToFiles(name)); err != nil { 135 | return err 136 | } 137 | 138 | pub := keyPathStyle.Render(name) 139 | priv := keyPathStyle.Render(name + ".pub") 140 | fmt.Println(baseStyle.Render(fmt.Sprintf("\nSuccessfully restored keys to %s and %s", pub, priv))) 141 | } 142 | return nil 143 | }, 144 | } 145 | 146 | manCmd = &cobra.Command{ 147 | Use: "man", 148 | Args: cobra.NoArgs, 149 | Short: "generate man pages", 150 | Hidden: true, 151 | SilenceUsage: true, 152 | RunE: func(*cobra.Command, []string) error { 153 | manPage, err := mcobra.NewManPage(1, rootCmd) 154 | if err != nil { 155 | //nolint: wrapcheck 156 | return err 157 | } 158 | manPage = manPage.WithSection("Copyright", "(C) 2022 Charmbracelet, Inc.\n"+ 159 | "Released under MIT license.") 160 | fmt.Println(manPage.Build(roff.NewDocument())) 161 | return nil 162 | }, 163 | } 164 | ) 165 | 166 | func init() { 167 | rootCmd.PersistentFlags().StringVarP(&language, "language", "l", "en", "Language") 168 | rootCmd.AddCommand(restoreCmd, manCmd) 169 | 170 | restoreCmd.PersistentFlags().StringVarP(&mnemonic, "seed", "s", "-", "Seed phrase") 171 | _ = restoreCmd.MarkFlagRequired("seed") 172 | } 173 | 174 | func main() { 175 | if err := rootCmd.Execute(); err != nil { 176 | os.Exit(1) 177 | } 178 | } 179 | 180 | func maybeFile(s string) string { 181 | f, err := openFileOrStdin(s) 182 | if err != nil { 183 | return s 184 | } 185 | defer f.Close() //nolint:errcheck 186 | bts, err := io.ReadAll(f) 187 | if err != nil { 188 | return s 189 | } 190 | return string(bts) 191 | } 192 | 193 | func openFileOrStdin(path string) (*os.File, error) { 194 | if path == "-" { 195 | return os.Stdin, nil 196 | } 197 | 198 | if fi, _ := os.Stdin.Stat(); (fi.Mode() & os.ModeNamedPipe) != 0 { 199 | return os.Stdin, nil 200 | } 201 | 202 | f, err := os.Open(path) 203 | if err != nil { 204 | return nil, fmt.Errorf("could not open %s: %w", path, err) 205 | } 206 | return f, nil 207 | } 208 | 209 | func parsePrivateKey(bts, pass []byte) (interface{}, error) { 210 | if len(pass) == 0 { 211 | //nolint: wrapcheck 212 | return ssh.ParseRawPrivateKey(bts) 213 | } 214 | //nolint: wrapcheck 215 | return ssh.ParseRawPrivateKeyWithPassphrase(bts, pass) 216 | } 217 | 218 | func backup(path string, pass []byte) (string, error) { 219 | f, err := openFileOrStdin(path) 220 | if err != nil { 221 | return "", fmt.Errorf("could not read key: %w", err) 222 | } 223 | defer f.Close() //nolint:errcheck 224 | bts, err := io.ReadAll(f) 225 | if err != nil { 226 | return "", fmt.Errorf("could not read key: %w", err) 227 | } 228 | 229 | key, err := parsePrivateKey(bts, pass) 230 | if err != nil && isPasswordError(err) { 231 | pass, err := askKeyPassphrase(path) 232 | if err != nil { 233 | return "", err 234 | } 235 | return backup(path, pass) 236 | } 237 | if err != nil { 238 | return "", fmt.Errorf("could not parse key: %w", err) 239 | } 240 | 241 | switch key := key.(type) { 242 | case *ed25519.PrivateKey: 243 | //nolint: wrapcheck 244 | return melt.ToMnemonic(key) 245 | default: 246 | return "", fmt.Errorf("unknown key type: %v", key) 247 | } 248 | } 249 | 250 | func isPasswordError(err error) bool { 251 | var kerr *ssh.PassphraseMissingError 252 | return errors.As(err, &kerr) 253 | } 254 | 255 | func marshallPrivateKey(key ed25519.PrivateKey, pass []byte) (*pem.Block, error) { 256 | if len(pass) == 0 { 257 | //nolint: wrapcheck 258 | return ssh.MarshalPrivateKey(key, "") 259 | } 260 | //nolint: wrapcheck 261 | return ssh.MarshalPrivateKeyWithPassphrase(key, "", pass) 262 | } 263 | 264 | func restore(mnemonic string, passFn func() ([]byte, error), outFn func(pem, pub []byte) error) error { 265 | pvtKey, err := melt.FromMnemonic(mnemonic) 266 | if err != nil { 267 | //nolint: wrapcheck 268 | return err 269 | } 270 | 271 | pass, err := passFn() 272 | if err != nil { 273 | return err 274 | } 275 | 276 | block, err := marshallPrivateKey(pvtKey, pass) 277 | if err != nil { 278 | return fmt.Errorf("could not marshal private key: %w", err) 279 | } 280 | 281 | pubkey, err := ssh.NewPublicKey(pvtKey.Public()) 282 | if err != nil { 283 | return fmt.Errorf("could not prepare public key: %w", err) 284 | } 285 | 286 | return outFn(pem.EncodeToMemory(block), ssh.MarshalAuthorizedKey(pubkey)) 287 | } 288 | 289 | func restoreToWriter(w io.Writer) func(pem, _ []byte) error { 290 | return func(pem, _ []byte) error { 291 | if _, err := fmt.Fprint(w, string(pem)); err != nil { 292 | return fmt.Errorf("could not write private key: %w", err) 293 | } 294 | return nil 295 | } 296 | } 297 | 298 | func restoreToFiles(path string) func(pem, pub []byte) error { 299 | return func(pem, pub []byte) error { 300 | if err := os.WriteFile(path, pem, 0o600); err != nil { //nolint: gomnd 301 | return fmt.Errorf("failed to write private key: %w", err) 302 | } 303 | 304 | if err := os.WriteFile(path+".pub", pub, 0o600); err != nil { //nolint: gomnd 305 | return fmt.Errorf("failed to write public key: %w", err) 306 | } 307 | return nil 308 | } 309 | } 310 | 311 | func getWidth(maxw int) int { 312 | w, _, err := term.GetSize(int(os.Stdout.Fd())) //nolint: gosec 313 | if err != nil || w > maxw { 314 | return maxWidth 315 | } 316 | return w 317 | } 318 | 319 | func renderBlock(w io.Writer, s lipgloss.Style, width int, str string) { 320 | _, _ = io.WriteString(w, s.Width(width).Render(str)) 321 | _, _ = io.WriteString(w, "\n") 322 | } 323 | 324 | func completeColor(truecolor, ansi256, ansi string) string { 325 | //nolint: exhaustive 326 | switch lipgloss.ColorProfile() { 327 | case termenv.TrueColor: 328 | return truecolor 329 | case termenv.ANSI256: 330 | return ansi256 331 | } 332 | return ansi 333 | } 334 | 335 | // setLanguage sets the language of the big39 mnemonic seed. 336 | func setLanguage(language string) error { 337 | list := getWordlist(language) 338 | if list == nil { 339 | return fmt.Errorf("this language is not supported") 340 | } 341 | bip39.SetWordList(list) 342 | return nil 343 | } 344 | 345 | func sanitizeLang(s string) string { 346 | return strings.ReplaceAll(strings.ToLower(s), " ", "-") 347 | } 348 | 349 | var wordLists = map[lang.Tag][]string{ 350 | lang.Chinese: wordlists.ChineseSimplified, 351 | lang.SimplifiedChinese: wordlists.ChineseSimplified, 352 | lang.TraditionalChinese: wordlists.ChineseTraditional, 353 | lang.Czech: wordlists.Czech, 354 | lang.AmericanEnglish: wordlists.English, 355 | lang.BritishEnglish: wordlists.English, 356 | lang.English: wordlists.English, 357 | lang.French: wordlists.French, 358 | lang.Italian: wordlists.Italian, 359 | lang.Japanese: wordlists.Japanese, 360 | lang.Korean: wordlists.Korean, 361 | lang.Spanish: wordlists.Spanish, 362 | lang.EuropeanSpanish: wordlists.Spanish, 363 | lang.LatinAmericanSpanish: wordlists.Spanish, 364 | } 365 | 366 | func getWordlist(language string) []string { 367 | language = sanitizeLang(language) 368 | tag := lang.Make(language) 369 | en := display.English.Languages() // default language name matcher 370 | for t := range wordLists { 371 | if sanitizeLang(en.Name(t)) == language { 372 | tag = t 373 | break 374 | } 375 | } 376 | if tag == lang.Und { // Unknown language 377 | return nil 378 | } 379 | base, _ := tag.Base() 380 | btag := lang.MustParse(base.String()) 381 | wl := wordLists[tag] 382 | if wl == nil { 383 | return wordLists[btag] 384 | } 385 | return wl 386 | } 387 | 388 | func readPassword(msg string) ([]byte, error) { 389 | _, _ = fmt.Fprint(os.Stderr, msg) 390 | t, err := tty.Open() 391 | if err != nil { 392 | return nil, fmt.Errorf("could not open tty: %w", err) 393 | } 394 | defer t.Close() //nolint: errcheck 395 | pass, err := term.ReadPassword(int(t.Input().Fd())) //nolint: gosec 396 | if err != nil { 397 | return nil, fmt.Errorf("could not read passphrase: %w", err) 398 | } 399 | return pass, nil 400 | } 401 | 402 | func askKeyPassphrase(path string) ([]byte, error) { 403 | defer fmt.Fprintf(os.Stderr, "\n") 404 | return readPassword(fmt.Sprintf("Enter the passphrase to unlock %q: ", path)) 405 | } 406 | 407 | func askNewPassphrase() ([]byte, error) { 408 | defer fmt.Fprintf(os.Stderr, "\n") 409 | pass, err := readPassword("Enter new passphrase (empty for no passphrase): ") 410 | if err != nil { 411 | return nil, err 412 | } 413 | 414 | confirm, err := readPassword("\nEnter same passphrase again: ") 415 | if err != nil { 416 | return nil, fmt.Errorf("could not read password confirmation for key: %w", err) 417 | } 418 | 419 | if !bytes.Equal(pass, confirm) { 420 | return nil, fmt.Errorf("Passphareses do not match") 421 | } 422 | 423 | return pass, nil 424 | } 425 | --------------------------------------------------------------------------------