diff --git a/internal/sshclient/bcryptpbkdf.go b/internal/sshclient/bcryptpbkdf.go new file mode 100644 index 0000000..7c492a0 --- /dev/null +++ b/internal/sshclient/bcryptpbkdf.go @@ -0,0 +1,95 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found at https://go.dev/LICENSE. +// +// Adapted from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf, which is not +// importable (internal package). Implements bcrypt_pbkdf(3) from OpenBSD. +// +// See https://flak.tedunangst.com/post/bcrypt-pbkdf and +// https://cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libutil/bcrypt_pbkdf.c. +package sshclient + +import ( + "crypto/sha512" + "errors" + + "golang.org/x/crypto/blowfish" +) + +const bcryptBlockSize = 32 + +// bcryptPBKDF derives a key from the password, salt and rounds count, +// returning a []byte of length keyLen that can be used as cryptographic key. +func bcryptPBKDF(password, salt []byte, rounds, keyLen int) ([]byte, error) { + if rounds < 1 { + return nil, errors.New("bcrypt_pbkdf: number of rounds is too small") + } + if len(password) == 0 { + return nil, errors.New("bcrypt_pbkdf: empty password") + } + if len(salt) == 0 || len(salt) > 1<<20 { + return nil, errors.New("bcrypt_pbkdf: bad salt length") + } + if keyLen > 1024 { + return nil, errors.New("bcrypt_pbkdf: keyLen is too large") + } + + numBlocks := (keyLen + bcryptBlockSize - 1) / bcryptBlockSize + key := make([]byte, numBlocks*bcryptBlockSize) + + h := sha512.New() + h.Write(password) + shapass := h.Sum(nil) + + shasalt := make([]byte, 0, sha512.Size) + cnt, tmp := make([]byte, 4), make([]byte, bcryptBlockSize) + for block := 1; block <= numBlocks; block++ { + h.Reset() + h.Write(salt) + cnt[0] = byte(block >> 24) + cnt[1] = byte(block >> 16) + cnt[2] = byte(block >> 8) + cnt[3] = byte(block) + h.Write(cnt) + bcryptHash(tmp, shapass, h.Sum(shasalt)) + + out := make([]byte, bcryptBlockSize) + copy(out, tmp) + for i := 2; i <= rounds; i++ { + h.Reset() + h.Write(tmp) + bcryptHash(tmp, shapass, h.Sum(shasalt)) + for j := 0; j < len(out); j++ { + out[j] ^= tmp[j] + } + } + + for i, v := range out { + key[i*numBlocks+(block-1)] = v + } + } + return key[:keyLen], nil +} + +var bcryptMagic = []byte("OxychromaticBlowfishSwatDynamite") + +func bcryptHash(out, shapass, shasalt []byte) { + c, err := blowfish.NewSaltedCipher(shapass, shasalt) + if err != nil { + panic(err) + } + for i := 0; i < 64; i++ { + blowfish.ExpandKey(shasalt, c) + blowfish.ExpandKey(shapass, c) + } + copy(out, bcryptMagic) + for i := 0; i < 32; i += 8 { + for j := 0; j < 64; j++ { + c.Encrypt(out[i:i+8], out[i:i+8]) + } + } + // Swap bytes due to different endianness. + for i := 0; i < 32; i += 4 { + out[i+3], out[i+2], out[i+1], out[i] = out[i], out[i+1], out[i+2], out[i+3] + } +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 3a697b8..1f15389 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/janvete/caddyview/internal/parser" + "git.janvete.com/seth/caddyview/internal/parser" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" "golang.org/x/term" @@ -193,7 +193,7 @@ func unlockWithPassphrase(path string, data []byte) (ssh.Signer, error) { if err != nil { return nil, err } - signer, err := ssh.ParsePrivateKeyWithPassphrase(data, pass) + signer, err := parsePrivateKeyWithPassphrase(data, pass) if err == nil { return signer, nil } @@ -202,6 +202,15 @@ func unlockWithPassphrase(path string, data []byte) (ssh.Signer, error) { return nil, fmt.Errorf("could not decrypt key in 3 attempts") } +// parsePrivateKeyWithPassphrase handles ciphers beyond what x/crypto/ssh +// supports (aes256-gcm@openssh.com, e.g. 1Password key exports). +func parsePrivateKeyWithPassphrase(data, pass []byte) (ssh.Signer, error) { + if isGCMEncryptedKey(data) { + return parseGCMKey(data, pass) + } + return ssh.ParsePrivateKeyWithPassphrase(data, pass) +} + // agentAuth returns an auth method backed by the SSH agent if SSH_AUTH_SOCK is set. func agentAuth() ssh.AuthMethod { sock := os.Getenv("SSH_AUTH_SOCK") diff --git a/internal/sshclient/gcmkey.go b/internal/sshclient/gcmkey.go new file mode 100644 index 0000000..526fb3c --- /dev/null +++ b/internal/sshclient/gcmkey.go @@ -0,0 +1,122 @@ +package sshclient + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/pem" + "errors" + "fmt" + + "golang.org/x/crypto/ssh" +) + +// aes256-gcm@openssh.com is a cipher OpenSSH (and 1Password key exports) may +// use for private key files. golang.org/x/crypto/ssh only supports aes256-ctr +// and aes256-cbc, so these keys are decrypted here and re-marshalled as +// unencrypted openssh-key-v1 blobs that ssh.ParsePrivateKey accepts. +const ( + opensshKeyMagic = "openssh-key-v1\x00" + opensshGCMCipher = "aes256-gcm@openssh.com" + gcmTagSize = 16 +) + +type opensshPrivateKeyHeader struct { + CipherName string + KdfName string + KdfOpts string + NumKeys uint32 + PubKey []byte + PrivKeyBlock []byte + // For AEAD ciphers (aes256-gcm@openssh.com) OpenSSH stores the 16-byte + // auth tag as raw bytes after the encrypted block, not inside it. + Rest []byte `ssh:"rest"` +} + +// isGCMEncryptedKey reports whether data is an OpenSSH private key encrypted +// with aes256-gcm@openssh.com. +func isGCMEncryptedKey(data []byte) bool { + block, _ := pem.Decode(data) + if block == nil || block.Type != "OPENSSH PRIVATE KEY" { + return false + } + if len(block.Bytes) < len(opensshKeyMagic) || string(block.Bytes[:len(opensshKeyMagic)]) != opensshKeyMagic { + return false + } + var header struct { + CipherName string + Rest []byte `ssh:"rest"` + } + if err := ssh.Unmarshal(block.Bytes[len(opensshKeyMagic):], &header); err != nil { + return false + } + return header.CipherName == opensshGCMCipher +} + +// parseGCMKey decrypts an aes256-gcm@openssh.com encrypted OpenSSH private +// key with passphrase and returns the signer. +func parseGCMKey(data, passphrase []byte) (ssh.Signer, error) { + block, _ := pem.Decode(data) + if block == nil || len(block.Bytes) < len(opensshKeyMagic) { + return nil, errors.New("invalid OpenSSH private key PEM") + } + var w opensshPrivateKeyHeader + if err := ssh.Unmarshal(block.Bytes[len(opensshKeyMagic):], &w); err != nil { + return nil, err + } + if w.NumKeys != 1 { + return nil, errors.New("multi-key files are not supported") + } + if w.KdfName != "bcrypt" { + return nil, fmt.Errorf("unknown KDF %q, only supports %q", w.KdfName, "bcrypt") + } + var kdfOpts struct { + Salt string + Rounds uint32 + } + if err := ssh.Unmarshal([]byte(w.KdfOpts), &kdfOpts); err != nil { + return nil, err + } + // Same cap as x/crypto/ssh so a hostile file cannot tie up the caller. + const maxRounds = 1 << 11 + if kdfOpts.Rounds > maxRounds { + return nil, fmt.Errorf("bcrypt KDF rounds %d exceed maximum %d", kdfOpts.Rounds, maxRounds) + } + + // AES-256-GCM: 32-byte key + 12-byte nonce derived by bcrypt_pbkdf. + // The 16-byte auth tag follows the encrypted block as raw bytes; + // gcm.Open expects it appended to the ciphertext. + if len(w.Rest) != gcmTagSize { + return nil, fmt.Errorf("unexpected GCM tag length %d", len(w.Rest)) + } + k, err := bcryptPBKDF(passphrase, []byte(kdfOpts.Salt), int(kdfOpts.Rounds), 32+12) + if err != nil { + return nil, err + } + aesCipher, err := aes.NewCipher(k[:32]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(aesCipher) + if err != nil { + return nil, err + } + sealed := make([]byte, 0, len(w.PrivKeyBlock)+gcmTagSize) + sealed = append(sealed, w.PrivKeyBlock...) + sealed = append(sealed, w.Rest...) + plain, err := gcm.Open(nil, k[32:], sealed, nil) + if err != nil { + return nil, err + } + + // Re-marshal as an unencrypted key (tag dropped). The plaintext keeps + // its original OpenSSH padding, which ssh.ParsePrivateKey validates. + w.CipherName = "none" + w.KdfName = "none" + w.KdfOpts = "" + w.PrivKeyBlock = plain + w.Rest = nil + raw := make([]byte, 0, len(opensshKeyMagic)+len(plain)+128) + raw = append(raw, opensshKeyMagic...) + raw = append(raw, ssh.Marshal(&w)...) + return ssh.ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "OPENSSH PRIVATE KEY", Bytes: raw})) +}