Go · 4549 bytes Raw Blame History
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 }
155