From c759c4772f0ca40a379cd19e63d3343b1e23714a Mon Sep 17 00:00:00 2001 From: Collin Williams <96917990+bluedragon1221@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:46:54 -0500 Subject: yokey: derived cryptography keys tool --- pkgs/yokey/.gitignore | 1 + pkgs/yokey/crypto.go | 150 +++++++++++++++++++++++++++++++++++++ pkgs/yokey/crypto_test.go | 183 ++++++++++++++++++++++++++++++++++++++++++++++ pkgs/yokey/default.nix | 6 ++ pkgs/yokey/go.mod | 10 +++ pkgs/yokey/go.sum | 16 ++++ pkgs/yokey/key | 1 + pkgs/yokey/main.go | 146 ++++++++++++++++++++++++++++++++++++ 8 files changed, 513 insertions(+) create mode 100644 pkgs/yokey/.gitignore create mode 100644 pkgs/yokey/crypto.go create mode 100644 pkgs/yokey/crypto_test.go create mode 100644 pkgs/yokey/default.nix create mode 100644 pkgs/yokey/go.mod create mode 100644 pkgs/yokey/go.sum create mode 100644 pkgs/yokey/key create mode 100644 pkgs/yokey/main.go (limited to 'pkgs/yokey') diff --git a/pkgs/yokey/.gitignore b/pkgs/yokey/.gitignore new file mode 100644 index 0000000..3cd5030 --- /dev/null +++ b/pkgs/yokey/.gitignore @@ -0,0 +1 @@ +yokey diff --git a/pkgs/yokey/crypto.go b/pkgs/yokey/crypto.go new file mode 100644 index 0000000..df6e4ec --- /dev/null +++ b/pkgs/yokey/crypto.go @@ -0,0 +1,150 @@ +package main + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/pem" + "io" + "strings" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" + "golang.org/x/crypto/ssh" + "pault.ag/go/sshsig" +) + +const sshsigContext = "sshsig-key-v1" + +func deriveKey(master []byte, info string, length int) []byte { + h := hkdf.New(sha256.New, master, []byte("fixed-salt"), []byte(info)) + out := make([]byte, length) + if _, err := io.ReadFull(h, out); err != nil { + panic(err) + } + return out +} + +func buildInfo(baseInfo, context string) string { + trimmed := strings.TrimSpace(context) + if trimmed == "" { + return baseInfo + } + + return baseInfo + ":" + trimmed +} + +func deriveEd25519(master []byte, context string) (string, []byte) { + seed := deriveKey(master, buildInfo("ed25519-key-v1", context), 32) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + panic(err) + } + + privBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + panic(err) + } + + return string(ssh.MarshalAuthorizedKey(sshPub)), pem.EncodeToMemory(privBlock) +} + +func deriveWireGuard(master []byte, context string) (string, string) { + priv := deriveKey(master, buildInfo("wireguard-key-v1", context), 32) + pub, _ := curve25519.X25519(priv, curve25519.Basepoint) + + return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(priv) +} + +const passwordCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@$%^&*" +const passwordLength = 14 + +func derivePassword(master []byte, seed string) string { + return derivePasswordWithContext(master, seed, "") +} + +func derivePasswordWithContext(master []byte, seed, context string) string { + info := buildInfo("password-v1:"+seed, context) + stream := hkdf.New(sha256.New, master, []byte("my-fixed-salt"), []byte(info)) + out := make([]byte, 0, passwordLength) + buf := make([]byte, 64) + maxMultiple := byte(256 / len(passwordCharset) * len(passwordCharset)) + + for len(out) < passwordLength { + if _, err := io.ReadFull(stream, buf); err != nil { + panic(err) + } + + for _, b := range buf { + if b >= maxMultiple { + continue + } + + out = append(out, passwordCharset[int(b)%len(passwordCharset)]) + if len(out) == passwordLength { + break + } + } + } + + return string(out) +} + +func deriveSSHSigSigner(master []byte) ssh.Signer { + _, privPem := deriveEd25519(master, sshsigContext) + rawPriv, err := ssh.ParseRawPrivateKey(privPem) + if err != nil { + panic(err) + } + + signer, err := ssh.NewSignerFromKey(rawPriv) + if err != nil { + panic(err) + } + + return signer +} + +func signSSHSig(master []byte, namespace string, message []byte) []byte { + signer := deriveSSHSigSigner(master) + h := crypto.SHA512.New() + if _, err := h.Write(message); err != nil { + panic(err) + } + + sig, err := sshsig.Sign(rand.Reader, signer, []byte(namespace), sshsig.HashAlgoSHA512, h.Sum(nil)) + if err != nil { + panic(err) + } + + return sig +} + +func verifySSHSig(master []byte, namespace string, message, signature []byte) error { + signer := deriveSSHSigSigner(master) + pub := signer.PublicKey() + + parsedSig, err := sshsig.ParseSignature(signature) + if err != nil { + return err + } + + h := crypto.SHA512.New() + if _, err := h.Write(message); err != nil { + return err + } + + return sshsig.Verify(pub, []byte(namespace), sshsig.HashAlgoSHA512, h.Sum(nil), parsedSig) +} + +func generateMasterKeyBytes() []byte { + key := make([]byte, 32) + rand.Read(key) + + return key +} diff --git a/pkgs/yokey/crypto_test.go b/pkgs/yokey/crypto_test.go new file mode 100644 index 0000000..6bedb72 --- /dev/null +++ b/pkgs/yokey/crypto_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/ssh" +) + +const testMasterHex = "95609277cb88af80a624df3cb8bb130d49ac62707d16e925762dadbec32a1d45" + +func testMasterKey(t *testing.T) []byte { + t.Helper() + + master, err := hex.DecodeString(testMasterHex) + if err != nil { + t.Fatalf("decode master key: %v", err) + } + + return master +} + +func parseEd25519PrivateKey(t *testing.T, pemBytes []byte) ed25519.PrivateKey { + t.Helper() + + rawPriv, err := ssh.ParseRawPrivateKey(pemBytes) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + + switch key := rawPriv.(type) { + case ed25519.PrivateKey: + return key + case *ed25519.PrivateKey: + return *key + default: + t.Fatalf("unexpected private key type: %T", rawPriv) + return nil + } +} + +func TestBuildInfo(t *testing.T) { + if got := buildInfo("ed25519-key-v1", ""); got != "ed25519-key-v1" { + t.Fatalf("empty context mismatch: %q", got) + } + + if got := buildInfo("ed25519-key-v1", " "); got != "ed25519-key-v1" { + t.Fatalf("whitespace context mismatch: %q", got) + } + + if got := buildInfo("ed25519-key-v1", "host:ganymede"); got != "ed25519-key-v1:host:ganymede" { + t.Fatalf("context append mismatch: %q", got) + } +} + +func TestDeriveEd25519DeterministicAndScoped(t *testing.T) { + master := testMasterKey(t) + + pubA1, privA1 := deriveEd25519(master, "host:ganymede") + pubA2, privA2 := deriveEd25519(master, "host:ganymede") + if pubA1 != pubA2 { + t.Fatalf("public key should be deterministic") + } + + edPrivA1 := parseEd25519PrivateKey(t, privA1) + edPrivA2 := parseEd25519PrivateKey(t, privA2) + + if !bytes.Equal(edPrivA1, edPrivA2) { + t.Fatalf("private key material should be deterministic") + } + + pubB, privB := deriveEd25519(master, "host:mercury") + if pubA1 == pubB { + t.Fatalf("public keys should differ across contexts") + } + + edPrivB := parseEd25519PrivateKey(t, privB) + + if bytes.Equal(edPrivA1, edPrivB) { + t.Fatalf("private key material should differ across contexts") + } + + if !strings.HasPrefix(pubA1, "ssh-ed25519 ") { + t.Fatalf("unexpected public key format: %q", pubA1) + } + + if len(edPrivA1) != ed25519.PrivateKeySize { + t.Fatalf("unexpected private key size: %d", len(edPrivA1)) + } +} + +func TestDeriveWireGuardDeterministicAndScoped(t *testing.T) { + master := testMasterKey(t) + + pubA1, privA1 := deriveWireGuard(master, "vpn:home") + pubA2, privA2 := deriveWireGuard(master, "vpn:home") + if pubA1 != pubA2 || privA1 != privA2 { + t.Fatalf("wireguard keys should be deterministic") + } + + pubB, privB := deriveWireGuard(master, "vpn:office") + if pubA1 == pubB || privA1 == privB { + t.Fatalf("wireguard keys should differ across contexts") + } + + privBytes, err := base64.StdEncoding.DecodeString(privA1) + if err != nil { + t.Fatalf("decode private key: %v", err) + } + if len(privBytes) != 32 { + t.Fatalf("private key should decode to 32 bytes, got %d", len(privBytes)) + } + + pubBytes, err := base64.StdEncoding.DecodeString(pubA1) + if err != nil { + t.Fatalf("decode public key: %v", err) + } + if len(pubBytes) != 32 { + t.Fatalf("public key should decode to 32 bytes, got %d", len(pubBytes)) + } + + recomputedPub, err := curve25519.X25519(privBytes, curve25519.Basepoint) + if err != nil { + t.Fatalf("recompute public key: %v", err) + } + if !bytes.Equal(pubBytes, recomputedPub) { + t.Fatalf("public key does not match private key") + } +} + +func TestDerivePasswordDeterministicCharsetAndContext(t *testing.T) { + master := testMasterKey(t) + + p1 := derivePassword(master, "forgejo") + p2 := derivePassword(master, "forgejo") + if p1 != p2 { + t.Fatalf("password should be deterministic") + } + + if len(p1) != passwordLength { + t.Fatalf("password length mismatch: got %d want %d", len(p1), passwordLength) + } + + for _, ch := range p1 { + if !strings.ContainsRune(passwordCharset, ch) { + t.Fatalf("password contains invalid character: %q", ch) + } + } + + pCtxA := derivePasswordWithContext(master, "forgejo", "host:ganymede") + pCtxB := derivePasswordWithContext(master, "forgejo", "host:mercury") + if pCtxA == pCtxB { + t.Fatalf("context should change derived password") + } + + pEmptyCtx := derivePasswordWithContext(master, "forgejo", "") + if p1 != pEmptyCtx { + t.Fatalf("empty context should match default password derivation") + } +} + +func TestGenerateMasterKeyBytesLength(t *testing.T) { + key := generateMasterKeyBytes() + if len(key) != 32 { + t.Fatalf("master key should be 32 bytes, got %d", len(key)) + } + + allZero := true + for _, b := range key { + if b != 0 { + allZero = false + break + } + } + if allZero { + t.Fatalf("generated master key should not be all zeros") + } +} diff --git a/pkgs/yokey/default.nix b/pkgs/yokey/default.nix new file mode 100644 index 0000000..4437de9 --- /dev/null +++ b/pkgs/yokey/default.nix @@ -0,0 +1,6 @@ +{pkgs, ...}: +pkgs.buildGoModule { + name = "yokey"; + src = ./.; + vendorHash = "sha256-utqr6tHvWmXe+9sV4Nul0EYTUOyWp/e4mcfiINdxkfc="; +} diff --git a/pkgs/yokey/go.mod b/pkgs/yokey/go.mod new file mode 100644 index 0000000..ed3a117 --- /dev/null +++ b/pkgs/yokey/go.mod @@ -0,0 +1,10 @@ +module git.ganymede/yokey + +go 1.25.5 + +require ( + golang.org/x/crypto v0.49.0 + pault.ag/go/sshsig v0.0.0-20221115041640-a684343203bd +) + +require golang.org/x/sys v0.42.0 // indirect diff --git a/pkgs/yokey/go.sum b/pkgs/yokey/go.sum new file mode 100644 index 0000000..cfaf4be --- /dev/null +++ b/pkgs/yokey/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pault.ag/go/sshsig v0.0.0-20221115041640-a684343203bd h1:f81rN0BPWQHgq163HG1P4MUWkuK3YbG492XUZtQZXzA= +pault.ag/go/sshsig v0.0.0-20221115041640-a684343203bd/go.mod h1:SKeyiYinm0JehwSECGojCWfecDe4oxn4w+Ib08LjsOU= diff --git a/pkgs/yokey/key b/pkgs/yokey/key new file mode 100644 index 0000000..8fb9df0 --- /dev/null +++ b/pkgs/yokey/key @@ -0,0 +1 @@ +95609277cb88af80a624df3cb8bb130d49ac62707d16e925762dadbec32a1d45 diff --git a/pkgs/yokey/main.go b/pkgs/yokey/main.go new file mode 100644 index 0000000..f5d0822 --- /dev/null +++ b/pkgs/yokey/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +func readMasterKey() []byte { + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + panic(err) + } + input = strings.TrimSpace(input) + if decoded, err := hex.DecodeString(input); err == nil { + return decoded + } + return []byte(input) +} + +func formatSignedMessage(message string, signature []byte) string { + var out bytes.Buffer + + out.WriteString("-----BEGIN SSH SIGNED MESSAGE-----\n") + out.WriteString(message) + if !strings.HasSuffix(message, "\n") { + out.WriteString("\n") + } + + sigBlock := pem.EncodeToMemory(&pem.Block{Type: "SSH SIGNATURE", Bytes: signature}) + out.Write(sigBlock) + + return out.String() +} + +func main() { + if len(os.Args) < 2 { + panic("usage: yokey ") + } + + mode := os.Args[1] + if mode == "gen" { + key := generateMasterKeyBytes() + fmt.Println(hex.EncodeToString(key)) + return + } + + if mode == "sshsig-public" { + master := readMasterKey() + signer := deriveSSHSigSigner(master) + fmt.Print(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) + return + } + + if mode == "sshsig-sign" { + if len(os.Args) != 4 { + panic("usage: yokey sshsig-sign ") + } + + namespace := os.Args[2] + message := []byte(os.Args[3]) + master := readMasterKey() + signature := signSSHSig(master, namespace, message) + fmt.Print(formatSignedMessage(string(message), signature)) + return + } + + if mode == "sshsig-verify" { + if len(os.Args) != 5 { + panic("usage: yokey sshsig-verify ") + } + + namespace := os.Args[2] + message := []byte(os.Args[3]) + signatureArg := strings.TrimSpace(os.Args[4]) + master := readMasterKey() + + signature, err := base64.StdEncoding.DecodeString(signatureArg) + if err != nil { + fmt.Println("invalid") + os.Exit(1) + } + + if err := verifySSHSig(master, namespace, message, signature); err != nil { + fmt.Println("invalid") + os.Exit(1) + } + + fmt.Println("ok") + return + } + + master := readMasterKey() + + if mode == "passwd" { + if len(os.Args) > 2 { + fmt.Println(derivePassword(master, os.Args[2])) + return + } else { + panic("must specify seed for password") + } + } + + var pub string + var priv string + + if mode == "ed25519" { + var privBytes []byte + if len(os.Args) > 3 { + pub, privBytes = deriveEd25519(master, os.Args[3]) + } else { + pub, privBytes = deriveEd25519(master, "") + } + priv = string(privBytes) + } + + if mode == "wg" { + if len(os.Args) > 3 { + pub, priv = deriveWireGuard(master, os.Args[3]) + } else { + pub, priv = deriveWireGuard(master, "") + } + } + + if len(os.Args) > 2 { + switch os.Args[2] { + case "public": + fmt.Print(pub) + return + case "private": + fmt.Print(priv) + return + } + } else { + panic("must specify mode for key: private or public") + } +} -- cgit v1.3.1