Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudflare/circl/sign/ed448"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// GenerateRSAKey generates an RSA key pair with the given bit size (2048 or 4096)
|
||||
func GenerateRSAKey(bits int, comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
if bits != 2048 && bits != 4096 {
|
||||
return nil, nil, "", fmt.Errorf("unsupported RSA key size: %d (use 2048 or 4096)", bits)
|
||||
}
|
||||
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: marshalRSAPrivateKey(privKey),
|
||||
})
|
||||
|
||||
// Generate SSH public key
|
||||
pub, err := ssh.NewPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return privPEM, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// GenerateEd25519Key generates an Ed25519 key pair
|
||||
func GenerateEd25519Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate Ed25519 key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM using OpenSSH format
|
||||
privPEM, err := ssh.MarshalPrivateKey(privKey, comment)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to marshal Ed25519 private key: %w", err)
|
||||
}
|
||||
|
||||
privPEMBytes := pem.EncodeToMemory(privPEM)
|
||||
|
||||
// Generate SSH public key
|
||||
pub, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return privPEMBytes, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// ed448PublicKey wraps an Ed448 public key to implement ssh.PublicKey
|
||||
type ed448PublicKey []byte
|
||||
|
||||
func (k ed448PublicKey) Type() string {
|
||||
return "ssh-ed448"
|
||||
}
|
||||
|
||||
func (k ed448PublicKey) Marshal() []byte {
|
||||
w := struct {
|
||||
KeyType string
|
||||
Key []byte
|
||||
}{
|
||||
KeyType: k.Type(),
|
||||
Key: []byte(k),
|
||||
}
|
||||
return ssh.Marshal(&w)
|
||||
}
|
||||
|
||||
func (k ed448PublicKey) Verify(data []byte, sig *ssh.Signature) error {
|
||||
if sig.Format != k.Type() {
|
||||
return fmt.Errorf("ssh: signature type %s for key type %s", sig.Format, k.Type())
|
||||
}
|
||||
if !ed448.Verify(ed448.PublicKey(k), data, sig.Blob, "") {
|
||||
return fmt.Errorf("ssh: ed448 signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateEd448Key generates an Ed448 key pair
|
||||
func GenerateEd448Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
pubKey, privKey, err := ed448.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate Ed448 key: %w", err)
|
||||
}
|
||||
|
||||
sshPubKey := ed448PublicKey(pubKey)
|
||||
|
||||
privPEM, err := marshalOpenSSHEd448(privKey, pubKey, comment)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to marshal Ed448 private key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(sshPubKey), comment)
|
||||
fp := fingerprintSHA256(sshPubKey)
|
||||
|
||||
return privPEM, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// marshalOpenSSHEd448 encodes an Ed448 key pair in openssh-key-v1 private key format
|
||||
func marshalOpenSSHEd448(privKey ed448.PrivateKey, pubKey ed448.PublicKey, comment string) ([]byte, error) {
|
||||
// Public key wire format
|
||||
pubWire := struct {
|
||||
KeyType string
|
||||
PubKey []byte
|
||||
}{
|
||||
KeyType: "ssh-ed448",
|
||||
PubKey: []byte(pubKey),
|
||||
}
|
||||
pubWireBytes := ssh.Marshal(&pubWire)
|
||||
|
||||
// Random check value for integrity verification
|
||||
var checkBuf [4]byte
|
||||
if _, err := rand.Read(checkBuf[:]); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random check: %w", err)
|
||||
}
|
||||
check := binary.BigEndian.Uint32(checkBuf[:])
|
||||
|
||||
// Build the private key blob (seed + public key, following OpenSSH convention)
|
||||
var keyBlob []byte
|
||||
if len(privKey) <= ed448.SeedSize {
|
||||
keyBlob = make([]byte, 0, ed448.SeedSize+len(pubKey))
|
||||
keyBlob = append(keyBlob, privKey...)
|
||||
keyBlob = append(keyBlob, pubKey...)
|
||||
} else {
|
||||
keyBlob = []byte(privKey)
|
||||
}
|
||||
|
||||
// Private key section (unencrypted)
|
||||
privSection := struct {
|
||||
Check1 uint32
|
||||
Check2 uint32
|
||||
KeyType string
|
||||
PubKey []byte
|
||||
PrivKey []byte
|
||||
Comment string
|
||||
}{
|
||||
Check1: check,
|
||||
Check2: check,
|
||||
KeyType: "ssh-ed448",
|
||||
PubKey: []byte(pubKey),
|
||||
PrivKey: keyBlob,
|
||||
Comment: comment,
|
||||
}
|
||||
privSectionBytes := ssh.Marshal(&privSection)
|
||||
|
||||
// Pad to block size of 8
|
||||
padLen := (8 - len(privSectionBytes)%8) % 8
|
||||
for i := 0; i < padLen; i++ {
|
||||
privSectionBytes = append(privSectionBytes, byte(i+1))
|
||||
}
|
||||
|
||||
// Assemble full openssh-key-v1 format
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("openssh-key-v1\x00")
|
||||
|
||||
outer := struct {
|
||||
CipherName string
|
||||
KdfName string
|
||||
KdfOpts string
|
||||
NumKeys uint32
|
||||
PubKey []byte
|
||||
PrivKey []byte
|
||||
}{
|
||||
CipherName: "none",
|
||||
KdfName: "none",
|
||||
KdfOpts: "",
|
||||
NumKeys: 1,
|
||||
PubKey: pubWireBytes,
|
||||
PrivKey: privSectionBytes,
|
||||
}
|
||||
buf.Write(ssh.Marshal(&outer))
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "OPENSSH PRIVATE KEY",
|
||||
Bytes: buf.Bytes(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParsePublicKey parses an SSH public key and returns its fingerprint
|
||||
func ParsePublicKey(pubKeyBytes []byte) (fingerprint string, keyType string, err error) {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
return fingerprintSHA256(pub), pub.Type(), nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey parses a PEM-encoded private key and extracts the public key
|
||||
func ParsePrivateKey(privKeyPEM []byte) (publicKey []byte, fingerprint string, keyType string, err error) {
|
||||
signer, err := ssh.ParsePrivateKey(privKeyPEM)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
pub := signer.PublicKey()
|
||||
pubBytes := ssh.MarshalAuthorizedKey(pub)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return pubBytes, fp, pub.Type(), nil
|
||||
}
|
||||
|
||||
// appendComment appends a comment to an SSH authorized key line
|
||||
func appendComment(pubBytes []byte, comment string) []byte {
|
||||
if comment == "" {
|
||||
return pubBytes
|
||||
}
|
||||
// MarshalAuthorizedKey returns "type base64\n", insert comment before newline
|
||||
line := strings.TrimRight(string(pubBytes), "\n")
|
||||
return []byte(line + " " + comment + "\n")
|
||||
}
|
||||
|
||||
// fingerprintSHA256 returns the SHA256 fingerprint of an SSH public key
|
||||
func fingerprintSHA256(pub ssh.PublicKey) string {
|
||||
hash := sha256.Sum256(pub.Marshal())
|
||||
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// marshalRSAPrivateKey marshals an RSA private key to PKCS#1 DER bytes
|
||||
func marshalRSAPrivateKey(key *rsa.PrivateKey) []byte {
|
||||
return x509.MarshalPKCS1PrivateKey(key)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateEd25519Key(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd25519Key("test@keywarden")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-ed25519") {
|
||||
t.Fatal("Public key should contain ssh-ed25519")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "test@keywarden") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
if !strings.Contains(string(privPEM), "PRIVATE KEY") {
|
||||
t.Fatal("Private key PEM should contain PRIVATE KEY header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKey2048(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateRSAKey(2048, "rsa-test")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey(2048) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-rsa") {
|
||||
t.Fatal("Public key should contain ssh-rsa")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "rsa-test") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKey4096(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateRSAKey(4096, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey(4096) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKeyInvalidBits(t *testing.T) {
|
||||
_, _, _, err := GenerateRSAKey(1024, "")
|
||||
if err == nil {
|
||||
t.Fatal("GenerateRSAKey(1024) should fail for unsupported key size")
|
||||
}
|
||||
|
||||
_, _, _, err = GenerateRSAKey(3072, "")
|
||||
if err == nil {
|
||||
t.Fatal("GenerateRSAKey(3072) should fail for unsupported key size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyEd25519(t *testing.T) {
|
||||
privPEM, expectedPub, _, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
pubKey, fingerprint, keyType, err := ParsePrivateKey(privPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-ed25519" {
|
||||
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if strings.TrimSpace(string(pubKey)) != strings.TrimSpace(string(expectedPub)) {
|
||||
t.Fatal("Parsed public key does not match generated public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyRSA(t *testing.T) {
|
||||
privPEM, _, _, err := GenerateRSAKey(2048, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey failed: %v", err)
|
||||
}
|
||||
|
||||
_, fingerprint, keyType, err := ParsePrivateKey(privPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-rsa" {
|
||||
t.Fatalf("Expected key type ssh-rsa, got %q", keyType)
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicKey(t *testing.T) {
|
||||
_, pubKey, expectedFP, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
fingerprint, keyType, err := ParsePublicKey(pubKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-ed25519" {
|
||||
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
|
||||
}
|
||||
if fingerprint != expectedFP {
|
||||
t.Fatalf("Fingerprint mismatch: got %q, want %q", fingerprint, expectedFP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd25519KeyUniqueness(t *testing.T) {
|
||||
_, pub1, fp1, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key 1 failed: %v", err)
|
||||
}
|
||||
|
||||
_, pub2, fp2, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if string(pub1) == string(pub2) {
|
||||
t.Fatal("Two generated keys should have different public keys")
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Fatal("Two generated keys should have different fingerprints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448Key(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd448Key("test@keywarden")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-ed448") {
|
||||
t.Fatal("Public key should contain ssh-ed448")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "test@keywarden") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
if !strings.Contains(string(privPEM), "OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("Private key PEM should contain OPENSSH PRIVATE KEY header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448KeyUniqueness(t *testing.T) {
|
||||
_, pub1, fp1, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key 1 failed: %v", err)
|
||||
}
|
||||
|
||||
_, pub2, fp2, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if string(pub1) == string(pub2) {
|
||||
t.Fatal("Two generated Ed448 keys should have different public keys")
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Fatal("Two generated Ed448 keys should have different fingerprints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448KeyNoComment(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyInvalid(t *testing.T) {
|
||||
_, _, _, err := ParsePrivateKey([]byte("not a valid PEM"))
|
||||
if err == nil {
|
||||
t.Fatal("ParsePrivateKey should fail for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicKeyInvalid(t *testing.T) {
|
||||
_, _, err := ParsePublicKey([]byte("not a valid public key"))
|
||||
if err == nil {
|
||||
t.Fatal("ParsePublicKey should fail for invalid key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user