The region at the end let earlier Host lines win over the mesh's (research 027/03). A roster fact cannot be placed at the start, so the region holds one Include of config.d, and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and known_hosts stay found until the controller holds those facts.
111 lines
3.2 KiB
Go
111 lines
3.2 KiB
Go
package main
|
|
|
|
// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as
|
|
// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this
|
|
// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
)
|
|
|
|
// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry.
|
|
var KeyTypes = map[string]bool{
|
|
"ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true,
|
|
"ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true,
|
|
"sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true,
|
|
}
|
|
|
|
// PublicKey is one public key as a line carries it.
|
|
type PublicKey struct {
|
|
Type string `json:"type"`
|
|
Bits int `json:"bits"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
Comment string `json:"comment"`
|
|
Options string `json:"options,omitempty"`
|
|
Weak string `json:"weak,omitempty"`
|
|
}
|
|
|
|
// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's.
|
|
func ParseKeyLine(line string) (PublicKey, error) {
|
|
fields := fieldsQuoted(strings.TrimSpace(line))
|
|
for i, f := range fields {
|
|
if !KeyTypes[f] || i+1 >= len(fields) {
|
|
continue
|
|
}
|
|
k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")}
|
|
blob, err := base64.StdEncoding.DecodeString(fields[i+1])
|
|
if err != nil {
|
|
return k, fmt.Errorf("the key after %s is not base64", f)
|
|
}
|
|
sum := sha256.Sum256(blob)
|
|
k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
|
|
k.Bits = bitsOf(f, blob)
|
|
switch {
|
|
case f == "ssh-dss":
|
|
k.Weak = "DSA: refused by current OpenSSH"
|
|
case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072:
|
|
k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits)
|
|
}
|
|
return k, nil
|
|
}
|
|
return PublicKey{}, fmt.Errorf("no public key on this line")
|
|
}
|
|
|
|
// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field.
|
|
func fieldsQuoted(s string) []string {
|
|
var out []string
|
|
var cur strings.Builder
|
|
quoted := false
|
|
for _, ch := range s {
|
|
switch {
|
|
case ch == '"':
|
|
quoted = !quoted
|
|
cur.WriteRune(ch)
|
|
case (ch == ' ' || ch == '\t') && !quoted:
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
default:
|
|
cur.WriteRune(ch)
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256.
|
|
func bitsOf(kind string, blob []byte) int {
|
|
strs := [][]byte{}
|
|
for len(blob) >= 4 {
|
|
n := binary.BigEndian.Uint32(blob)
|
|
if int(n) > len(blob)-4 {
|
|
break
|
|
}
|
|
strs = append(strs, blob[4:4+n])
|
|
blob = blob[4+n:]
|
|
}
|
|
switch {
|
|
case strings.Contains(kind, "ed25519"):
|
|
return 256
|
|
case kind == "ssh-rsa" && len(strs) >= 3:
|
|
return new(big.Int).SetBytes(strs[2]).BitLen()
|
|
case kind == "ssh-dss" && len(strs) >= 2:
|
|
return new(big.Int).SetBytes(strs[1]).BitLen()
|
|
case strings.Contains(kind, "nistp256"):
|
|
return 256
|
|
case strings.Contains(kind, "nistp384"):
|
|
return 384
|
|
case strings.Contains(kind, "nistp521"):
|
|
return 521
|
|
}
|
|
return 0
|
|
}
|