ssh: enforce user presence verification for security keys
Previously the library did not verify the "User Presence" (UP) bit in signatures generated by FIDO/U2F security keys (sk-ecdsa-sha2-nistp256@openssh.com and sk-ssh-ed25519@openssh.com). This allowed signatures without physical interaction to be accepted if the underlying hardware produced them, deviating from the default secure behavior expected by the FIDO standards and OpenSSH. skECDSAPublicKey.Verify and skEd25519PublicKey.Verify now enforce the user-presence bit (0x01, constant flagUserPresence) by default. Signatures whose flags byte has UP clear fail with the sentinel errSKMissingUserPresence. The server public-key authentication path honors the OpenSSH "no-touch-required" extension as an opt-out. noTouchAllowed reports true when the extension is present either in the Permissions returned by PublicKeyCallback (authorized_keys-level opt-out) or in the certificate's own Extensions (CA-level opt-out); in that case skKeyWithoutUP is used to derive a clone of the SK public key (and, for certificates, a clone of the wrapping Certificate whose inner Key is the cloned SK key) whose Verify accepts UP-clear signatures. The originals are never mutated, so a per-session opt-out cannot leak across authentication attempts or connections. Matching OpenSSH, the opt-out is read only from Extensions, never from CriticalOptions. skKeyWithoutUP is iterative and unwraps at most one level of *Certificate: the SSH cert format forbids Certificate.Key from being another Certificate (parseCert rejects it) but callers can still construct such a value directly in Go, so a recursive descent would be driven to unbounded depth by malformed or cyclic input. Any such pathological *Certificate is returned unchanged. CertChecker.CheckCert applies skKeyWithoutUP unconditionally to the certificate's CA key before verifying the CA signature, matching OpenSSH, which calls sshkey_verify with detailsp==NULL in sshkey.c:cert_parse and never extracts or enforces UP/UV flags on CA signatures. The UP bit on a CA signature reflects the CA operator's presence at cert-issuance time, which has no bearing on whether the user being authenticated is present now, so enforcing it here would only break interop with certificates issued by non-interactive SK CAs without a corresponding security benefit. The skKeyWithoutUP call is a no-op for non-SK CA keys (the common case). This change breaks backward compatibility for clients or keys that generate user-authentication signatures without the User Presence flag set. Previously those signatures were accepted by the server. They will now be rejected with "ssh: signature missing required user presence flag" unless the "no-touch-required" extension is explicitly granted to the session by the server callbacks, or carried by the user certificate. This issue was found during a security audit by NCC Group Cryptography Services, sponsored by Teleport. Fixes golang/go#79566 Fixes CVE-2026-39831 Change-Id: I74b6de3bb6a2d7a0f34d7fa36bbbbf06f0b3fc6b Reviewed-on: https://go-review.googlesource.com/c/crypto/+/781662 Reviewed-by: Neal Patel <nealpatel@google.com> Reviewed-by: Roland Shoemaker <roland@golang.org> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
committed by
Neal Patel
parent
9c2cd33e8d
commit
b61cf853a8
+11
-1
@@ -444,7 +444,17 @@ func (c *CertChecker) CheckCert(principal string, cert *Certificate) error {
|
||||
if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(CertTimeInfinity) && (unixNow >= before || before < 0) {
|
||||
return fmt.Errorf("ssh: cert has expired")
|
||||
}
|
||||
if err := cert.SignatureKey.Verify(cert.bytesForSigning(), cert.Signature); err != nil {
|
||||
// Match OpenSSH: the SK user-presence flag is never enforced on a
|
||||
// certificate's CA signature. OpenSSH calls sshkey_verify with
|
||||
// detailsp==NULL in sshkey.c:cert_parse, so the UP/UV flags are
|
||||
// not even extracted. The UP bit on a CA signature reflects the
|
||||
// CA operator's presence at signing time, which has no bearing on
|
||||
// whether the user being authenticated is present now; enforcing
|
||||
// it here would only break interop with certificates issued by
|
||||
// non-interactive SK CAs. skKeyWithoutUP is a no-op for non-SK
|
||||
// keys (the common case).
|
||||
caKey := skKeyWithoutUP(cert.SignatureKey)
|
||||
if err := caKey.Verify(cert.bytesForSigning(), cert.Signature); err != nil {
|
||||
return fmt.Errorf("ssh: certificate signature does not verify")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -128,6 +130,103 @@ func TestValidateCert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// signSKCert builds an SK-ECDSA signature over cert.bytesForSigning()
|
||||
// using caKey as the simulated hardware token. The SK signing flags
|
||||
// byte (UP bit and others) is caller-controlled so tests can exercise
|
||||
// the user-presence enforcement paths. caApp is the SK application
|
||||
// string (typically "ssh:").
|
||||
func signSKCert(t *testing.T, cert *Certificate, caKey *ecdsa.PrivateKey, caApp string, flags byte) {
|
||||
t.Helper()
|
||||
cert.SignatureKey = &skECDSAPublicKey{
|
||||
application: caApp,
|
||||
PublicKey: caKey.PublicKey,
|
||||
}
|
||||
cert.Nonce = make([]byte, 32)
|
||||
if _, err := rand.Read(cert.Nonce); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(caApp))
|
||||
appDigest := h.Sum(nil)
|
||||
h.Reset()
|
||||
h.Write(cert.bytesForSigning())
|
||||
dataDigest := h.Sum(nil)
|
||||
|
||||
var counter uint32 = 1
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
Counter uint32
|
||||
MessageDigest []byte `ssh:"rest"`
|
||||
}{appDigest, flags, counter, dataDigest}
|
||||
h.Reset()
|
||||
h.Write(Marshal(blob))
|
||||
digest := h.Sum(nil)
|
||||
|
||||
r, s, err := ecdsa.Sign(rand.Reader, caKey, digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert.Signature = &Signature{
|
||||
Format: KeyAlgoSKECDSA256,
|
||||
Blob: Marshal(struct{ R, S *big.Int }{r, s}),
|
||||
Rest: Marshal(struct {
|
||||
Flags byte
|
||||
Counter uint32
|
||||
}{flags, counter}),
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCertSKAuthorityNoUPRequired pins the OpenSSH-parity
|
||||
// behavior of CertChecker.CheckCert for SK CA signatures: a
|
||||
// certificate signed by an SK CA that produced a UP=0 signature is
|
||||
// accepted, because the UP bit on a CA signature has no bearing on
|
||||
// the current user authentication (see sshkey.c:cert_parse, which
|
||||
// passes detailsp==NULL to sshkey_verify). Without this, certs issued
|
||||
// by non-interactive SK CAs would fail to verify.
|
||||
func TestCheckCertSKAuthorityNoUPRequired(t *testing.T) {
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userPub, err := NewPublicKey(&userKey.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mkCert := func(flags byte) *Certificate {
|
||||
c := &Certificate{
|
||||
CertType: UserCert,
|
||||
Key: userPub,
|
||||
ValidBefore: CertTimeInfinity,
|
||||
ValidPrincipals: []string{"user"},
|
||||
}
|
||||
signSKCert(t, c, caKey, "ssh:", flags)
|
||||
return c
|
||||
}
|
||||
|
||||
checker := CertChecker{
|
||||
IsUserAuthority: func(k PublicKey) bool {
|
||||
caSK := &skECDSAPublicKey{application: "ssh:", PublicKey: caKey.PublicKey}
|
||||
return bytes.Equal(k.Marshal(), caSK.Marshal())
|
||||
},
|
||||
}
|
||||
|
||||
// Both UP=0 and UP=1 CA signatures verify: UP is not enforced
|
||||
// here, matching OpenSSH.
|
||||
if err := checker.CheckCert("user", mkCert(0)); err != nil {
|
||||
t.Errorf("UP=0 CA signature must verify (OpenSSH parity): %v", err)
|
||||
}
|
||||
if err := checker.CheckCert("user", mkCert(flagUserPresence)); err != nil {
|
||||
t.Errorf("UP=1 CA signature must verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertTime(t *testing.T) {
|
||||
cert := Certificate{
|
||||
ValidPrincipals: []string{"user"},
|
||||
|
||||
+26
@@ -901,11 +901,25 @@ type skFields struct {
|
||||
Counter uint32
|
||||
}
|
||||
|
||||
// flagUserPresence is the "user present" bit (UP) in the SK signature
|
||||
// flags, matching the FIDO CTAP2 authenticatorData UP flag. See
|
||||
// openssh/PROTOCOL.u2f.
|
||||
const flagUserPresence = 0x01
|
||||
|
||||
// errSKMissingUserPresence is returned by SK key Verify methods when
|
||||
// the signature does not assert user presence and the key was not
|
||||
// marked as no-touch-required.
|
||||
var errSKMissingUserPresence = errors.New("ssh: signature missing required user presence flag")
|
||||
|
||||
type skECDSAPublicKey struct {
|
||||
// application is a URL-like string, typically "ssh:" for SSH.
|
||||
// see openssh/PROTOCOL.u2f for details.
|
||||
application string
|
||||
ecdsa.PublicKey
|
||||
// noTouchRequired, when true, disables the default user-presence
|
||||
// check in Verify. It is set by skKeyWithoutUP on a clone of the
|
||||
// key, never on an instance shared across authentication attempts.
|
||||
noTouchRequired bool
|
||||
}
|
||||
|
||||
func (k *skECDSAPublicKey) Type() string {
|
||||
@@ -991,6 +1005,10 @@ func (k *skECDSAPublicKey) Verify(data []byte, sig *Signature) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if skf.Flags&flagUserPresence == 0 && !k.noTouchRequired {
|
||||
return errSKMissingUserPresence
|
||||
}
|
||||
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
@@ -1024,6 +1042,10 @@ type skEd25519PublicKey struct {
|
||||
// see openssh/PROTOCOL.u2f for details.
|
||||
application string
|
||||
ed25519.PublicKey
|
||||
// noTouchRequired, when true, disables the default user-presence
|
||||
// check in Verify. It is set by skKeyWithoutUP on a clone of the
|
||||
// key, never on an instance shared across authentication attempts.
|
||||
noTouchRequired bool
|
||||
}
|
||||
|
||||
func (k *skEd25519PublicKey) Type() string {
|
||||
@@ -1098,6 +1120,10 @@ func (k *skEd25519PublicKey) Verify(data []byte, sig *Signature) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if skf.Flags&flagUserPresence == 0 && !k.noTouchRequired {
|
||||
return errSKMissingUserPresence
|
||||
}
|
||||
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -841,6 +842,296 @@ func TestSKKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// skTestHarness builds SK-formatted signatures over a fixed payload
|
||||
// using a caller-supplied signing function, letting tests vary the UP
|
||||
// flag byte without duplicating the wire-format scaffolding.
|
||||
type skTestHarness struct {
|
||||
format string
|
||||
application string
|
||||
data []byte
|
||||
// sign takes the SHA-256 digest of the marshalled SK blob and
|
||||
// returns the value to embed in Signature.Blob. For ECDSA keys the
|
||||
// helper feeds the digest to ecdsa.Sign; for ed25519 the helper
|
||||
// feeds the raw marshalled blob to ed25519.Sign.
|
||||
signDigest func(digest []byte) []byte
|
||||
signBlob func(blob []byte) []byte
|
||||
}
|
||||
|
||||
func (h skTestHarness) sign(t *testing.T, flags byte) *Signature {
|
||||
t.Helper()
|
||||
hsh := sha256.New()
|
||||
hsh.Write([]byte(h.application))
|
||||
appDigest := hsh.Sum(nil)
|
||||
|
||||
hsh.Reset()
|
||||
hsh.Write(h.data)
|
||||
dataDigest := hsh.Sum(nil)
|
||||
|
||||
var counter uint32 = 1
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
Counter uint32
|
||||
MessageDigest []byte `ssh:"rest"`
|
||||
}{appDigest, flags, counter, dataDigest}
|
||||
marshalled := Marshal(blob)
|
||||
|
||||
var sigBlob []byte
|
||||
if h.signDigest != nil {
|
||||
hsh.Reset()
|
||||
hsh.Write(marshalled)
|
||||
sigBlob = h.signDigest(hsh.Sum(nil))
|
||||
} else {
|
||||
sigBlob = h.signBlob(marshalled)
|
||||
}
|
||||
|
||||
return &Signature{
|
||||
Format: h.format,
|
||||
Blob: sigBlob,
|
||||
Rest: Marshal(struct {
|
||||
Flags byte
|
||||
Counter uint32
|
||||
}{flags, counter}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUserPresence(t *testing.T) {
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := skTestHarness{
|
||||
format: "sk-ecdsa-sha2-nistp256@openssh.com",
|
||||
application: "ssh:",
|
||||
data: []byte("test data"),
|
||||
signDigest: func(digest []byte) []byte {
|
||||
r, s, err := ecdsa.Sign(rand.Reader, ecKey, digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Marshal(struct{ R, S *big.Int }{r, s})
|
||||
},
|
||||
}
|
||||
|
||||
pk := &skECDSAPublicKey{
|
||||
application: h.application,
|
||||
PublicKey: ecKey.PublicKey,
|
||||
}
|
||||
|
||||
// Valid signature with UP=1 should pass.
|
||||
if err := pk.Verify(h.data, h.sign(t, flagUserPresence)); err != nil {
|
||||
t.Errorf("Verify failed with UP=1: %v", err)
|
||||
}
|
||||
|
||||
// Valid signature with UP=0 should fail with the user-presence sentinel.
|
||||
sigNoUP := h.sign(t, 0)
|
||||
if err := pk.Verify(h.data, sigNoUP); !errors.Is(err, errSKMissingUserPresence) {
|
||||
t.Errorf("expected errSKMissingUserPresence, got: %v", err)
|
||||
}
|
||||
|
||||
// UV set but UP clear must still fail: we only waive UP, never UV-only.
|
||||
if err := pk.Verify(h.data, h.sign(t, 0x04)); !errors.Is(err, errSKMissingUserPresence) {
|
||||
t.Errorf("UV-only (flags=0x04): expected errSKMissingUserPresence, got: %v", err)
|
||||
}
|
||||
|
||||
// With noTouchRequired, UP=0 passes; UP=1+UV=1 also passes.
|
||||
pk.noTouchRequired = true
|
||||
if err := pk.Verify(h.data, sigNoUP); err != nil {
|
||||
t.Errorf("Verify with noTouchRequired failed: %v", err)
|
||||
}
|
||||
if err := pk.Verify(h.data, h.sign(t, flagUserPresence|0x04)); err != nil {
|
||||
t.Errorf("Verify UP|UV with noTouchRequired failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKKeyWithoutUP(t *testing.T) {
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edPub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skEC := &skECDSAPublicKey{PublicKey: ecKey.PublicKey}
|
||||
skED := &skEd25519PublicKey{PublicKey: edPub}
|
||||
certEC := &Certificate{Key: &skECDSAPublicKey{PublicKey: ecKey.PublicKey}}
|
||||
certED := &Certificate{Key: &skEd25519PublicKey{PublicKey: edPub}}
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rsaPub, err := NewPublicKey(&rsaKey.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certRSA := &Certificate{Key: rsaPub}
|
||||
|
||||
// SK raw keys: return a clone with the flag set, original untouched.
|
||||
gotEC := skKeyWithoutUP(skEC)
|
||||
if gotEC == skEC {
|
||||
t.Error("skECDSAPublicKey: expected a clone, got the original pointer")
|
||||
}
|
||||
if !gotEC.(*skECDSAPublicKey).noTouchRequired {
|
||||
t.Error("skECDSAPublicKey clone: noTouchRequired not set")
|
||||
}
|
||||
if skEC.noTouchRequired {
|
||||
t.Error("skECDSAPublicKey: original mutated")
|
||||
}
|
||||
|
||||
gotED := skKeyWithoutUP(skED)
|
||||
if gotED == skED {
|
||||
t.Error("skEd25519PublicKey: expected a clone, got the original pointer")
|
||||
}
|
||||
if !gotED.(*skEd25519PublicKey).noTouchRequired {
|
||||
t.Error("skEd25519PublicKey clone: noTouchRequired not set")
|
||||
}
|
||||
if skED.noTouchRequired {
|
||||
t.Error("skEd25519PublicKey: original mutated")
|
||||
}
|
||||
|
||||
// Certificate wrapping SK: return a clone of the cert with a cloned
|
||||
// SK key inside. Neither the original cert nor the original inner
|
||||
// key must be mutated.
|
||||
originalInnerEC := certEC.Key
|
||||
gotCertEC := skKeyWithoutUP(certEC)
|
||||
if gotCertEC == certEC {
|
||||
t.Error("*Certificate(SK ecdsa): expected a clone, got the original pointer")
|
||||
}
|
||||
if got := gotCertEC.(*Certificate).Key.(*skECDSAPublicKey); !got.noTouchRequired {
|
||||
t.Error("*Certificate(SK ecdsa): inner clone missing noTouchRequired")
|
||||
}
|
||||
if certEC.Key != originalInnerEC {
|
||||
t.Error("*Certificate(SK ecdsa): original cert's Key pointer mutated")
|
||||
}
|
||||
if originalInnerEC.(*skECDSAPublicKey).noTouchRequired {
|
||||
t.Error("*Certificate(SK ecdsa): original inner key mutated")
|
||||
}
|
||||
|
||||
gotCertED := skKeyWithoutUP(certED)
|
||||
if gotCertED == certED {
|
||||
t.Error("*Certificate(SK ed25519): expected a clone, got the original pointer")
|
||||
}
|
||||
if got := gotCertED.(*Certificate).Key.(*skEd25519PublicKey); !got.noTouchRequired {
|
||||
t.Error("*Certificate(SK ed25519): inner clone missing noTouchRequired")
|
||||
}
|
||||
|
||||
// Non-SK key inside a cert: return original unchanged (nothing to clone).
|
||||
if got := skKeyWithoutUP(certRSA); got != certRSA {
|
||||
t.Error("*Certificate(RSA): expected the original pointer back")
|
||||
}
|
||||
|
||||
// Plain non-SK key: return original unchanged.
|
||||
if got := skKeyWithoutUP(rsaPub); got != rsaPub {
|
||||
t.Error("rsaPublicKey: expected the original pointer back")
|
||||
}
|
||||
|
||||
// Pathological: *Certificate whose Key is itself a *Certificate.
|
||||
// The SSH cert format forbids this and parseCert rejects it, but
|
||||
// a Go caller can still construct such a value. skKeyWithoutUP
|
||||
// must not recurse into it (or panic); it returns the input
|
||||
// unchanged. This also defends against a hypothetical cycle built
|
||||
// from hand-constructed Certificate pointers.
|
||||
nestedCert := &Certificate{Key: &Certificate{Key: skEC}}
|
||||
if got := skKeyWithoutUP(nestedCert); got != nestedCert {
|
||||
t.Error("*Certificate wrapping *Certificate: expected the original pointer back")
|
||||
}
|
||||
selfCycle := &Certificate{}
|
||||
selfCycle.Key = selfCycle
|
||||
if got := skKeyWithoutUP(selfCycle); got != selfCycle {
|
||||
t.Error("self-referential *Certificate: expected the original pointer back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTouchAllowed(t *testing.T) {
|
||||
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sk := &skECDSAPublicKey{PublicKey: ecKey.PublicKey}
|
||||
certNoExt := &Certificate{Key: sk}
|
||||
certOptOut := &Certificate{
|
||||
Key: sk,
|
||||
Permissions: Permissions{Extensions: map[string]string{"no-touch-required": ""}},
|
||||
}
|
||||
// Non-empty value: OpenSSH writes "" but any value must be accepted
|
||||
// because the check is presence-only.
|
||||
certOptOutNonEmpty := &Certificate{
|
||||
Key: sk,
|
||||
Permissions: Permissions{Extensions: map[string]string{"no-touch-required": "yes"}},
|
||||
}
|
||||
// no-touch-required belongs in Extensions, never CriticalOptions.
|
||||
// Putting it in CriticalOptions must NOT be treated as opt-out.
|
||||
certCritOnly := &Certificate{
|
||||
Key: sk,
|
||||
Permissions: Permissions{CriticalOptions: map[string]string{"no-touch-required": ""}},
|
||||
}
|
||||
|
||||
permsEmpty := &Permissions{}
|
||||
permsOptOut := &Permissions{Extensions: map[string]string{"no-touch-required": ""}}
|
||||
permsCritOnly := &Permissions{CriticalOptions: map[string]string{"no-touch-required": ""}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pub PublicKey
|
||||
perms *Permissions
|
||||
want bool
|
||||
}{
|
||||
{"nil perms, no cert", sk, nil, false},
|
||||
{"empty perms, no cert", sk, permsEmpty, false},
|
||||
{"perms opt-out, raw key", sk, permsOptOut, true},
|
||||
{"nil perms, cert opt-out", certOptOut, nil, true},
|
||||
{"nil perms, cert opt-out non-empty value", certOptOutNonEmpty, nil, true},
|
||||
{"nil perms, cert no ext", certNoExt, nil, false},
|
||||
{"perms opt-out, cert no ext", certNoExt, permsOptOut, true},
|
||||
{"empty perms, cert opt-out", certOptOut, permsEmpty, true},
|
||||
// Negative controls: CriticalOptions must not waive UP.
|
||||
{"critical-options only, raw key", sk, permsCritOnly, false},
|
||||
{"critical-options only, cert", certCritOnly, nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := noTouchAllowed(tc.pub, tc.perms); got != tc.want {
|
||||
t.Errorf("noTouchAllowed = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUserPresenceEd25519(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := skTestHarness{
|
||||
format: "sk-ssh-ed25519@openssh.com",
|
||||
application: "ssh:",
|
||||
data: []byte("test data"),
|
||||
signBlob: func(blob []byte) []byte {
|
||||
return ed25519.Sign(priv, blob)
|
||||
},
|
||||
}
|
||||
|
||||
pk := &skEd25519PublicKey{
|
||||
application: h.application,
|
||||
PublicKey: pub,
|
||||
}
|
||||
|
||||
if err := pk.Verify(h.data, h.sign(t, flagUserPresence)); err != nil {
|
||||
t.Errorf("Verify failed with UP=1: %v", err)
|
||||
}
|
||||
sigNoUP := h.sign(t, 0)
|
||||
if err := pk.Verify(h.data, sigNoUP); !errors.Is(err, errSKMissingUserPresence) {
|
||||
t.Errorf("expected errSKMissingUserPresence, got: %v", err)
|
||||
}
|
||||
pk.noTouchRequired = true
|
||||
if err := pk.Verify(h.data, sigNoUP); err != nil {
|
||||
t.Errorf("Verify with noTouchRequired failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSignerWithAlgos(t *testing.T) {
|
||||
algorithSigner, ok := testSigners["rsa"].(AlgorithmSigner)
|
||||
if !ok {
|
||||
|
||||
+96
-11
@@ -34,15 +34,20 @@ type Permissions struct {
|
||||
// or not supported.
|
||||
CriticalOptions map[string]string
|
||||
|
||||
// Extensions are extra functionality that the server may
|
||||
// offer on authenticated connections. Lack of support for an
|
||||
// extension does not preclude authenticating a user. Common
|
||||
// extensions are "permit-agent-forwarding",
|
||||
// "permit-X11-forwarding". The Go SSH library currently does
|
||||
// not act on any extension, and it is up to server
|
||||
// implementations to honor them. Extensions can be used to
|
||||
// pass data from the authentication callbacks to the server
|
||||
// application layer.
|
||||
// Extensions are extra functionality that the server may offer on
|
||||
// authenticated connections. Lack of support for an extension does not
|
||||
// preclude authenticating a user. Common extensions are
|
||||
// "permit-agent-forwarding", "permit-X11-forwarding". In general the Go
|
||||
// SSH library does not act on extensions and it is up to server
|
||||
// implementations to honor them; extensions can also be used to pass data
|
||||
// from the authentication callbacks to the server application layer.
|
||||
//
|
||||
// The one extension acted upon by this library is "no-touch-required",
|
||||
// which applies only to security-key public keys
|
||||
// (sk-ecdsa-sha2-nistp256@openssh.com and sk-ssh-ed25519@openssh.com).
|
||||
// When present, it waives the default requirement that SK signatures
|
||||
// assert user presence (i.e. a physical touch of the authenticator)
|
||||
// during signature verification.
|
||||
Extensions map[string]string
|
||||
|
||||
// ExtraData allows to store user defined data.
|
||||
@@ -84,6 +89,79 @@ type ServerPreAuthConn interface {
|
||||
SendAuthBanner(string) error
|
||||
}
|
||||
|
||||
// noTouchRequiredExtension is the extension name used by OpenSSH in
|
||||
// authorized_keys options and certificate extensions to mark keys
|
||||
// whose signatures do not need to assert user presence (touch). See
|
||||
// ssh-keygen(1) and sshd(8).
|
||||
const noTouchRequiredExtension = "no-touch-required"
|
||||
|
||||
// noTouchAllowed reports whether the user presence requirement on
|
||||
// SK signatures should be waived for this authentication attempt. The
|
||||
// requirement is waived when the "no-touch-required" extension is
|
||||
// present either in the Permissions returned by the auth callback
|
||||
// (authorized_keys-level opt-out) or in the certificate's own
|
||||
// Extensions (CA-level opt-out), matching OpenSSH behavior. OpenSSH
|
||||
// reads the per-key opt-out only from cert Extensions and
|
||||
// authorized_keys options (never from CriticalOptions); we follow the
|
||||
// same rule.
|
||||
func noTouchAllowed(pubKey PublicKey, perms *Permissions) bool {
|
||||
if perms != nil {
|
||||
if _, ok := perms.Extensions[noTouchRequiredExtension]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if cert, ok := pubKey.(*Certificate); ok {
|
||||
if _, ok := cert.Extensions[noTouchRequiredExtension]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// skKeyWithoutUP returns a PublicKey equivalent to pubKey but whose
|
||||
// Verify accepts SK signatures with the user-presence flag clear. If
|
||||
// pubKey is not (and does not wrap) an SK key, pubKey is returned
|
||||
// unchanged. The returned value never mutates pubKey: for SK keys a
|
||||
// shallow copy is made so that the noTouchRequired flag is set only on
|
||||
// the clone.
|
||||
//
|
||||
// The implementation is iterative rather than recursive. When pubKey
|
||||
// is a *Certificate we unwrap exactly one level to look at the inner
|
||||
// key. The SSH cert format forbids Certificate.Key from being another
|
||||
// Certificate (parseCert rejects it), but nothing stops callers from
|
||||
// constructing such a value directly in Go; a recursive descent could
|
||||
// otherwise be driven to unbounded depth by a hand-crafted or cyclic
|
||||
// Certificate. A malformed input of that shape simply returns
|
||||
// unchanged here.
|
||||
func skKeyWithoutUP(pubKey PublicKey) PublicKey {
|
||||
cert, isCert := pubKey.(*Certificate)
|
||||
target := pubKey
|
||||
if isCert {
|
||||
target = cert.Key
|
||||
}
|
||||
var cloned PublicKey
|
||||
switch k := target.(type) {
|
||||
case *skECDSAPublicKey:
|
||||
c := *k
|
||||
c.noTouchRequired = true
|
||||
cloned = &c
|
||||
case *skEd25519PublicKey:
|
||||
c := *k
|
||||
c.noTouchRequired = true
|
||||
cloned = &c
|
||||
default:
|
||||
// Not an SK key (or a pathological *Certificate wrapping
|
||||
// another *Certificate): pubKey is already usable for Verify.
|
||||
return pubKey
|
||||
}
|
||||
if !isCert {
|
||||
return cloned
|
||||
}
|
||||
c := *cert
|
||||
c.Key = cloned
|
||||
return &c
|
||||
}
|
||||
|
||||
// ServerConfig holds server specific configuration data.
|
||||
type ServerConfig struct {
|
||||
// Config contains configuration shared between client and server.
|
||||
@@ -739,8 +817,15 @@ userAuthLoop:
|
||||
}
|
||||
|
||||
signedData := buildDataSignedForAuth(sessionID, userAuthReq, algo, pubKeyData)
|
||||
|
||||
if err := pubKey.Verify(signedData, sig); err != nil {
|
||||
// pubKey is reused below for VerifiedPublicKeyCallback and
|
||||
// must remain the key as presented by the client; derive a
|
||||
// separate value for Verify that carries any applicable
|
||||
// no-touch-required opt-out.
|
||||
pubKeyForVerify := pubKey
|
||||
if noTouchAllowed(pubKey, candidate.perms) {
|
||||
pubKeyForVerify = skKeyWithoutUP(pubKey)
|
||||
}
|
||||
if err := pubKeyForVerify.Verify(signedData, sig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -904,3 +909,126 @@ func (*markerConn) RemoteAddr() net.Addr { return nil }
|
||||
func (*markerConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (*markerConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (*markerConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// skTestSigner is a Signer that produces SK-ECDSA signatures over
|
||||
// user-auth data, simulating a FIDO/U2F authenticator. The flags
|
||||
// byte (UP and other bits) is caller-controlled so tests can exercise
|
||||
// the server's user-presence enforcement and its opt-out paths. This
|
||||
// is test-only: the real Signer is hardware-backed.
|
||||
type skTestSigner struct {
|
||||
priv *ecdsa.PrivateKey
|
||||
pub PublicKey
|
||||
flags byte
|
||||
application string
|
||||
}
|
||||
|
||||
func (s *skTestSigner) PublicKey() PublicKey { return s.pub }
|
||||
|
||||
func (s *skTestSigner) Sign(r io.Reader, data []byte) (*Signature, error) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(s.application))
|
||||
appDigest := h.Sum(nil)
|
||||
h.Reset()
|
||||
h.Write(data)
|
||||
dataDigest := h.Sum(nil)
|
||||
var counter uint32 = 1
|
||||
blob := struct {
|
||||
ApplicationDigest []byte `ssh:"rest"`
|
||||
Flags byte
|
||||
Counter uint32
|
||||
MessageDigest []byte `ssh:"rest"`
|
||||
}{appDigest, s.flags, counter, dataDigest}
|
||||
h.Reset()
|
||||
h.Write(Marshal(blob))
|
||||
digest := h.Sum(nil)
|
||||
x, y, err := ecdsa.Sign(r, s.priv, digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signature{
|
||||
Format: KeyAlgoSKECDSA256,
|
||||
Blob: Marshal(struct{ R, S *big.Int }{x, y}),
|
||||
Rest: Marshal(struct {
|
||||
Flags byte
|
||||
Counter uint32
|
||||
}{s.flags, counter}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestServerAuthSKUserPresence drives the full userAuthLoop with an SK
|
||||
// public-key client and verifies the server's wiring of the UP check
|
||||
// and its two opt-out paths: the per-key Permissions.Extensions route
|
||||
// and (via cert) the cert-level route. It also confirms that non-SK
|
||||
// clients are unaffected by the new code path.
|
||||
func TestServerAuthSKUserPresence(t *testing.T) {
|
||||
userKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skPub := &skECDSAPublicKey{application: "ssh:", PublicKey: userKey.PublicKey}
|
||||
|
||||
runAuth := func(t *testing.T, signer Signer, perms *Permissions) error {
|
||||
t.Helper()
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(ConnMetadata, PublicKey) (*Permissions, error) {
|
||||
return perms, nil
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["ecdsa"])
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, _, err := NewServerConn(c1, serverConf)
|
||||
serverErr <- err
|
||||
}()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{PublicKeys(signer)},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
_, _, _, clientErr := NewClientConn(c2, "", clientConf)
|
||||
<-serverErr
|
||||
return clientErr
|
||||
}
|
||||
|
||||
optOut := &Permissions{Extensions: map[string]string{noTouchRequiredExtension: ""}}
|
||||
|
||||
t.Run("UP=1, default perms accepts", func(t *testing.T) {
|
||||
s := &skTestSigner{priv: userKey, pub: skPub, flags: flagUserPresence, application: "ssh:"}
|
||||
if err := runAuth(t, s, nil); err != nil {
|
||||
t.Errorf("expected auth to succeed: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("UP=0, default perms rejects", func(t *testing.T) {
|
||||
s := &skTestSigner{priv: userKey, pub: skPub, flags: 0, application: "ssh:"}
|
||||
if err := runAuth(t, s, nil); err == nil {
|
||||
t.Error("expected auth to fail with UP=0")
|
||||
}
|
||||
})
|
||||
t.Run("UP=0, perms opt-out accepts", func(t *testing.T) {
|
||||
s := &skTestSigner{priv: userKey, pub: skPub, flags: 0, application: "ssh:"}
|
||||
if err := runAuth(t, s, optOut); err != nil {
|
||||
t.Errorf("expected auth to succeed with opt-out: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("UP=0, perms CriticalOptions does NOT opt out", func(t *testing.T) {
|
||||
s := &skTestSigner{priv: userKey, pub: skPub, flags: 0, application: "ssh:"}
|
||||
critOnly := &Permissions{CriticalOptions: map[string]string{noTouchRequiredExtension: ""}}
|
||||
if err := runAuth(t, s, critOnly); err == nil {
|
||||
t.Error("no-touch-required in CriticalOptions must not waive UP")
|
||||
}
|
||||
})
|
||||
t.Run("non-SK RSA signer unaffected", func(t *testing.T) {
|
||||
if err := runAuth(t, testSigners["rsa"], nil); err != nil {
|
||||
t.Errorf("plain RSA auth must still work: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user