tenseleyflow/shithub / 0936fec

Browse files

Add sshkey package: ParseAuthorizedKey + algo whitelist + RSA bit floor + canonical fingerprint

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0936fecc90c2481732a4eb819e4a5665ee390004
Parents
c7690aa
Tree
78c0b22

6 changed files

StatusFile+-
A internal/auth/sshkey/sshkey.go 154 0
A internal/auth/sshkey/sshkey_test.go 144 0
A internal/auth/sshkey/testdata/ecdsa256.pub 1 0
A internal/auth/sshkey/testdata/ed25519.pub 1 0
A internal/auth/sshkey/testdata/rsa1024.pub 1 0
A internal/auth/sshkey/testdata/rsa2048.pub 1 0
internal/auth/sshkey/sshkey.goadded
@@ -0,0 +1,154 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package sshkey wraps the parsing, validation, and fingerprinting of
4
+// user-supplied SSH public keys. Every entry that takes key material
5
+// from outside (the settings handlers, future imports, the AKC binary's
6
+// equality checks) goes through this package so policy lives in exactly
7
+// one place.
8
+package sshkey
9
+
10
+import (
11
+	"crypto/dsa"   //nolint:staticcheck // DSA detection only — never accepted.
12
+	"crypto/ecdsa"
13
+	"crypto/ed25519"
14
+	"crypto/rsa"
15
+	"errors"
16
+	"fmt"
17
+	"strings"
18
+
19
+	"golang.org/x/crypto/ssh"
20
+)
21
+
22
+// MinRSABits is the smallest accepted modulus length for ssh-rsa keys.
23
+const MinRSABits = 2048
24
+
25
+// MaxKeysPerUser is the cap enforced at the handler layer. Keeps DB rows
26
+// per-user bounded and limits AKC lookup amplification if an attacker
27
+// registered an enormous keyring on a single account.
28
+const MaxKeysPerUser = 100
29
+
30
+// Parsed is the validated, ready-to-store representation of a user-supplied
31
+// SSH public key. Title is whatever name the user typed; everything else
32
+// is derived from the key blob.
33
+type Parsed struct {
34
+	Title       string
35
+	Type        string // OpenSSH algorithm name, e.g. "ssh-ed25519"
36
+	Bits        int    // 0 when not meaningful (e.g. ed25519)
37
+	Fingerprint string // canonical SHA256:<base64-no-padding>
38
+	// PublicKey is the canonical authorized_keys line (no comment; no
39
+	// trailing newline). It is what the AKC binary re-emits on a hit.
40
+	PublicKey string
41
+}
42
+
43
+// Errors. Keep them precise enough for the settings handler to render a
44
+// useful UI string.
45
+var (
46
+	ErrUnsupportedAlgo = errors.New("sshkey: unsupported key algorithm")
47
+	ErrRSATooShort     = fmt.Errorf("sshkey: RSA keys must be at least %d bits", MinRSABits)
48
+	ErrUnparseable     = errors.New("sshkey: could not parse key — paste the contents of your .pub file")
49
+	ErrTitleEmpty      = errors.New("sshkey: title required")
50
+	ErrTitleTooLong    = errors.New("sshkey: title may be at most 80 characters")
51
+	ErrTitleControl    = errors.New("sshkey: title contains control characters")
52
+)
53
+
54
+// Parse validates and canonicalizes input. Title goes through a small
55
+// validator (length + control-char rejection); the key blob through
56
+// ssh.ParseAuthorizedKey + an algorithm whitelist + an RSA-bits check.
57
+func Parse(title, blob string) (*Parsed, error) {
58
+	t := strings.TrimSpace(title)
59
+	if t == "" {
60
+		return nil, ErrTitleEmpty
61
+	}
62
+	if len(t) > 80 {
63
+		return nil, ErrTitleTooLong
64
+	}
65
+	if hasControlChars(t) {
66
+		return nil, ErrTitleControl
67
+	}
68
+
69
+	pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(blob))
70
+	if err != nil {
71
+		return nil, ErrUnparseable
72
+	}
73
+
74
+	algo := pub.Type()
75
+	if !algorithmAllowed(algo) {
76
+		return nil, ErrUnsupportedAlgo
77
+	}
78
+
79
+	bits, err := keyBits(pub)
80
+	if err != nil {
81
+		return nil, err
82
+	}
83
+	if algo == "ssh-rsa" && bits < MinRSABits {
84
+		return nil, ErrRSATooShort
85
+	}
86
+
87
+	canonical := canonicalAuthorizedKey(pub)
88
+	return &Parsed{
89
+		Title:       t,
90
+		Type:        algo,
91
+		Bits:        bits,
92
+		Fingerprint: ssh.FingerprintSHA256(pub),
93
+		PublicKey:   canonical,
94
+	}, nil
95
+}
96
+
97
+// FingerprintOf returns the canonical SHA256 fingerprint for an
98
+// already-parsed key. Used by the AKC binary's equality checks.
99
+func FingerprintOf(pub ssh.PublicKey) string {
100
+	return ssh.FingerprintSHA256(pub)
101
+}
102
+
103
+// canonicalAuthorizedKey re-emits the parsed key as a single
104
+// `<algo> <base64>` line so storage/lookup don't accidentally depend on
105
+// whitespace or comment fields the user supplied.
106
+func canonicalAuthorizedKey(pub ssh.PublicKey) string {
107
+	line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
108
+	return line
109
+}
110
+
111
+// algorithmAllowed gates the OpenSSH algorithm names we accept.
112
+func algorithmAllowed(algo string) bool {
113
+	switch algo {
114
+	case "ssh-ed25519",
115
+		"sk-ssh-ed25519@openssh.com",
116
+		"ecdsa-sha2-nistp256",
117
+		"ecdsa-sha2-nistp384",
118
+		"ecdsa-sha2-nistp521",
119
+		"sk-ecdsa-sha2-nistp256@openssh.com",
120
+		"ssh-rsa":
121
+		return true
122
+	}
123
+	return false
124
+}
125
+
126
+// keyBits extracts the modulus / curve size from a parsed key. Returns 0
127
+// for ed25519 (where the concept doesn't apply meaningfully).
128
+func keyBits(pub ssh.PublicKey) (int, error) {
129
+	cp, ok := pub.(ssh.CryptoPublicKey)
130
+	if !ok {
131
+		return 0, nil
132
+	}
133
+	switch k := cp.CryptoPublicKey().(type) {
134
+	case ed25519.PublicKey:
135
+		return 0, nil
136
+	case *rsa.PublicKey:
137
+		return k.N.BitLen(), nil
138
+	case *ecdsa.PublicKey:
139
+		return k.Params().BitSize, nil
140
+	case *dsa.PublicKey:
141
+		return 0, ErrUnsupportedAlgo
142
+	default:
143
+		return 0, nil
144
+	}
145
+}
146
+
147
+func hasControlChars(s string) bool {
148
+	for _, r := range s {
149
+		if r < 0x20 || r == 0x7f {
150
+			return true
151
+		}
152
+	}
153
+	return false
154
+}
internal/auth/sshkey/sshkey_test.goadded
@@ -0,0 +1,144 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sshkey
4
+
5
+import (
6
+	"errors"
7
+	"os"
8
+	"os/exec"
9
+	"path/filepath"
10
+	"strings"
11
+	"testing"
12
+)
13
+
14
+func mustRead(t *testing.T, name string) string {
15
+	t.Helper()
16
+	b, err := os.ReadFile(filepath.Join("testdata", name))
17
+	if err != nil {
18
+		t.Fatalf("read %s: %v", name, err)
19
+	}
20
+	return string(b)
21
+}
22
+
23
+func TestParse_AcceptsEd25519(t *testing.T) {
24
+	t.Parallel()
25
+	pub := mustRead(t, "ed25519.pub")
26
+	got, err := Parse("my laptop", pub)
27
+	if err != nil {
28
+		t.Fatalf("Parse: %v", err)
29
+	}
30
+	if got.Type != "ssh-ed25519" {
31
+		t.Fatalf("type = %q", got.Type)
32
+	}
33
+	if !strings.HasPrefix(got.Fingerprint, "SHA256:") {
34
+		t.Fatalf("fingerprint shape: %q", got.Fingerprint)
35
+	}
36
+	// Cross-check against ssh-keygen if available — defensive.
37
+	if _, err := exec.LookPath("ssh-keygen"); err == nil {
38
+		out, err := exec.Command("ssh-keygen", "-E", "sha256", "-lf",
39
+			filepath.Join("testdata", "ed25519.pub")).Output()
40
+		if err != nil {
41
+			t.Fatalf("ssh-keygen: %v", err)
42
+		}
43
+		if !strings.Contains(string(out), got.Fingerprint) {
44
+			t.Fatalf("fingerprint mismatch with ssh-keygen: %q vs %s",
45
+				got.Fingerprint, out)
46
+		}
47
+	}
48
+}
49
+
50
+func TestParse_AcceptsRSA2048(t *testing.T) {
51
+	t.Parallel()
52
+	pub := mustRead(t, "rsa2048.pub")
53
+	got, err := Parse("ci runner", pub)
54
+	if err != nil {
55
+		t.Fatalf("Parse: %v", err)
56
+	}
57
+	if got.Type != "ssh-rsa" {
58
+		t.Fatalf("type = %q", got.Type)
59
+	}
60
+	if got.Bits < 2048 {
61
+		t.Fatalf("bits = %d", got.Bits)
62
+	}
63
+}
64
+
65
+func TestParse_AcceptsECDSA(t *testing.T) {
66
+	t.Parallel()
67
+	pub := mustRead(t, "ecdsa256.pub")
68
+	got, err := Parse("hsm", pub)
69
+	if err != nil {
70
+		t.Fatalf("Parse: %v", err)
71
+	}
72
+	if got.Type != "ecdsa-sha2-nistp256" {
73
+		t.Fatalf("type = %q", got.Type)
74
+	}
75
+}
76
+
77
+func TestParse_RejectsRSA1024(t *testing.T) {
78
+	t.Parallel()
79
+	pub := mustRead(t, "rsa1024.pub")
80
+	_, err := Parse("weak", pub)
81
+	if !errors.Is(err, ErrRSATooShort) {
82
+		t.Fatalf("expected ErrRSATooShort, got %v", err)
83
+	}
84
+}
85
+
86
+func TestParse_RejectsUnparseable(t *testing.T) {
87
+	t.Parallel()
88
+	cases := []string{
89
+		"",
90
+		"not-a-key",
91
+		"ssh-rsa AAAA broken",
92
+		"ssh-dss AAAAB3NzaC1kc3MAAACB",
93
+	}
94
+	for _, in := range cases {
95
+		_, err := Parse("title", in)
96
+		if err == nil {
97
+			t.Errorf("expected error for %q", in)
98
+		}
99
+	}
100
+}
101
+
102
+func TestParse_RejectsEmptyTitle(t *testing.T) {
103
+	t.Parallel()
104
+	pub := mustRead(t, "ed25519.pub")
105
+	_, err := Parse("   ", pub)
106
+	if !errors.Is(err, ErrTitleEmpty) {
107
+		t.Fatalf("expected ErrTitleEmpty, got %v", err)
108
+	}
109
+}
110
+
111
+func TestParse_RejectsLongTitle(t *testing.T) {
112
+	t.Parallel()
113
+	pub := mustRead(t, "ed25519.pub")
114
+	_, err := Parse(strings.Repeat("a", 81), pub)
115
+	if !errors.Is(err, ErrTitleTooLong) {
116
+		t.Fatalf("expected ErrTitleTooLong, got %v", err)
117
+	}
118
+}
119
+
120
+func TestParse_RejectsControlCharsInTitle(t *testing.T) {
121
+	t.Parallel()
122
+	pub := mustRead(t, "ed25519.pub")
123
+	_, err := Parse("oops\nnewline", pub)
124
+	if !errors.Is(err, ErrTitleControl) {
125
+		t.Fatalf("expected ErrTitleControl, got %v", err)
126
+	}
127
+}
128
+
129
+func TestParse_CanonicalizesPublicKey(t *testing.T) {
130
+	t.Parallel()
131
+	pub := mustRead(t, "ed25519.pub")
132
+	got, _ := Parse("my laptop", pub)
133
+	// No trailing newline, no comment.
134
+	if strings.HasSuffix(got.PublicKey, "\n") {
135
+		t.Fatal("canonical key has trailing newline")
136
+	}
137
+	if strings.Contains(got.PublicKey, "fixture-ed25519") {
138
+		t.Fatal("canonical key includes original comment")
139
+	}
140
+	parts := strings.Fields(got.PublicKey)
141
+	if len(parts) != 2 {
142
+		t.Fatalf("canonical key should be 'algo b64', got %q", got.PublicKey)
143
+	}
144
+}
internal/auth/sshkey/testdata/ecdsa256.pubadded
@@ -0,0 +1,1 @@
1
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDhZ0XNDwZFKaR2bnyRAIGkQsRrrCfdZ5d/VEF4NX3wFbjt587fPWT9X4t0WxbwKyC9M6f8cFFI9KHs697Vw1/E= fixture-ecdsa256
internal/auth/sshkey/testdata/ed25519.pubadded
@@ -0,0 +1,1 @@
1
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8uLO5XJSuhkis6RRogK+blyCwUe6qJJUNNwNr3HnFu fixture-ed25519
internal/auth/sshkey/testdata/rsa1024.pubadded
@@ -0,0 +1,1 @@
1
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCczCPPyi3UFhbqVvwipqmmhYcBrO+b2BI/aDgzoe7X8ARwpAVgE7P8rRKU4FbmFDgfc/sgUu5hy04JAwGXepruA06k1fQnLrtJkVyYXEQeophCw3VASJH2YvoKyLHr5ynw3za5EKyvhPWcc5NfWoJZMaNrmxHk4VF6eTgb0L9JPw== fixture-rsa1024
internal/auth/sshkey/testdata/rsa2048.pubadded
@@ -0,0 +1,1 @@
1
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDJHPBEZcki4/K8/HR0gQQ/vVIwWaWibmLmlRQdE8V/W4DqksEY6XXty1hZay8euhw5rdHe4ZtwLRCREBYDjPRP5Zej0f0ZndqMbTJupOw7wd8AtiMMQXXzfHYfaAGTUv3+k0FkYWjxikdlra95OuAJ9zChclqb+h0kyfNGN6fEww9pI1j/BaEFFFK3HDqE0Wm2Xuxw4Wy4T1ird+ev3s5Op2t/SqLkU+7vaVbYxxCRMQoeb4egJgnplXXTFmt1hVL1i6oMYd9+enVP+QmYhO3+uChzCODDy4j8E1VLnUMBo/Yei6PPSPUBlY86CfZ+qopiLXh7ApcDUbHYApHxVUaN fixture-rsa2048