aboutsummaryrefslogtreecommitdiff
path: root/pkgs/yokey
diff options
context:
space:
mode:
authorCollin Williams <96917990+bluedragon1221@users.noreply.github.com>2026-03-31 15:46:54 -0500
committerCollin Williams <96917990+bluedragon1221@users.noreply.github.com>2026-03-31 15:46:54 -0500
commitc759c4772f0ca40a379cd19e63d3343b1e23714a (patch)
tree53cb7ba9ac3452d32909a64f1795ebdd6a840c20 /pkgs/yokey
parent6154fca38879afcb57f16adad002df083876116d (diff)
yokey: derived cryptography keys tool
Diffstat (limited to 'pkgs/yokey')
-rw-r--r--pkgs/yokey/.gitignore1
-rw-r--r--pkgs/yokey/crypto.go150
-rw-r--r--pkgs/yokey/crypto_test.go183
-rw-r--r--pkgs/yokey/default.nix6
-rw-r--r--pkgs/yokey/go.mod10
-rw-r--r--pkgs/yokey/go.sum16
-rw-r--r--pkgs/yokey/key1
-rw-r--r--pkgs/yokey/main.go146
8 files changed, 513 insertions, 0 deletions
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>")
+ }
+
+ 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> <message>")
+ }
+
+ 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> <message> <signature>")
+ }
+
+ 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")
+ }
+}