diff --git a/agent/yubiagent/generate_test.go b/agent/yubiagent/generate_test.go index ab708d4..f5575a2 100644 --- a/agent/yubiagent/generate_test.go +++ b/agent/yubiagent/generate_test.go @@ -4,6 +4,8 @@ package yubiagent //go:generate mockgen -destination=./mock_shimagent_test.go -package=yubiagent github.com/theparanoids/ysshra/agent/shimagent ShimAgent +//go:generate mkdir -p ./mock +//go:generate mockgen -destination=./mock/yubiagent.go -package=mock github.com/theparanoids/ysshra/agent/yubiagent YubiAgent // ####################################################### // Following commands generates testdata for client_test.go. diff --git a/agent/yubiagent/mock/yubiagent.go b/agent/yubiagent/mock/yubiagent.go new file mode 100644 index 0000000..3961125 --- /dev/null +++ b/agent/yubiagent/mock/yubiagent.go @@ -0,0 +1,313 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/theparanoids/ysshra/agent/yubiagent (interfaces: YubiAgent) + +// Package mock is a generated GoMock package. +package mock + +import ( + x509 "crypto/x509" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + ssh "golang.org/x/crypto/ssh" + agent "golang.org/x/crypto/ssh/agent" +) + +// MockYubiAgent is a mock of YubiAgent interface. +type MockYubiAgent struct { + ctrl *gomock.Controller + recorder *MockYubiAgentMockRecorder +} + +// MockYubiAgentMockRecorder is the mock recorder for MockYubiAgent. +type MockYubiAgentMockRecorder struct { + mock *MockYubiAgent +} + +// NewMockYubiAgent creates a new mock instance. +func NewMockYubiAgent(ctrl *gomock.Controller) *MockYubiAgent { + mock := &MockYubiAgent{ctrl: ctrl} + mock.recorder = &MockYubiAgentMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockYubiAgent) EXPECT() *MockYubiAgentMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockYubiAgent) Add(arg0 agent.AddedKey) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockYubiAgentMockRecorder) Add(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockYubiAgent)(nil).Add), arg0) +} + +// AddHardCert mocks base method. +func (m *MockYubiAgent) AddHardCert(arg0 ssh.PublicKey, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddHardCert", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddHardCert indicates an expected call of AddHardCert. +func (mr *MockYubiAgentMockRecorder) AddHardCert(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddHardCert", reflect.TypeOf((*MockYubiAgent)(nil).AddHardCert), arg0, arg1) +} + +// AddSmartcardKey mocks base method. +func (m *MockYubiAgent) AddSmartcardKey(arg0 string, arg1 []byte, arg2 time.Duration, arg3 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddSmartcardKey", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddSmartcardKey indicates an expected call of AddSmartcardKey. +func (mr *MockYubiAgentMockRecorder) AddSmartcardKey(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSmartcardKey", reflect.TypeOf((*MockYubiAgent)(nil).AddSmartcardKey), arg0, arg1, arg2, arg3) +} + +// AttestSlot mocks base method. +func (m *MockYubiAgent) AttestSlot(arg0 string) (*x509.Certificate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AttestSlot", arg0) + ret0, _ := ret[0].(*x509.Certificate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AttestSlot indicates an expected call of AttestSlot. +func (mr *MockYubiAgentMockRecorder) AttestSlot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttestSlot", reflect.TypeOf((*MockYubiAgent)(nil).AttestSlot), arg0) +} + +// Close mocks base method. +func (m *MockYubiAgent) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockYubiAgentMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockYubiAgent)(nil).Close)) +} + +// Extension mocks base method. +func (m *MockYubiAgent) Extension(arg0 string, arg1 []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Extension", arg0, arg1) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Extension indicates an expected call of Extension. +func (mr *MockYubiAgentMockRecorder) Extension(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Extension", reflect.TypeOf((*MockYubiAgent)(nil).Extension), arg0, arg1) +} + +// Forward mocks base method. +func (m *MockYubiAgent) Forward(arg0 []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Forward", arg0) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Forward indicates an expected call of Forward. +func (mr *MockYubiAgentMockRecorder) Forward(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forward", reflect.TypeOf((*MockYubiAgent)(nil).Forward), arg0) +} + +// List mocks base method. +func (m *MockYubiAgent) List() ([]*agent.Key, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List") + ret0, _ := ret[0].([]*agent.Key) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockYubiAgentMockRecorder) List() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockYubiAgent)(nil).List)) +} + +// ListSlots mocks base method. +func (m *MockYubiAgent) ListSlots() ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSlots") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSlots indicates an expected call of ListSlots. +func (mr *MockYubiAgentMockRecorder) ListSlots() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSlots", reflect.TypeOf((*MockYubiAgent)(nil).ListSlots)) +} + +// Lock mocks base method. +func (m *MockYubiAgent) Lock(arg0 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lock", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Lock indicates an expected call of Lock. +func (mr *MockYubiAgentMockRecorder) Lock(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lock", reflect.TypeOf((*MockYubiAgent)(nil).Lock), arg0) +} + +// ReadSlot mocks base method. +func (m *MockYubiAgent) ReadSlot(arg0 string) (*x509.Certificate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadSlot", arg0) + ret0, _ := ret[0].(*x509.Certificate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadSlot indicates an expected call of ReadSlot. +func (mr *MockYubiAgentMockRecorder) ReadSlot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadSlot", reflect.TypeOf((*MockYubiAgent)(nil).ReadSlot), arg0) +} + +// Remove mocks base method. +func (m *MockYubiAgent) Remove(arg0 ssh.PublicKey) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockYubiAgentMockRecorder) Remove(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockYubiAgent)(nil).Remove), arg0) +} + +// RemoveAll mocks base method. +func (m *MockYubiAgent) RemoveAll() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveAll") + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveAll indicates an expected call of RemoveAll. +func (mr *MockYubiAgentMockRecorder) RemoveAll() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAll", reflect.TypeOf((*MockYubiAgent)(nil).RemoveAll)) +} + +// RemoveSmartcardKey mocks base method. +func (m *MockYubiAgent) RemoveSmartcardKey(arg0 string, arg1 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveSmartcardKey", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveSmartcardKey indicates an expected call of RemoveSmartcardKey. +func (mr *MockYubiAgentMockRecorder) RemoveSmartcardKey(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveSmartcardKey", reflect.TypeOf((*MockYubiAgent)(nil).RemoveSmartcardKey), arg0, arg1) +} + +// Sign mocks base method. +func (m *MockYubiAgent) Sign(arg0 ssh.PublicKey, arg1 []byte) (*ssh.Signature, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sign", arg0, arg1) + ret0, _ := ret[0].(*ssh.Signature) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Sign indicates an expected call of Sign. +func (mr *MockYubiAgentMockRecorder) Sign(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sign", reflect.TypeOf((*MockYubiAgent)(nil).Sign), arg0, arg1) +} + +// SignWithFlags mocks base method. +func (m *MockYubiAgent) SignWithFlags(arg0 ssh.PublicKey, arg1 []byte, arg2 agent.SignatureFlags) (*ssh.Signature, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignWithFlags", arg0, arg1, arg2) + ret0, _ := ret[0].(*ssh.Signature) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignWithFlags indicates an expected call of SignWithFlags. +func (mr *MockYubiAgentMockRecorder) SignWithFlags(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignWithFlags", reflect.TypeOf((*MockYubiAgent)(nil).SignWithFlags), arg0, arg1, arg2) +} + +// Signers mocks base method. +func (m *MockYubiAgent) Signers() ([]ssh.Signer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Signers") + ret0, _ := ret[0].([]ssh.Signer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Signers indicates an expected call of Signers. +func (mr *MockYubiAgentMockRecorder) Signers() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Signers", reflect.TypeOf((*MockYubiAgent)(nil).Signers)) +} + +// Unlock mocks base method. +func (m *MockYubiAgent) Unlock(arg0 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unlock", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Unlock indicates an expected call of Unlock. +func (mr *MockYubiAgentMockRecorder) Unlock(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockYubiAgent)(nil).Unlock), arg0) +} + +// Wait mocks base method. +func (m *MockYubiAgent) Wait(arg0 byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Wait indicates an expected call of Wait. +func (mr *MockYubiAgentMockRecorder) Wait(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockYubiAgent)(nil).Wait), arg0) +} diff --git a/agent/yubiagent/slot.go b/agent/yubiagent/slot.go new file mode 100644 index 0000000..2e926a6 --- /dev/null +++ b/agent/yubiagent/slot.go @@ -0,0 +1,196 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package yubiagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + + "github.com/rs/zerolog/log" + "github.com/theparanoids/crypki/proto" + "github.com/theparanoids/ysshra/attestation/yubiattest" + "github.com/theparanoids/ysshra/keyid" + keyutil "github.com/theparanoids/ysshra/sshutils/key" + "golang.org/x/crypto/ssh" +) + +// Slot wraps the yubiagent and stores the information of a key slot in the agent. +type Slot struct { + yubiAgent YubiAgent + code string // the code of the key slot, e.g. "9a", "9e" + public ssh.PublicKey // the public key stored in the key slot + attest *x509.Certificate // the attestation certificate of the key slot + policy keyid.TouchPolicy // the touch policy of the key slot + csrs []*proto.SSHCertificateSigningRequest // the certificates signing requests that are backed by the key slot +} + +// NewSlot returns the key slot of the given agent. +func NewSlot(yubiAgent YubiAgent, code string) (*Slot, error) { + attestationCert, err := yubiAgent.AttestSlot(code) + if err != nil { + return nil, err + } + // Due to Infineon Technologies' RSA key generation issue, we do not support RSA + // certificates generated by YubiKeys with firmware between 4.2.6-4.3.4. + // Ref: https://www.yubico.com/support/security-advisories/ysa-2017-01/ + if attestationCert.PublicKeyAlgorithm == x509.RSA && isCVE201715361AffectedVersion(attestationCert.Extensions) { + return nil, fmt.Errorf("found RSA certificate generated by YubiKey with firmware 4.2.6-4.3.4") + } + + publicKey, err := ssh.NewPublicKey(attestationCert.PublicKey) + if err != nil { + return nil, err + } + if validateYubikeyCrytpoAlgorithm(publicKey) { + return nil, fmt.Errorf("unsupported certificate type in the key slot: %s", code) + } + + touchPolicy := getTouchPolicy(attestationCert) + + return &Slot{ + code: code, + public: publicKey, + attest: attestationCert, + policy: touchPolicy, + yubiAgent: yubiAgent, + }, nil +} + +// NewSlotWithAttrs returns a new slot agent with attribute values. +// The function is for unit tests. +func NewSlotWithAttrs(yubiAgent YubiAgent, code string, public ssh.PublicKey, + attest *x509.Certificate, policy keyid.TouchPolicy) *Slot { + return &Slot{ + yubiAgent: yubiAgent, + code: code, + public: public, + attest: attest, + policy: policy, + } +} + +// RegisterCSR registers the given certificate signing request for the key slot. +// The public key of the CSR is expected to match to the private key in the key slot. +func (s *Slot) RegisterCSR(csr *proto.SSHCertificateSigningRequest) { + s.csrs = append(s.csrs, csr) +} + +// RegisterCSRs registers multiple certificate signing request for the key slot. +func (s *Slot) RegisterCSRs(csrs []*proto.SSHCertificateSigningRequest) { + for _, csr := range csrs { + s.RegisterCSR(csr) + } +} + +// CSRs returns the registered CSRs that match to the private key in the key slot. +func (s *Slot) CSRs() []*proto.SSHCertificateSigningRequest { + return s.csrs +} + +// AddCertsToAgent adds the certificates to the agent of the key slot. +// The function implements the interface csr.AgentKey. +// When the signer returns the signed certificates back to gensign, gensign could simply invoke the function +// to add certificates to the corresponding agent. +func (s *Slot) AddCertsToAgent(certs []ssh.PublicKey, comments []string) error { + for i, c := range certs { + cert, err := keyutil.CastSSHPublicKeyToCertificate(c) + if err != nil { + continue + } + err = s.yubiAgent.AddHardCert(cert, comments[i]) + if err != nil { + return err + } + } + return nil +} + +// PublicKey returns the public key of the key slot. +func (s *Slot) PublicKey() ssh.PublicKey { + return s.public +} + +// TouchPolicy returns the touch policy of the key slot. +func (s *Slot) TouchPolicy() keyid.TouchPolicy { + return s.policy +} + +// Serial returns the serial number of the yubikey. +func (s *Slot) Serial() (string, error) { + return yubiattest.ModHex(s.attest) +} + +// SlotCode returns the code number of the key slot. +func (s *Slot) SlotCode() string { + return s.code +} + +// AttestCert returns the attest cert of the key slot. +func (s *Slot) AttestCert() *x509.Certificate { + return s.attest +} + +// Agent returns the yubiagent. +func (s *Slot) Agent() YubiAgent { + return s.yubiAgent +} + +// getTouchPolicy returns the touch policy coded in the given attestation certificate +func getTouchPolicy(attestCert *x509.Certificate) keyid.TouchPolicy { + var touch = keyid.DefaultTouch + if attestCert == nil { + return touch + } + for _, ext := range attestCert.Extensions { + // NOTE: The following id is the touch policy stored in attestation certificate. + // Refer: https://developers.yubico.com/PIV/Introduction/PIV_attestation.html + if ext.Id.String() == "1.3.6.1.4.1.41482.3.8" { + touch = keyid.TouchPolicy(ext.Value[1]) + } + } + return touch +} + +// YubiKeys with firmaware version 4.2.6 - 4.3.4 are only affected by +// Infineon RSA key generation issue +func isCVE201715361AffectedVersion(extensions []pkix.Extension) bool { + for _, ext := range extensions { + if ext.Id.String() == "1.3.6.1.4.1.41482.3.3" { + version := int(ext.Value[0])*100 + int(ext.Value[1])*10 + int(ext.Value[2]) + if version >= 426 && version <= 434 { + log.Printf("affected firmware detected, %v", version) + return true + } + } + } + return false +} + +// validateYubikeyCrytpoAlgorithm validates whether the crypto algorithm is supported by YubiKey. +func validateYubikeyCrytpoAlgorithm(pub crypto.PublicKey) bool { + switch pk := pub.(type) { + case *rsa.PublicKey: + switch pk.Size() * 8 { + case 2048: + return true + default: + return false + } + case *ecdsa.PublicKey: + switch pk.Curve { + case elliptic.P256(), elliptic.P384(): + return true + default: + return false + } + // ed25519 is non-supported key algo for now. + default: + return false + } +} diff --git a/agent/yubiagent/slot_test.go b/agent/yubiagent/slot_test.go new file mode 100644 index 0000000..c029e14 --- /dev/null +++ b/agent/yubiagent/slot_test.go @@ -0,0 +1,228 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package yubiagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + "github.com/theparanoids/crypki/proto" + "github.com/theparanoids/ysshra/agent/yubiagent/mock" + "github.com/theparanoids/ysshra/attestation/yubiattest" + "github.com/theparanoids/ysshra/keyid" + "golang.org/x/crypto/ssh" +) + +func TestNewSlot(t *testing.T) { + t.Parallel() + + happyPathAttestCert := &x509.Certificate{ + PublicKey: &rsa.PublicKey{}, + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 8}, + Value: []byte{0, byte(keyid.NeverTouch)}, + }, + }, + } + happyPathPublicKey, _ := ssh.NewPublicKey(happyPathAttestCert.PublicKey) + + tests := []struct { + name string + attestCert *x509.Certificate + attestErr error + code string + want *Slot + wantErr bool + }{ + { + name: "happy path", + code: "9a", + attestCert: happyPathAttestCert, + want: &Slot{ + attest: happyPathAttestCert, + public: happyPathPublicKey, + code: "9a", + policy: keyid.NeverTouch, + }, + }, + { + name: "attest error", + attestCert: nil, + attestErr: errors.New("failed to attest"), + wantErr: true, + }, + { + name: "RSA with affected firmware version", + attestCert: &x509.Certificate{ + PublicKeyAlgorithm: x509.RSA, + PublicKey: &rsa.PublicKey{}, + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 8}, + Value: []byte{0, byte(keyid.NeverTouch)}, + }, + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 3}, // firmware version + Value: []byte{4, 3, 4}, + }, + }, + }, + wantErr: true, + }, + { + name: "unsupported public key", + attestCert: &x509.Certificate{ + PublicKey: "invalid string here", + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 8}, + Value: []byte{0, byte(keyid.NeverTouch)}, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().AttestSlot(tt.code). + Return(tt.attestCert, tt.attestErr).Times(1) + + if tt.want != nil { + tt.want.attest = tt.attestCert + tt.want.public, _ = ssh.NewPublicKey(tt.attestCert.PublicKey) + tt.want.yubiAgent = yubicoAgent + } + + got, err := NewSlot(yubicoAgent, tt.code) + if (err != nil) != tt.wantErr { + t.Errorf("NewSlot() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewSlot() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_validateSSHPublicKeyAlgo(t *testing.T) { + t.Parallel() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + privEC, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + pub crypto.PublicKey + want bool + }{ + { + name: "happy path rsa", + pub: &priv.PublicKey, + want: true, + }, + { + name: "happy path edcsa", + pub: &privEC.PublicKey, + want: true, + }, + { + name: "invalid", + pub: "invalid", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validateYubikeyCrytpoAlgorithm(tt.pub); got != tt.want { + t.Errorf("validateYubikeyCrytpoAlgorithm() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSlot_RegisterCSRs(t *testing.T) { + t.Parallel() + s := &Slot{} + csrs := []*proto.SSHCertificateSigningRequest{ + { + Principals: []string{"test01"}, + Validity: 3600, + }, + { + Principals: []string{"test02"}, + Validity: 3600, + }, + } + s.RegisterCSRs(csrs) + if !reflect.DeepEqual(s.CSRs(), csrs) { + t.Errorf("CSRs() got = %v, want %v", s.CSRs(), csrs) + } +} + +func TestSlotFields(t *testing.T) { + t.Parallel() + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + happyPathPublicKey, _ := ssh.NewPublicKey(ecdsa.PublicKey{}) + + s := &Slot{ + yubiAgent: mock.NewMockYubiAgent(mockCtrl), + code: "test-code", + public: happyPathPublicKey, + attest: &x509.Certificate{ + PublicKey: "invalid string here", + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 7}, + Value: []byte("12345"), + }, + }, + }, + policy: keyid.DefaultTouch, + } + if !reflect.DeepEqual(s.PublicKey(), s.public) { + t.Errorf("PublicKey() got = %v, want %v", s.CSRs(), s.public) + } + if !reflect.DeepEqual(s.Agent(), s.yubiAgent) { + t.Errorf("Agent() got = %v, want %v", s.CSRs(), s.yubiAgent) + } + if !reflect.DeepEqual(s.SlotCode(), s.code) { + t.Errorf("SlotCode() got = %v, want %v", s.CSRs(), s.code) + } + if !reflect.DeepEqual(s.TouchPolicy(), s.policy) { + t.Errorf("TouchPolicy() got = %v, want %v", s.CSRs(), s.policy) + } + if !reflect.DeepEqual(s.AttestCert(), s.attest) { + t.Errorf("AttestCert() got = %v, want %v", s.CSRs(), s.attest) + } + serial, _ := s.Serial() + wantSerial, _ := yubiattest.ModHex(s.attest) + if !reflect.DeepEqual(serial, wantSerial) { + t.Errorf("Serial() got = %v, want %v", serial, wantSerial) + } +} diff --git a/attestation/yubiattest/attest.go b/attestation/yubiattest/attest.go index 16343a5..feb8a39 100644 --- a/attestation/yubiattest/attest.go +++ b/attestation/yubiattest/attest.go @@ -9,10 +9,6 @@ import ( "os" ) -const ( - modHexMap = "cbdefghijklnrtuv" -) - // Attestor is the struct that performs attestation on a Yubikey. type Attestor struct { // roots is a certificate pool, which should include YubicoPIVRootCA and YubicoU2FRootCA. diff --git a/attestation/yubiattest/modhex.go b/attestation/yubiattest/modhex.go index 539a47a..fc48528 100644 --- a/attestation/yubiattest/modhex.go +++ b/attestation/yubiattest/modhex.go @@ -8,6 +8,10 @@ import ( "fmt" ) +const ( + modHexMap = "cbdefghijklnrtuv" // ref: https://developers.yubico.com/yubico-c/Manuals/modhex.1.html +) + // ModHex extract serial number from attestation certificate // and convert it to ModHex format. // Ref: https://developers.yubico.com/PIV/Introduction/PIV_attestation.html diff --git a/attestation/yubikey/algo.go b/attestation/yubikey/algo.go new file mode 100644 index 0000000..1538c0e --- /dev/null +++ b/attestation/yubikey/algo.go @@ -0,0 +1,4 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package yubikey diff --git a/cmd/gensign/main.go b/cmd/gensign/main.go index 22d3110..95247a5 100644 --- a/cmd/gensign/main.go +++ b/cmd/gensign/main.go @@ -5,6 +5,7 @@ package main import ( "context" + "github.com/theparanoids/ysshra/gensign/smartcard" "io" "os" @@ -26,7 +27,8 @@ const ( ) var handlerCreators = map[string]gensign.CreateHandler{ - regular.HandlerName: regular.NewHandler, + regular.HandlerName: regular.NewHandler, + smartcard.HandlerName: smartcard.NewHandler, } func main() { diff --git a/config/module.go b/config/module.go new file mode 100644 index 0000000..e033f5f --- /dev/null +++ b/config/module.go @@ -0,0 +1,29 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package config + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" +) + +// DecodeModuleConfig decodes module configuration. +func DecodeModuleConfig(undecodedConf interface{}, modConf interface{}) error { + config := &mapstructure.DecoderConfig{ + DecodeHook: StringToX509PublicKeyAlgo(), + Metadata: nil, + Result: modConf, + } + + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return fmt.Errorf("failed to initialize decoder, %v", err) + } + err = decoder.Decode(undecodedConf) + if err != nil { + return fmt.Errorf("failed to decode config, %v", err) + } + return nil +} diff --git a/config/module_test.go b/config/module_test.go new file mode 100644 index 0000000..5333140 --- /dev/null +++ b/config/module_test.go @@ -0,0 +1,49 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package config + +import ( + "reflect" + "testing" +) + +func TestExtractModuleConf(t *testing.T) { + type exampleModConf struct { + KeyString string `mapstructure:"key_string"` + KeyInt int `mapstructure:"key_int"` + KeyStringList []string `mapstructure:"key_string_list"` + } + + tests := []struct { + name string + undecodedConf interface{} + wantConf interface{} + wantErr bool + }{ + { + name: "happy path", + undecodedConf: map[string]interface{}{ + "key_string": "value-string", + "key_int": 123, + "key_string_list": []string{"value1", "value2"}, + }, + wantConf: map[string]interface{}{ + "key_string": "value-string", + "key_int": 123, + "key_string_list": []string{"value1", "value2"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := &exampleModConf{} + if err := DecodeModuleConfig(tt.undecodedConf, &conf); (err != nil) != tt.wantErr { + t.Errorf("DecodeModuleConfig() error = %v, wantErr %v", err, tt.wantErr) + } + if reflect.DeepEqual(conf, tt.wantConf) { + t.Errorf("DecodeModuleConfig() got = %v, want %v", conf, tt.wantConf) + } + }) + } +} diff --git a/gensign/regular/handler.go b/gensign/regular/handler.go index d6d8388..cbb2f17 100644 --- a/gensign/regular/handler.go +++ b/gensign/regular/handler.go @@ -30,8 +30,6 @@ const ( // HandlerName is a unique name to identify a handler. // It is also appended to the cert label. HandlerName = "paranoids.regular" - // IsForHumanUser indicates whether this handler should be used for a human user. - IsForHumanUser = true ) // Handler implements gensign.Handler. diff --git a/gensign/smartcard/handler.go b/gensign/smartcard/handler.go new file mode 100644 index 0000000..470ee0b --- /dev/null +++ b/gensign/smartcard/handler.go @@ -0,0 +1,44 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package smartcard + +import ( + "net" + + "github.com/theparanoids/ysshra/config" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/gensign" +) + +const ( + // HandlerName is a unique name to identify a handler. + HandlerName = "paranoids.smartcard" +) + +// Handler implements gensign.Handler. +type Handler struct { +} + +// NewHandler creates an SSH agent the ssh connection, +// and constructs a gensign.Handler containing the options loaded from conf. +func NewHandler(gensignConf *config.GensignConfig, conn net.Conn) (gensign.Handler, error) { + // TODO + return nil, nil +} + +// Name returns the name of the handler. +func (h *Handler) Name() string { + return HandlerName +} + +// Authenticate succeeds if the user is allowed to use request the certificate based on the public key on server side's directory. +func (h *Handler) Authenticate(param *csr.ReqParam) error { + // TODO + return nil +} + +func (h *Handler) Generate(param *csr.ReqParam) ([]csr.AgentKey, error) { + // TODO + return nil, nil +} diff --git a/modules/auth.go b/modules/auth.go new file mode 100644 index 0000000..fc75291 --- /dev/null +++ b/modules/auth.go @@ -0,0 +1,13 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package modules + +import ( + "github.com/theparanoids/ysshra/csr" +) + +// AuthnModule is the interface to authenticate an SSH certificate request for a handler. +type AuthnModule interface { + Authenticate(*csr.ReqParam) error +} diff --git a/modules/authn_f9_verify/authn.go b/modules/authn_f9_verify/authn.go new file mode 100644 index 0000000..88b4aed --- /dev/null +++ b/modules/authn_f9_verify/authn.go @@ -0,0 +1,121 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_f9_verify + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path" + "strconv" + + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/attestation/yubiattest" + yconfig "github.com/theparanoids/ysshra/config" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/modules" + "golang.org/x/crypto/ssh/agent" +) + +const ( + // Name is a unique name to identify an authentication module. + Name = "f9_verify" + + modHexMap = "cbdefghijklnrtuv" + hexMap = "0123456789abcdef" +) + +type authn struct { + f9CertsDir string + agent yubiagent.YubiAgent +} + +// New returns an authentication module. +func New(ag agent.Agent, c map[string]interface{}) (modules.AuthnModule, error) { + conf := &config{} + if err := yconfig.DecodeModuleConfig(c, conf); err != nil { + return nil, fmt.Errorf("failed to initilaize module %q, %v", Name, err) + } + + yubiAgent, ok := ag.(yubiagent.YubiAgent) + if !ok { + return nil, fmt.Errorf("yubiagent is the only supported agent in module %q", Name) + } + return &authn{ + f9CertsDir: conf.F9CertsDir, + agent: yubiAgent, + }, nil +} + +// Authenticate checks if f9 cert is modified or imported. +func (a *authn) Authenticate(_ *csr.ReqParam) error { + f9Cert, err := a.agent.ReadSlot("f9") + if err != nil { + return fmt.Errorf(`failed to read slot f9, %v`, err) + } + + if err := VerifyF9Cert(a.f9CertsDir, f9Cert); err != nil { + return fmt.Errorf(`failed to verify f9 attestation cert, %v"`, err) + } + return nil +} + +// VerifyF9Cert will ensure that the user is using a Yubikey that was provisioned to him or her, +// rather than just any Yubikey. +func VerifyF9Cert(f9CertDirPath string, f9Cert *x509.Certificate) error { + const ( + prefix = "0" + suffix = ".pem" + ) + f9Serial, err := yubiattest.ModHex(f9Cert) + if err != nil { + return err + } + f9SerialNum, err := getAttestationSerialNum([]byte(f9Serial)) + if err != nil { + return fmt.Errorf("couldn't get serial from yubikey f9 slot: serial=%s", f9Serial) + } + + f9SerialNumStr := strconv.FormatUint(f9SerialNum, 10) + + // Since the serial number keeps incrementing when new Yubikeys are manufactured, + // the serial number once can be fitted in 7 decimal digits, + // but now it needs 8 decimal digits. + // For those old yubikeys with serial numbers of 7 decimal digits, + // the file name of the corresponding f9 cert is prepended with a `0`. + if len(f9SerialNumStr) < 8 { + f9SerialNumStr = prefix + f9SerialNumStr + } + + // Get the attestation cert provided by yubico in the configured path. + certPath := path.Join(f9CertDirPath, f9SerialNumStr+suffix) + certBytes, err := os.ReadFile(certPath) + if err != nil { + return fmt.Errorf("unable to read attestation cert file: serial=%s, path=%s", f9Serial, certPath) + } + block, _ := pem.Decode(certBytes) + if block == nil { + return fmt.Errorf("couldn't decode f9 cert: serial=%s, path=%s", f9Serial, certPath) + } + if !bytes.Equal(block.Bytes, f9Cert.Raw) { + return fmt.Errorf("attestation cert mismatch: serial=%s, path=%s", f9Serial, certPath) + } + return nil +} + +// getAttestationSerialNum gets the serial number of attestation cert in Decimal format. +// Ref: https://developers.yubico.com/OTP/Modhex_Converter.html +func getAttestationSerialNum(modHex []byte) (uint64, error) { + var hexString string + for _, val := range modHex { + b := bytes.IndexByte([]byte(modHexMap), val) + if b == -1 { + b = 0 + } + hexString += fmt.Sprintf("%c", hexMap[b]) + } + return strconv.ParseUint(hexString, 16, 64) +} diff --git a/modules/authn_f9_verify/authn_test.go b/modules/authn_f9_verify/authn_test.go new file mode 100644 index 0000000..bda5d54 --- /dev/null +++ b/modules/authn_f9_verify/authn_test.go @@ -0,0 +1,325 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_f9_verify + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "errors" + "math/big" + "net" + "os" + "path" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/agent/yubiagent/mock" +) + +func testSignX509Cert(unsignedCert, caCert *x509.Certificate, pubKey *rsa.PublicKey, + caPrivKey *rsa.PrivateKey) (*x509.Certificate, []byte, error) { + certBytes, err := x509.CreateCertificate(rand.Reader, unsignedCert, caCert, pubKey, caPrivKey) + if err != nil { + return nil, nil, err + } + + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, nil, err + } + + b := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + pem := pem.EncodeToMemory(&b) + + return cert, pem, nil +} + +func testSelfSignX509Cert() (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + return testSelfSignX509CertWithBits(2048) +} + +func testSelfSignX509CertWithBits(bits int) (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + var unsignedCert = &x509.Certificate{ + SerialNumber: big.NewInt(1), + PublicKeyAlgorithm: x509.ECDSA, + IsCA: true, + } + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, nil, err + } + cert, pem, err := testSignX509Cert(unsignedCert, unsignedCert, &priv.PublicKey, priv) + if err != nil { + return nil, nil, nil, err + } + return cert, pem, priv, nil +} + +func testGenSignX509Cert(caCert *x509.Certificate, caKey *rsa.PrivateKey, + priv *rsa.PrivateKey, yubikeyHexDecimal []byte) (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + if priv == nil { + var err error + priv, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, nil, err + } + } + + var unsignedCert = &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-10 * time.Second), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageCRLSign, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + ExtraExtensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 7}, + Value: yubikeyHexDecimal, + }, + }, + } + + cert, pem, err := testSignX509Cert(unsignedCert, caCert, &priv.PublicKey, caKey) + if err != nil { + return nil, nil, nil, err + } + return cert, pem, priv, err +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) + want *authn + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + return yubicoAgent, map[string]interface{}{ + "f9_certs_dir": "/path/to/f9/certs", + } + }, + want: &authn{ + f9CertsDir: "/path/to/f9/certs", + }, + }, + { + name: "invalid module config", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + return yubicoAgent, map[string]interface{}{ + "f9_certs_dir": 123, + } + }, + wantErr: true, + }, + { + name: "not yubiagent", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + return nil, nil + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ag, c := tt.agent(t) + got, err := New(ag, c) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + gotMod, ok := got.(*authn) + if !ok { + t.Errorf("the module is not the correct authn") + } + tt.want.agent = ag + if !reflect.DeepEqual(gotMod, tt.want) { + t.Errorf("New() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_authn_Authenticate(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) (yubiagent.YubiAgent, string) + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + tmpDir := t.TempDir() + + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + // "02:04:04:03:02:01" is the HEX Decimal encoding of the yubikey serial number. + // The first byte (`02`) indicates that the type is integer. + // The second byte (`04`) denotes the number of bytes of the value. + // The rest of the bytes (`04`, `03`, `02`, `01`) can be converted to + // modhex `cfcecdcb` and decimal `67305985`. + cert, certByte, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + + serialPath := path.Join(tmpDir, "67305985.pem") + if err := os.WriteFile(serialPath, certByte, 0644); err != nil { + t.Fatal(err) + } + + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(cert, nil).Times(1) + return yubicoAgent, serialPath + }, + }, + { + name: "invalid yx509 cert", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + invalidCert, _, _, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(invalidCert, nil).Times(1) + return yubicoAgent, "" + }, + wantErr: true, + }, + { + name: "agent error", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + invalidCert, _, _, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(invalidCert, errors.New("some error")).Times(1) + return yubicoAgent, "" + }, + wantErr: true, + }, + { + name: "f9 cert not found", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + tmpDir := t.TempDir() + + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + cert, certByte, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + + serialPath := path.Join(tmpDir, "invalid_path.pem") + if err := os.WriteFile(serialPath, certByte, 0644); err != nil { + t.Fatal(err) + } + + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(cert, nil).Times(1) + return yubicoAgent, serialPath + }, + wantErr: true, + }, + { + name: "invalid cert at path", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + tmpDir := t.TempDir() + + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + + serialPath := path.Join(tmpDir, "67305985.pem") + if err := os.WriteFile(serialPath, []byte("invalid-cert-bytes"), 0644); err != nil { + t.Fatal(err) + } + + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(cert, nil).Times(1) + return yubicoAgent, serialPath + }, + wantErr: true, + }, + { + name: "f9 cert not equal", + agent: func(t *testing.T) (yubiagent.YubiAgent, string) { + tmpDir := t.TempDir() + + caCert, caByte, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + + serialPath := path.Join(tmpDir, "67305985.pem") + if err := os.WriteFile(serialPath, caByte, 0644); err != nil { + t.Fatal(err) + } + + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().ReadSlot("f9").Return(cert, nil).Times(1) + return yubicoAgent, serialPath + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent, p := tt.agent(t) + f9CertsDir := filepath.Dir(p) + a := &authn{ + agent: agent, + f9CertsDir: f9CertsDir, + } + if err := a.Authenticate(nil); (err != nil) != tt.wantErr { + t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/modules/authn_f9_verify/config.go b/modules/authn_f9_verify/config.go new file mode 100644 index 0000000..403ebd6 --- /dev/null +++ b/modules/authn_f9_verify/config.go @@ -0,0 +1,9 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_f9_verify + +type config struct { + // F9CertsDir stores all the authorized f9 certificates. + F9CertsDir string `mapstructure:"f9_certs_dir"` +} diff --git a/modules/authn_slot_attest/authn.go b/modules/authn_slot_attest/authn.go new file mode 100644 index 0000000..971a4de --- /dev/null +++ b/modules/authn_slot_attest/authn.go @@ -0,0 +1,74 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_slot_attest + +import ( + "fmt" + + "github.com/theparanoids/ysshra/agent/ssh" + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/attestation/yubiattest" + yconfig "github.com/theparanoids/ysshra/config" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/modules" + "golang.org/x/crypto/ssh/agent" +) + +const ( + // Name is a unique name to identify an authentication module. + Name = "slot_attest" +) + +type authn struct { + slot *yubiagent.Slot + conf *config +} + +// New returns an authentication module. +func New(ag agent.Agent, c map[string]interface{}) (modules.AuthnModule, error) { + conf := &config{} + if err := yconfig.DecodeModuleConfig(c, conf); err != nil { + return nil, fmt.Errorf("failed to initilaize module %q, %v", Name, err) + } + + yubiAgent, ok := ag.(yubiagent.YubiAgent) + if !ok { + return nil, fmt.Errorf("yubiagent is the only supported agent in module %q", Name) + } + + slot, err := yubiagent.NewSlot(yubiAgent, conf.Slot) + if err != nil { + return nil, fmt.Errorf("failed to access slot agent in module %q, %v", Name, err) + } + + return &authn{ + slot: slot, + conf: conf, + }, nil +} + +// Authenticate attests a key slot to verify the key pair of that slot is generated inside a smartcard. +func (a *authn) Authenticate(_ *csr.ReqParam) error { + // Read the certificate in the yubikey f9 slot. + // F9 slot is only used for attestation of other keys generated on device with instruction f9. + f9Cert, err := a.slot.Agent().ReadSlot("f9") + if err != nil { + return fmt.Errorf(`failed to read slot f9, %v`, err) + } + + attestor, err := yubiattest.NewAttestor(a.conf.PIVRootCA, a.conf.U2FRootCA) + if err != nil { + return fmt.Errorf(`failed to initialize yubikey attestor, %v`, err) + } + + if err := attestor.Attest(f9Cert, a.slot.AttestCert()); err != nil { + return fmt.Errorf(`failed to attest yubikey slot %s, %v`, a.slot.SlotCode(), err) + } + + if err = ssh.ChallengeSSHAgent(a.slot.Agent(), a.slot.PublicKey()); err != nil { + return fmt.Errorf(`failed to authenticate slot key %s with challenge response, %v"`, a.slot.SlotCode(), err) + } + + return nil +} diff --git a/modules/authn_slot_attest/config.go b/modules/authn_slot_attest/config.go new file mode 100644 index 0000000..af898a2 --- /dev/null +++ b/modules/authn_slot_attest/config.go @@ -0,0 +1,13 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_slot_attest + +type config struct { + // Slot is the slot number inside a yubikey. + Slot string `mapstructure:"slot"` + // PIVRootCA is the file path to the root CA of yubikey PIV (Personal Identity Verification). + PIVRootCA string `mapstructure:"piv_root_ca"` + // U2FRootCA is the file path to the root CA of yubikey U2F. + U2FRootCA string `mapstructure:"u2f_root_ca"` +} diff --git a/modules/authn_slot_serial/authn.go b/modules/authn_slot_serial/authn.go new file mode 100644 index 0000000..bd02c9d --- /dev/null +++ b/modules/authn_slot_serial/authn.go @@ -0,0 +1,98 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_slot_serial + +import ( + "fmt" + "os" + "strings" + + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/attestation/yubiattest" + yconfig "github.com/theparanoids/ysshra/config" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/modules" + "golang.org/x/crypto/ssh/agent" +) + +const ( + // Name is a unique name to identify an authentication module. + Name = "slot_serial" +) + +type authn struct { + slot *yubiagent.Slot + yubikeyMappings string +} + +// New returns an authentication module. +func New(ag agent.Agent, c map[string]interface{}) (modules.AuthnModule, error) { + conf := &config{} + if err := yconfig.DecodeModuleConfig(c, conf); err != nil { + return nil, fmt.Errorf("failed to initilaize module %q, %v", Name, err) + } + + yubiAgent, ok := ag.(yubiagent.YubiAgent) + if !ok { + return nil, fmt.Errorf("yubiagent is the only supported agent in module %q", Name) + } + + slot, err := yubiagent.NewSlot(yubiAgent, conf.Slot) + if err != nil { + return nil, fmt.Errorf("failed to access slot agent in module %q, %v", Name, err) + } + + return &authn{ + slot: slot, + yubikeyMappings: conf.YubikeyMappings, + }, nil + +} + +// Authenticate extracts the yubikey serial number from a key slot, and looks up a yubikey mapping file to check whether +// the belonger of that serial number matches to the certificate requester. +func (a *authn) Authenticate(param *csr.ReqParam) error { + // Look up the yubikey serial number from the attestation cert. + serial, err := yubiattest.ModHex(a.slot.AttestCert()) + if err != nil { + return fmt.Errorf(`failed to lookup the current yubiKey serial number in the attestation cert at slot %s, %v"`, a.slot.SlotCode(), err) + } + + user, err := findUserFromYubikeyMapping(serial, a.yubikeyMappings) + if err != nil { + return fmt.Errorf(`failed to find username in the yubikey mapping by serial %s, %v"`, serial, err) + } + + if user != param.LogName { + return fmt.Errorf(`yubikey doesn't belong to current user, serial: %s, yubikey owner: %s`, serial, user) + } + + return nil +} + +// findUserFromYubikeyMapping searches for the username in the yubiKey mapping file +// using the given yubikey serial number. +func findUserFromYubikeyMapping(modhex, mapping string) (user string, err error) { + data, err := os.ReadFile(mapping) + if err != nil { + return "", err + } + + if len(modhex) != 8 { + return "", fmt.Errorf("invalid modhex value %v. must be 8 characters exactly", modhex) + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + fields := strings.Split(line, ":") + user := fields[0] + keys := fields[1:] + for _, key := range keys { + if key[4:] == modhex { + return user, nil + } + } + } + return "", fmt.Errorf("user not found") +} diff --git a/modules/authn_slot_serial/authn_test.go b/modules/authn_slot_serial/authn_test.go new file mode 100644 index 0000000..f445524 --- /dev/null +++ b/modules/authn_slot_serial/authn_test.go @@ -0,0 +1,306 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_slot_serial + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "errors" + "math/big" + "net" + "reflect" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/agent/yubiagent/mock" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/keyid" +) + +func testSignX509Cert(unsignedCert, caCert *x509.Certificate, pubKey *rsa.PublicKey, + caPrivKey *rsa.PrivateKey) (*x509.Certificate, []byte, error) { + certBytes, err := x509.CreateCertificate(rand.Reader, unsignedCert, caCert, pubKey, caPrivKey) + if err != nil { + return nil, nil, err + } + + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, nil, err + } + + b := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + pem := pem.EncodeToMemory(&b) + + return cert, pem, nil +} + +func testSelfSignX509Cert() (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + return testSelfSignX509CertWithBits(2048) +} + +func testSelfSignX509CertWithBits(bits int) (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + var unsignedCert = &x509.Certificate{ + SerialNumber: big.NewInt(1), + PublicKeyAlgorithm: x509.ECDSA, + IsCA: true, + } + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, nil, err + } + cert, pem, err := testSignX509Cert(unsignedCert, unsignedCert, &priv.PublicKey, priv) + if err != nil { + return nil, nil, nil, err + } + return cert, pem, priv, nil +} + +func testGenSignX509Cert(caCert *x509.Certificate, caKey *rsa.PrivateKey, + priv *rsa.PrivateKey, yubikeyHexDecimal []byte) (*x509.Certificate, []byte, *rsa.PrivateKey, error) { + if priv == nil { + var err error + priv, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, nil, err + } + } + + var unsignedCert = &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-10 * time.Second), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageCRLSign, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + ExtraExtensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 7}, + Value: yubikeyHexDecimal, + }, + }, + } + + cert, pem, err := testSignX509Cert(unsignedCert, caCert, &priv.PublicKey, caKey) + if err != nil { + return nil, nil, nil, err + } + return cert, pem, priv, err +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) + want *authn + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + + happyPathAttestCert := &x509.Certificate{ + PublicKey: &rsa.PublicKey{}, + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 8}, + Value: []byte{0, byte(keyid.NeverTouch)}, + }, + }, + } + yubicoAgent.EXPECT().AttestSlot("9a"). + Return(happyPathAttestCert, nil).Times(1) + + return yubicoAgent, map[string]interface{}{ + "slot": "9a", + "yubikey_mappings": "/path/to/yubikey/mappings", + } + }, + want: &authn{ + yubikeyMappings: "/path/to/yubikey/mappings", + }, + }, + { + name: "failed to create slot agent", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + + yubicoAgent.EXPECT().AttestSlot("9a"). + Return(nil, errors.New("some agent error")).Times(1) + + return yubicoAgent, map[string]interface{}{ + "slot": "9a", + "yubikey_mappings": "/path/to/yubikey/mappings", + } + }, + wantErr: true, + }, + { + name: "invalid config", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + + return yubicoAgent, map[string]interface{}{ + "slot": 123, + } + }, + wantErr: true, + }, + { + name: "invalid agent", + agent: func(t *testing.T) (yubiagent.YubiAgent, map[string]interface{}) { + return nil, map[string]interface{}{ + "slot": 123, + } + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ag, c := tt.agent(t) + got, err := New(ag, c) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + gotMod, ok := got.(*authn) + if !ok { + t.Errorf("the module is not the correct authn") + } + gotMod.slot = nil // We don't need to check slot agent in this unit test. + if !reflect.DeepEqual(gotMod, tt.want) { + t.Errorf("New() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_authn_Authenticate(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) *yubiagent.Slot + mappingPath string + reqParam *csr.ReqParam + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) *yubiagent.Slot { + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + // "02:04:04:03:02:01" is the HEX Decimal encoding of the yubikey serial number. + // The first byte (`02`) indicates that the type is integer. + // The second byte (`04`) denotes the number of bytes of the value. + // The rest of the bytes (`04`, `03`, `02`, `01`) can be converted to + // modhex `cfcecdcb` and decimal `67305985`. + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + agent := yubiagent.NewSlotWithAttrs(nil, "9a", nil, cert, 0) + return agent + }, + reqParam: &csr.ReqParam{ + LogName: "test_user1", + }, + mappingPath: "./testdata/yubikey_mappings", + }, + { + name: "no user found in the mapping", + agent: func(t *testing.T) *yubiagent.Slot { + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{00, 00, 00, 00, 00, 00}) // an invalid decimal + if err != nil { + t.Fatal(err) + } + agent := yubiagent.NewSlotWithAttrs(nil, "9a", nil, cert, 0) + return agent + }, + reqParam: &csr.ReqParam{ + LogName: "test_user1", + }, + mappingPath: "./testdata/yubikey_mappings", + wantErr: true, + }, + { + name: "failed to read mapping file", + agent: func(t *testing.T) *yubiagent.Slot { + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{00, 00, 00, 00, 00, 00}) + if err != nil { + t.Fatal(err) + } + agent := yubiagent.NewSlotWithAttrs(nil, "9a", nil, cert, 0) + return agent + }, + reqParam: &csr.ReqParam{ + LogName: "test_user1", + }, + mappingPath: "./testdata/invalid-path", + wantErr: true, + }, + { + name: "invalid user", + agent: func(t *testing.T) *yubiagent.Slot { + caCert, _, caPriv, err := testSelfSignX509Cert() + if err != nil { + t.Fatal(err) + } + // "02:04:04:03:02:01" is the HEX Decimal encoding of the yubikey serial number. + // The first byte (`02`) indicates that the type is integer. + // The second byte (`04`) denotes the number of bytes of the value. + // The rest of the bytes (`04`, `03`, `02`, `01`) can be converted to + // modhex `cfcecdcb` and decimal `67305985`. + cert, _, _, err := testGenSignX509Cert(caCert, caPriv, nil, []byte{02, 04, 04, 03, 02, 01}) + if err != nil { + t.Fatal(err) + } + agent := yubiagent.NewSlotWithAttrs(nil, "9a", nil, cert, 0) + return agent + }, + reqParam: &csr.ReqParam{ + LogName: "invalid user", + }, + mappingPath: "./testdata/invalid-path", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := tt.agent(t) + a := &authn{ + slot: agent, + yubikeyMappings: tt.mappingPath, + } + if err := a.Authenticate(tt.reqParam); (err != nil) != tt.wantErr { + t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/modules/authn_slot_serial/config.go b/modules/authn_slot_serial/config.go new file mode 100644 index 0000000..cef297f --- /dev/null +++ b/modules/authn_slot_serial/config.go @@ -0,0 +1,11 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package authn_slot_serial + +type config struct { + // Slot is the slot number inside a yubikey. + Slot string `mapstructure:"slot"` + // YubikeyMappings is the mapping file that includes the mappings between a yubikey serial number and its belonger. + YubikeyMappings string `mapstructure:"yubikey_mappings"` +} diff --git a/modules/authn_slot_serial/testdata/yubikey_mappings b/modules/authn_slot_serial/testdata/yubikey_mappings new file mode 100644 index 0000000..b48fe31 --- /dev/null +++ b/modules/authn_slot_serial/testdata/yubikey_mappings @@ -0,0 +1 @@ +test_user1:eiddcfcecdcb diff --git a/modules/csr.go b/modules/csr.go new file mode 100644 index 0000000..9b75833 --- /dev/null +++ b/modules/csr.go @@ -0,0 +1,23 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package modules + +import ( + "crypto/x509" + + "github.com/theparanoids/ysshra/csr" +) + +// CSRModule is the interface to generate CSR for a handler. +type CSRModule interface { + Generate(*csr.ReqParam) ([]csr.AgentKey, error) +} + +// CSROption is the option struct to create a CSR module. +type CSROption struct { + // KeyIdentifiers is the mapping from CA public key algorithm to the key identifier configured in signer. + KeyIdentifiers map[x509.PublicKeyAlgorithm]string + // KeyIDVersion specifies the version of KeyID. + KeyIDVersion uint16 `json:"keyid_version"` +} diff --git a/modules/csr_smartcard_hardkey/config.go b/modules/csr_smartcard_hardkey/config.go new file mode 100644 index 0000000..620dce9 --- /dev/null +++ b/modules/csr_smartcard_hardkey/config.go @@ -0,0 +1,17 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package csr_smartcard_hardkey + +type config struct { + // Slot is the slot number inside a yubikey. + Slot string `mapstructure:"slot"` + // CertValiditySec is the validity period of the certificate in seconds. + CertValiditySec uint64 `mapstructure:"cert_validity_sec" default:"43200"` + // IsFirefighter indicates whether the certificate is for emergency situation. + IsFirefighter bool `mapstructure:"is_firefighter"` + // TouchPolicy indicates the touch policy of the certificate. + TouchPolicy int `mapstructure:"touch_policy"` + // PrincipalsTpl is the template to generate template list. + PrincipalsTpl string `mapstructure:"principals_tpl"` +} diff --git a/modules/csr_smartcard_hardkey/generator.go b/modules/csr_smartcard_hardkey/generator.go new file mode 100644 index 0000000..08dcb83 --- /dev/null +++ b/modules/csr_smartcard_hardkey/generator.go @@ -0,0 +1,95 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package csr_smartcard_hardkey + +import ( + "fmt" + + "github.com/theparanoids/crypki/proto" + "github.com/theparanoids/ysshra/agent/yubiagent" + yconfig "github.com/theparanoids/ysshra/config" + "github.com/theparanoids/ysshra/crypki" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/keyid" + "github.com/theparanoids/ysshra/modules" + "github.com/theparanoids/ysshra/sshutils/cert" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +const ( + // Name is a unique name to identify a CSR generator module. + Name = "smartcard_hardkey" +) + +type generator struct { + slot *yubiagent.Slot + c *config + opt *modules.CSROption +} + +// New returns a CSR generator module. +func New(ag agent.Agent, c map[string]interface{}, opt *modules.CSROption) (modules.CSRModule, error) { + conf := &config{} + if err := yconfig.DecodeModuleConfig(c, conf); err != nil { + return nil, fmt.Errorf("failed to initilaize module %q, %v", Name, err) + } + + yubiAgent, ok := ag.(yubiagent.YubiAgent) + if !ok { + return nil, fmt.Errorf("yubiagent is the only supported agent in module %q", Name) + } + + slot, err := yubiagent.NewSlot(yubiAgent, conf.Slot) + if err != nil { + return nil, fmt.Errorf("failed to access slot agent in module %q, %v", Name, err) + } + + return &generator{ + slot: slot, + c: conf, + opt: opt, + }, nil +} + +// Generate generates a slice of agent keys which include SSH certificate requests. +func (g *generator) Generate(param *csr.ReqParam) ([]csr.AgentKey, error) { + keyIdentifier, ok := g.opt.KeyIdentifiers[param.Attrs.CAPubKeyAlgo] + if !ok { + return nil, fmt.Errorf("unsupported CA public key algorithm %q", param.Attrs.CAPubKeyAlgo) + } + + principals := cert.GetPrincipals(g.c.PrincipalsTpl, param.LogName) + + kid := &keyid.KeyID{ + Principals: principals, + TransID: param.TransID, + ReqUser: param.ReqUser, + ReqIP: param.ClientIP, + ReqHost: param.ReqHost, + Version: g.opt.KeyIDVersion, + IsFirefighter: g.c.IsFirefighter, + IsHWKey: true, + IsHeadless: false, + IsNonce: false, + Usage: keyid.AllUsage, + TouchPolicy: keyid.TouchPolicy(g.c.TouchPolicy), + } + + request := &proto.SSHCertificateSigningRequest{ + KeyMeta: &proto.KeyMeta{Identifier: keyIdentifier}, + Extensions: crypki.GetDefaultExtension(), + Validity: g.c.CertValiditySec, + Principals: kid.Principals, + PublicKey: string(ssh.MarshalAuthorizedKey(g.slot.PublicKey())), + } + + var err error + request.KeyId, err = kid.Marshal() + if err != nil { + return nil, err + } + g.slot.RegisterCSR(request) + return []csr.AgentKey{g.slot}, nil +} diff --git a/modules/csr_smartcard_hardkey/generator_test.go b/modules/csr_smartcard_hardkey/generator_test.go new file mode 100644 index 0000000..6fc45be --- /dev/null +++ b/modules/csr_smartcard_hardkey/generator_test.go @@ -0,0 +1,263 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package csr_smartcard_hardkey + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + "github.com/theparanoids/crypki/proto" + "github.com/theparanoids/ysshra/agent/yubiagent" + "github.com/theparanoids/ysshra/agent/yubiagent/mock" + "github.com/theparanoids/ysshra/crypki" + "github.com/theparanoids/ysshra/csr" + "github.com/theparanoids/ysshra/keyid" + "github.com/theparanoids/ysshra/message" + "github.com/theparanoids/ysshra/modules" + "golang.org/x/crypto/ssh" + sshagent "golang.org/x/crypto/ssh/agent" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) (sshagent.Agent, map[string]interface{}) + want *generator + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) (sshagent.Agent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + + happyPathAttestCert := &x509.Certificate{ + PublicKey: &rsa.PublicKey{}, + Extensions: []pkix.Extension{ + { + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 8}, + Value: []byte{0, byte(keyid.NeverTouch)}, + }, + }, + } + yubicoAgent.EXPECT().AttestSlot("9a").Return(happyPathAttestCert, nil).Times(1) + return yubicoAgent, map[string]interface{}{ + "touch_policy": 1, + "principals_tpl": "", + "slot": "9a", + "cert_validity_sec": 3600, + } + }, + want: &generator{ + c: &config{ + TouchPolicy: 1, + PrincipalsTpl: "", + Slot: "9a", + CertValiditySec: 3600, + }, + opt: &modules.CSROption{}, + }, + }, + { + name: "failed to extract config", + agent: func(t *testing.T) (sshagent.Agent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + return yubicoAgent, map[string]interface{}{ + "touch_policy": "invalid", + } + }, + wantErr: true, + }, + { + name: "invalid key agent", + agent: func(t *testing.T) (sshagent.Agent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + agent := sshagent.NewKeyring() + return agent, map[string]interface{}{ + "touch_policy": 1, + "principals": "", + "slot": "9a", + "cert_validity_sec": 3600, + } + }, + wantErr: true, + }, + { + name: "failed to fetch attest certs", + agent: func(t *testing.T) (sshagent.Agent, map[string]interface{}) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + yubicoAgent := mock.NewMockYubiAgent(mockCtrl) + yubicoAgent.EXPECT().AttestSlot("9a").Return(nil, errors.New("invalid attestation")).Times(1) + return yubicoAgent, map[string]interface{}{ + "touch_policy": 1, + "principals": "", + "slot": "9a", + "cert_validity_sec": 3600, + } + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ag, c := tt.agent(t) + got, err := New(ag, c, &modules.CSROption{}) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + gotMod, ok := got.(*generator) + if !ok { + t.Errorf("the generator is not the correct type") + } + tt.want.slot = gotMod.slot + if !reflect.DeepEqual(gotMod, tt.want) { + t.Errorf("New() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_generator_Generate(t *testing.T) { + tests := []struct { + name string + agent func(t *testing.T) *yubiagent.Slot + c *config + opt *modules.CSROption + param *csr.ReqParam + want func(t *testing.T) *proto.SSHCertificateSigningRequest + wantErr bool + }{ + { + name: "happy path", + agent: func(t *testing.T) *yubiagent.Slot { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Error(err) + } + pub, err := ssh.NewPublicKey(&priv.PublicKey) + if err != nil { + t.Error(err) + } + return yubiagent.NewSlotWithAttrs(nil, "9a", pub, nil, keyid.DefaultTouch) + }, + c: &config{ + IsFirefighter: true, + TouchPolicy: 1, + PrincipalsTpl: "", + Slot: "9a", + CertValiditySec: 86400, + }, + opt: &modules.CSROption{ + KeyIdentifiers: map[x509.PublicKeyAlgorithm]string{ + x509.RSA: "rsa-key-identifier", + }, + KeyIDVersion: 1, + }, + param: &csr.ReqParam{ + LogName: "testuser", + Attrs: &message.Attributes{ + CAPubKeyAlgo: x509.RSA, + }, + TransID: "12345", + ReqUser: "ReqUser", + ClientIP: "1.2.3.4", + ReqHost: "example-host.com", + }, + want: func(t *testing.T) *proto.SSHCertificateSigningRequest { + kid := &keyid.KeyID{ + Principals: []string{"testuser"}, + TransID: "12345", + ReqUser: "ReqUser", + ReqIP: "1.2.3.4", + ReqHost: "example-host.com", + Version: 1, + IsFirefighter: true, + IsHWKey: true, + IsHeadless: false, + IsNonce: false, + Usage: keyid.AllUsage, + TouchPolicy: keyid.NeverTouch, + } + kidMarshalled, err := kid.Marshal() + if err != nil { + t.Error(err) + } + return &proto.SSHCertificateSigningRequest{ + KeyMeta: &proto.KeyMeta{Identifier: "rsa-key-identifier"}, + Extensions: crypki.GetDefaultExtension(), + Validity: 86400, + Principals: []string{"testuser"}, + KeyId: kidMarshalled, + } + }, + }, + { + name: "failed to lookup key identifier", + agent: func(t *testing.T) *yubiagent.Slot { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Error(err) + } + pub, err := ssh.NewPublicKey(&priv.PublicKey) + if err != nil { + t.Error(err) + } + return yubiagent.NewSlotWithAttrs(nil, "9a", pub, nil, keyid.DefaultTouch) + }, + c: &config{}, + opt: &modules.CSROption{ + KeyIdentifiers: map[x509.PublicKeyAlgorithm]string{}, + KeyIDVersion: 1, + }, + param: &csr.ReqParam{ + Attrs: &message.Attributes{ + CAPubKeyAlgo: x509.RSA, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &generator{ + slot: tt.agent(t), + c: tt.c, + opt: tt.opt, + } + got, err := g.Generate(tt.param) + if (err != nil) != tt.wantErr { + t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if len(got) != 1 || len(got[0].CSRs()) != 1 { + t.Errorf("want a csr returned in the agent key, got %v", got) + } + gotCSR := got[0].CSRs()[0] + wantCSR := tt.want(t) + wantCSR.PublicKey = gotCSR.PublicKey // We don't check public key. + if !reflect.DeepEqual(gotCSR, wantCSR) { + t.Errorf("Generate() got CSR = %v, want %v", gotCSR, wantCSR) + } + }) + } +} diff --git a/sshutils/cert/principal.go b/sshutils/cert/principal.go index a9f390f..c3a9d43 100644 --- a/sshutils/cert/principal.go +++ b/sshutils/cert/principal.go @@ -3,41 +3,21 @@ package cert +import "strings" + const ( - // TouchlessLabel is the label for touchless certificates. - TouchlessLabel = ":notouch" - // TouchLabel is the label for touch certificates. - TouchLabel = ":touch" + // LognamePlaceholder is the placeholder for logname in a template to generate placeholders. + LognamePlaceholder = "" + // SplitChar is the splitter char to split the principals in a template. + SplitChar = "," ) -// GetPrincipals returns the labeled principals based on the certificate type. -func GetPrincipals(principals []string, certType Type) []string { - switch certType { - case UnknownCertType: - return nil - case TouchSudoCert: - return getTouchPrincipals(principals) - case TouchlessSudoCert: - fallthrough - case TouchlessCert: - return getTouchlessPrincipals(principals) - default: - return principals - } -} - -func getTouchlessPrincipals(principals []string) []string { - var labeledPrincipals []string - for _, p := range principals { - labeledPrincipals = append(labeledPrincipals, p+TouchlessLabel) - } - return labeledPrincipals -} - -func getTouchPrincipals(principals []string) []string { - var labeledPrincipals []string - for _, p := range principals { - labeledPrincipals = append(labeledPrincipals, p+TouchLabel) +// GetPrincipals returns a slice of principals based on the given principals template and the SSH logname. +func GetPrincipals(prinsTpl string, logname string) []string { + prins := strings.ReplaceAll(prinsTpl, LognamePlaceholder, logname) + principals := strings.Split(prins, SplitChar) + for i := range principals { + principals[i] = strings.TrimSpace(principals[i]) } - return labeledPrincipals + return principals } diff --git a/sshutils/cert/principal_legacy.go b/sshutils/cert/principal_legacy.go new file mode 100644 index 0000000..deaa144 --- /dev/null +++ b/sshutils/cert/principal_legacy.go @@ -0,0 +1,45 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +// TODO: remove principal_legacy.go after we fully release YSSHRA. + +package cert + +const ( + // TouchlessLabel is the label for touchless certificates. + TouchlessLabel = ":notouch" + // TouchLabel is the label for touch certificates. + TouchLabel = ":touch" +) + +// GetPrincipalsLegacy returns the labeled principals based on the certificate type. +func GetPrincipalsLegacy(principals []string, certType Type) []string { + switch certType { + case UnknownCertType: + return nil + case TouchSudoCert: + return getTouchPrincipals(principals) + case TouchlessSudoCert: + fallthrough + case TouchlessCert: + return getTouchlessPrincipals(principals) + default: + return principals + } +} + +func getTouchlessPrincipals(principals []string) []string { + var labeledPrincipals []string + for _, p := range principals { + labeledPrincipals = append(labeledPrincipals, p+TouchlessLabel) + } + return labeledPrincipals +} + +func getTouchPrincipals(principals []string) []string { + var labeledPrincipals []string + for _, p := range principals { + labeledPrincipals = append(labeledPrincipals, p+TouchLabel) + } + return labeledPrincipals +} diff --git a/sshutils/cert/principal_legacy_test.go b/sshutils/cert/principal_legacy_test.go new file mode 100644 index 0000000..c9003ad --- /dev/null +++ b/sshutils/cert/principal_legacy_test.go @@ -0,0 +1,50 @@ +// Copyright 2022 Yahoo Inc. +// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. + +package cert + +import ( + "reflect" + "testing" +) + +func TestGetPrincipalsLegacy(t *testing.T) { + tests := []struct { + name string + principals []string + certType Type + want []string + }{ + { + name: "unkown", + principals: []string{"user1", "user2"}, + certType: UnknownCertType, + want: nil, + }, + { + name: "TouchSudoCert", + principals: []string{"user1", "user2"}, + certType: TouchSudoCert, + want: []string{"user1:touch", "user2:touch"}, + }, + { + name: "TouchlessSudoCert", + principals: []string{"user1", "user2"}, + certType: TouchlessSudoCert, + want: []string{"user1:notouch", "user2:notouch"}, + }, + { + name: "TouchlessCert", + principals: []string{"user1", "user2"}, + certType: TouchlessCert, + want: []string{"user1:notouch", "user2:notouch"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetPrincipalsLegacy(tt.principals, tt.certType); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetPrincipals() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sshutils/cert/principal_test.go b/sshutils/cert/principal_test.go index 21d1bbd..9fa73b4 100644 --- a/sshutils/cert/principal_test.go +++ b/sshutils/cert/principal_test.go @@ -1,6 +1,3 @@ -// Copyright 2022 Yahoo Inc. -// Licensed under the terms of the Apache License 2.0. Please see LICENSE file in project root for terms. - package cert import ( @@ -10,39 +7,21 @@ import ( func TestGetPrincipals(t *testing.T) { tests := []struct { - name string - principals []string - certType Type - want []string + name string + prinsConf string + logName string + want []string }{ { - name: "unkown", - principals: []string{"user1", "user2"}, - certType: UnknownCertType, - want: nil, - }, - { - name: "TouchSudoCert", - principals: []string{"user1", "user2"}, - certType: TouchSudoCert, - want: []string{"user1:touch", "user2:touch"}, - }, - { - name: "TouchlessSudoCert", - principals: []string{"user1", "user2"}, - certType: TouchlessSudoCert, - want: []string{"user1:notouch", "user2:notouch"}, - }, - { - name: "TouchlessCert", - principals: []string{"user1", "user2"}, - certType: TouchlessCert, - want: []string{"user1:notouch", "user2:notouch"}, + name: "happy path", + prinsConf: ",:123,test:", + logName: "example_user", + want: []string{"example_user", "example_user:123", "test:example_user"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := GetPrincipals(tt.principals, tt.certType); !reflect.DeepEqual(got, tt.want) { + if got := GetPrincipals(tt.prinsConf, tt.logName); !reflect.DeepEqual(got, tt.want) { t.Errorf("GetPrincipals() = %v, want %v", got, tt.want) } })