├── .devcontainer ├── devcontainer.json └── postCreate.sh ├── .github └── workflows │ └── go.yml ├── .gitignore ├── .goreleaser.yml ├── .vscode └── launch.json ├── FIXME.txt ├── LICENSE ├── README.md ├── action ├── env.go ├── issue │ ├── config.go │ ├── config_test.go │ ├── issue.go │ └── issue_test.go ├── serve │ ├── authprofile │ │ └── authprofile.go │ ├── cert.go │ ├── certificateservice │ │ └── service.go │ ├── certshandler │ │ └── handler.go │ ├── config.go │ ├── hello_service.go │ ├── httpzaplog │ │ └── handler.go │ ├── issuehandler │ │ └── handler.go │ ├── serve.go │ ├── tokenauth.go │ ├── tokenauth_test.go │ └── version_service.go └── setup │ ├── config.go │ ├── config_test.go │ ├── nameconstraints.go │ ├── nameconstraints_test.go │ ├── setup.go │ └── setup_test.go ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── cmd └── kmgm │ ├── app │ ├── app.go │ └── appflags │ │ └── appflags.go │ ├── batch │ └── batch.go │ ├── issue │ └── issue.go │ ├── list │ └── list.go │ ├── main.go │ ├── main_test.go │ ├── remote │ ├── bootstrap │ │ └── bootstrap.go │ ├── issue │ │ └── issue.go │ ├── show │ │ └── show.go │ ├── subcommand.go │ └── version │ │ └── version.go │ ├── remote_test.go │ ├── serve │ ├── serve.go │ └── testserver │ │ └── testserver.go │ ├── setup │ └── setup.go │ ├── show │ ├── format.go │ ├── ifchanged.go │ └── show.go │ ├── testkmgm │ ├── gen.go │ ├── generate_testkeys.go │ ├── testkeys.go │ ├── testkeys_util.go │ └── testkmgm.go │ └── tool │ ├── dump │ └── dump.go │ ├── pubkeyhash │ ├── format.go │ └── pubkeyhash.go │ └── tool.go ├── consts └── consts.go ├── dname ├── config.go └── protoconv.go ├── docs ├── demo.svg └── tutorials │ └── nginx │ ├── .gitignore │ ├── README.md │ ├── chrome-warning.png │ ├── conf │ └── nginx.conf │ ├── docker.sh │ ├── pub │ └── index.html │ └── tls │ └── .placeholder ├── domainname ├── domainname.go ├── domainname_posix.go └── domainname_test.go ├── exporter └── exporter.go ├── frontend ├── editstruct.go ├── editstruct_test.go ├── frontend.go ├── noninteractive.go ├── promptuife │ └── frontend.go └── validate │ ├── path.go │ └── pkixelement.go ├── gen.go ├── go.mod ├── go.sum ├── httperr └── error.go ├── ipapi ├── ipapi.go └── ipapi_test.go ├── keyusage ├── keyusage.go ├── keyusage_test.go ├── x509.go └── x509_test.go ├── pb ├── apiversion.go ├── kmgm.pb.go ├── kmgm.proto └── kmgm_grpc.pb.go ├── pemparser ├── consts.go ├── marshaller.go ├── parser.go └── parser_test.go ├── period ├── days.go ├── days_test.go ├── validityperiod.go └── validityperiod_test.go ├── remote ├── conn.go ├── conn_test.go ├── hello │ └── hello.go ├── issue │ └── issue.go ├── transportcredentials.go └── user │ ├── authorization.go │ ├── context.go │ └── user.go ├── renovate.json ├── san ├── protoconv.go ├── san.go └── san_test.go ├── storage ├── config.go ├── issuedb │ ├── issuedb.go │ ├── issuedb_test.go │ └── state.go ├── k8ssecret.go ├── storage.go └── storage_test.go ├── structflags ├── flags.go └── tag.go ├── testutils └── testutils.go ├── tools └── demoenv │ ├── bashrc │ ├── demoenv.sh │ └── sh_wrap.sh ├── version └── version.go └── wcrypto ├── cert.go ├── cert_test.go ├── key.go ├── keytype.go └── token.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kmgm", 3 | "image": "mcr.microsoft.com/devcontainers/go", 4 | "customizations": { 5 | "vscode": { 6 | "settings": { 7 | "terminal.integrated.defaultProfile.linux": "zsh", 8 | "terminal.integrated.profiles.linux": { 9 | "zsh": { 10 | "path": "/usr/bin/zsh", 11 | }, 12 | }, 13 | }, 14 | "extensions": [ 15 | "golang.Go" 16 | ] 17 | } 18 | }, 19 | "postCreateCommand": "./.devcontainer/postCreate.sh", 20 | "remoteUser": "vscode" 21 | } 22 | -------------------------------------------------------------------------------- /.devcontainer/postCreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PREFIX="/usr/local" && \ 3 | VERSION="1.46.0" && \ 4 | curl -sSL \ 5 | "https://github.com/bufbuild/buf/releases/download/v${VERSION}/buf-$(uname -s)-$(uname -m).tar.gz" | \ 6 | sudo tar -xvzf - -C "${PREFIX}" --strip-components 1 7 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test and Release 3 | jobs: 4 | unit_tests: 5 | strategy: 6 | matrix: 7 | platform: [ubuntu-latest, macos-latest] 8 | runs-on: ${{ matrix.platform }} 9 | steps: 10 | - name: Install Go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: '>= 1.21' 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | - name: Test 17 | run: go test ./... 18 | release: 19 | needs: [unit_tests] 20 | if: contains(github.ref, 'tags/v') 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '>= 1.21' 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | version: latest 33 | args: release --clean 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | kmgm 2 | !kmgm/ 3 | *.pem 4 | dist/ 5 | cover.out 6 | trace.out 7 | *.pprof 8 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | # - go mod tidy 4 | # - go generate ./... 5 | builds: 6 | - main: ./cmd/kmgm/main.go 7 | 8 | env: 9 | - CGO_ENABLED=0 10 | 11 | goarch: 12 | - amd64 13 | - arm64 14 | 15 | goos: 16 | - linux 17 | - darwin 18 | 19 | ldflags: 20 | - -s -w 21 | - -X github.com/IPA-CyberLab/kmgm/version.Version={{.Version}} 22 | - -X github.com/IPA-CyberLab/kmgm/version.Commit={{.Commit}} 23 | # - -X main.date={{.Date}} -X main.builtBy=goreleaser 24 | 25 | archives: 26 | - name_template: >- 27 | {{- .ProjectName }}_ 28 | {{- title .Os }}_ 29 | {{- if eq .Arch "amd64" }}x86_64 30 | {{- else if eq .Arch "386" }}i386 31 | {{- else }}{{ .Arch }}{{ end }} 32 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 33 | 34 | checksum: 35 | name_template: 'checksums.txt' 36 | 37 | snapshot: 38 | name_template: "{{ .Tag }}-next" 39 | 40 | changelog: 41 | sort: asc 42 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "test", 12 | "program": "${fileDirname}", 13 | "env": {}, 14 | "args": [] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /FIXME.txt: -------------------------------------------------------------------------------- 1 | // FIXME[P1]: make profile configurable via config file. 2 | 3 | // FIXME[P0]: output cert chain, not just leaf 4 | 5 | // FIXME[P1]: remote issue test: check perm 6 | 7 | // FIXME[P2]: consistency: Certificate -> Cert 8 | // FIXME[P2]: zip / PKCS #12 support 9 | 10 | // FIXME[P3]: prometheus support 11 | 12 | // FIXME[PX]: 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kmgm 2 | **:closed_lock_with_key::link: Generate certs for your cluster, easy way** 3 | 4 | [![Build Status][gh-actions-badge]][gh-actions] 5 | [![go report][go-report-badge]][go-report] 6 | 7 | kmgm is a [certificate authority](https://en.wikipedia.org/wiki/Certificate_authority) with focus on its ease of use. Setup certificates and deploy to your cluster in minutes! 8 | 9 | ![demo session][demo-session-svg] 10 | 11 | ## Installation 12 | 13 | Linux, macOS: 14 | 15 | Install a pre-built binary of the latest version: 16 | 17 | ```sh 18 | curl -L https://github.com/IPA-CyberLab/kmgm/releases/latest/download/kmgm_$(uname)_$(uname -m).tar.gz | sudo tar zx -C /usr/local/bin kmgm 19 | ``` 20 | 21 | Install a pre-built binary of a specific version: 22 | 23 | ```sh 24 | VER=0.3.0; curl -L https://github.com/IPA-CyberLab/kmgm/releases/download/v${VER}/kmgm_$(uname)_$(uname -m).tar.gz | sudo tar zx -C /usr/local/bin kmgm 25 | ``` 26 | 27 | or, to build it yourself: 28 | 29 | ```sh 30 | go get -v -u github.com/IPA-CyberLab/kmgm/cmd/... 31 | ``` 32 | 33 | ## Quick start 34 | 35 | Setup a new CA: 36 | ```sh 37 | kmgm setup 38 | ``` 39 | 40 | Issue a new certificate: 41 | ```sh 42 | kmgm issue 43 | ``` 44 | 45 | ## Tutorials 46 | 47 | - [Setup nginx with kmgm issued certificate](https://github.com/IPA-CyberLab/kmgm/blob/master/docs/tutorials/nginx/README.md) 48 | 49 | ## License 50 | 51 | kmgm is licensed under Apache license version 2.0. See [LICENSE](https://github.com/IPA-CyberLab/kmgm/blob/master/LICENSE) for more information. 52 | 53 | 54 | [go-report-badge]: https://goreportcard.com/badge/github.com/IPA-CyberLab/kmgm 55 | [go-report]: https://goreportcard.com/report/github.com/IPA-CyberLab/kmgm 56 | [gh-actions-badge]: https://github.com/IPA-CyberLab/kmgm/workflows/Test%20and%20Release/badge.svg 57 | [gh-actions]: https://github.com/IPA-CyberLab/kmgm/actions 58 | [demo-session-svg]: https://raw.githubusercontent.com/IPA-CyberLab/kmgm/master/docs/demo.svg 59 | -------------------------------------------------------------------------------- /action/env.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/rand" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | "time" 13 | 14 | "go.uber.org/zap" 15 | "google.golang.org/grpc" 16 | "gopkg.in/yaml.v3" 17 | 18 | "github.com/IPA-CyberLab/kmgm/frontend" 19 | "github.com/IPA-CyberLab/kmgm/remote" 20 | "github.com/IPA-CyberLab/kmgm/remote/hello" 21 | "github.com/IPA-CyberLab/kmgm/storage" 22 | "github.com/IPA-CyberLab/kmgm/wcrypto" 23 | ) 24 | 25 | type Environment struct { 26 | Storage *storage.Storage 27 | Randr io.Reader 28 | Frontend frontend.Frontend 29 | Logger *zap.Logger 30 | NowImpl func() time.Time 31 | PregenKeySupplier func(wcrypto.KeyType) crypto.PrivateKey 32 | 33 | ProfileName string 34 | 35 | ConnectionInfo remote.ConnectionInfo 36 | ClientConn *grpc.ClientConn 37 | } 38 | 39 | func NewEnvironment(fe frontend.Frontend, stor *storage.Storage) (*Environment, error) { 40 | l := zap.L() 41 | 42 | cfg := &Environment{ 43 | Storage: stor, 44 | Randr: rand.Reader, 45 | Frontend: fe, 46 | Logger: l, 47 | NowImpl: time.Now, 48 | 49 | ProfileName: storage.DefaultProfileName, 50 | } 51 | return cfg, nil 52 | } 53 | 54 | func (env *Environment) Clone() *Environment { 55 | return &Environment{ 56 | Storage: env.Storage, 57 | Randr: env.Randr, 58 | Frontend: env.Frontend, 59 | Logger: env.Logger, 60 | NowImpl: env.NowImpl, 61 | 62 | ProfileName: env.ProfileName, 63 | 64 | ConnectionInfo: env.ConnectionInfo, 65 | ClientConn: nil, // Don't clone 66 | } 67 | } 68 | 69 | func (env *Environment) SaveConnectionInfo() error { 70 | path := env.Storage.ConnectionInfoPath() 71 | 72 | bs, err := yaml.Marshal(env.ConnectionInfo) 73 | if err != nil { 74 | return fmt.Errorf("Failed to marshal ConnectionInfo to yaml: %w", err) 75 | } 76 | 77 | if err := ioutil.WriteFile(path, bs, 0644); err != nil { 78 | return fmt.Errorf("Failed to write server connection info to %q: %w", path, err) 79 | } 80 | env.Logger.Sugar().Infof("Wrote server connection info to file %q.", path) 81 | 82 | return nil 83 | } 84 | 85 | func (env *Environment) LoadConnectionInfo() error { 86 | slog := env.Logger.Sugar() 87 | path := env.Storage.ConnectionInfoPath() 88 | 89 | bs, err := os.ReadFile(path) 90 | if err != nil { 91 | if errors.Is(err, os.ErrNotExist) { 92 | slog.Warnf("Could not find server connection info file %q. Ignoring.", path) 93 | return nil 94 | } 95 | return err 96 | } 97 | 98 | if err := yaml.Unmarshal(bs, &env.ConnectionInfo); err != nil { 99 | return fmt.Errorf("Failed to unmarshal server connection info: %w", err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (env *Environment) EnsureClientConn(ctx context.Context) error { 106 | if env.ClientConn != nil { 107 | return nil 108 | } 109 | 110 | conn, err := env.ConnectionInfo.Dial(ctx, env.Logger) 111 | if err != nil { 112 | return err 113 | } 114 | if hello.Hello(ctx, conn, env.Logger); err != nil { 115 | return err 116 | } 117 | 118 | env.ClientConn = conn 119 | return nil 120 | } 121 | 122 | func (env *Environment) Profile() (*storage.Profile, error) { 123 | return env.Storage.Profile(env.ProfileName) 124 | } 125 | 126 | var GlobalEnvironment *Environment 127 | -------------------------------------------------------------------------------- /action/issue/config.go: -------------------------------------------------------------------------------- 1 | package issue 2 | 3 | import ( 4 | "crypto/x509" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "time" 10 | 11 | "go.uber.org/multierr" 12 | 13 | "github.com/IPA-CyberLab/kmgm/dname" 14 | "github.com/IPA-CyberLab/kmgm/keyusage" 15 | "github.com/IPA-CyberLab/kmgm/period" 16 | "github.com/IPA-CyberLab/kmgm/san" 17 | "github.com/IPA-CyberLab/kmgm/wcrypto" 18 | ) 19 | 20 | type Config struct { 21 | Subject *dname.Config `yaml:"subject" flags:""` 22 | Names san.Names `yaml:"subjectAltNames" flags:"subject-alt-name,set cert subjectAltNames,san"` 23 | KeyUsage keyusage.KeyUsage `yaml:"keyUsage" flags:"key-usage,what the key/cert is used for (tlsServer, tlsClient, tlsClientServer),ku"` 24 | Validity period.ValidityPeriod `yaml:"validity" flags:"validity,time duration/timestamp where the cert is valid to (examples: 30d, 1y, 20220530)"` 25 | KeyType wcrypto.KeyType `yaml:"keyType" flags:"key-type,private key type (rsa, ecdsa),t"` 26 | 27 | // Don't create issuedb entry. 28 | NoIssueDBEntry bool 29 | } 30 | 31 | func DefaultConfig(baseSubject *dname.Config) *Config { 32 | var ns san.Names 33 | 34 | override := os.Getenv("KMGM_DEFAULT_NAMES") 35 | if override != "" { 36 | var err error 37 | ns, err = san.Parse(override) 38 | if err != nil { 39 | log.Panicf("Failed to parse KMGM_DEFAULT_NAMES %q: %v", override, err) 40 | } 41 | } else { 42 | ns = san.ForThisHost() 43 | } 44 | 45 | return &Config{ 46 | Subject: dname.DefaultConfig("", baseSubject), 47 | Names: ns, 48 | KeyUsage: keyusage.KeyUsageTLSClientServer.Clone(), 49 | Validity: period.ValidityPeriod{Days: 820}, 50 | KeyType: wcrypto.KeyAny, 51 | } 52 | } 53 | 54 | func EmptyConfig() *Config { 55 | return &Config{ 56 | Subject: &dname.Config{}, 57 | } 58 | } 59 | 60 | func (o *Config) Clone() *Config { 61 | return &Config{ 62 | Subject: o.Subject.Clone(), 63 | Names: o.Names.Clone(), 64 | KeyUsage: o.KeyUsage, 65 | Validity: o.Validity, 66 | KeyType: o.KeyType, 67 | NoIssueDBEntry: o.NoIssueDBEntry, 68 | } 69 | } 70 | 71 | func ConfigFromCert(cert *x509.Certificate) (*Config, error) { 72 | kt, err := wcrypto.KeyTypeOfPub(cert.PublicKey) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | return &Config{ 78 | Subject: dname.FromPkixName(cert.Subject), 79 | Names: san.FromCertificate(cert), 80 | KeyUsage: keyusage.FromCertificate(cert), 81 | Validity: period.ValidityPeriod{NotAfter: cert.NotAfter}, 82 | KeyType: kt, 83 | }, nil 84 | } 85 | 86 | func (a *Config) CompatibleWith(b *Config) error { 87 | if err := a.Subject.CompatibleWith(b.Subject); err != nil { 88 | return err 89 | } 90 | if err := a.Names.CompatibleWith(b.Names); err != nil { 91 | return err 92 | } 93 | if !a.KeyUsage.Equals(b.KeyUsage) { 94 | return fmt.Errorf("KeyUsage mismatch: %v != %v", a.KeyUsage, b.KeyUsage) 95 | } 96 | if err := a.KeyType.CompatibleWith(b.KeyType); err != nil { 97 | return err 98 | } 99 | return nil 100 | } 101 | 102 | const expireThreshold = 30 * time.Second 103 | 104 | var ErrValidityPeriodExpired = errors.New("Declining to issue certificate which expires within 30 seconds.") 105 | 106 | func (cfg *Config) Verify(now time.Time) error { 107 | var merr error 108 | if err := cfg.Subject.Verify(); err != nil { 109 | merr = fmt.Errorf("Subject.%w", err) 110 | } 111 | if err := cfg.Names.Verify(); err != nil { 112 | merr = multierr.Append(merr, fmt.Errorf("Names.%w", err)) 113 | } 114 | if err := cfg.KeyUsage.Verify(); err != nil { 115 | merr = multierr.Append(merr, fmt.Errorf("KeyUsage.%w", err)) 116 | } 117 | if cfg.Validity.GetNotAfter(now).Before(now.Add(expireThreshold)) { 118 | merr = multierr.Append(merr, ErrValidityPeriodExpired) 119 | } 120 | 121 | return merr 122 | } 123 | -------------------------------------------------------------------------------- /action/issue/config_test.go: -------------------------------------------------------------------------------- 1 | package issue_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/IPA-CyberLab/kmgm/action/issue" 7 | "github.com/IPA-CyberLab/kmgm/dname" 8 | "github.com/IPA-CyberLab/kmgm/keyusage" 9 | "github.com/IPA-CyberLab/kmgm/pemparser" 10 | "github.com/IPA-CyberLab/kmgm/san" 11 | "github.com/IPA-CyberLab/kmgm/wcrypto" 12 | ) 13 | 14 | const testCertPem = `-----BEGIN CERTIFICATE----- 15 | MIIBmjCCAUGgAwIBAgIIYw/GUWnaxD4wCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMH 16 | dGVzdCBDQTAeFw0xOTEyMjkxMjUxMDNaFw0yMjAzMjgxMjUyMDNaMBIxEDAOBgNV 17 | BAMMB3Rlc3RfY24wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASJNqgEYpQNmZO9 18 | fDPoqs84G4vl++6gJyumdRny+OX/lqLlb6VdYFmfd5S7XhCHUUp0jGulQO7WxDsn 19 | cXLxHu9do4GAMH4wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMC 20 | BggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFEMkkSwCNkQq7Z6N 21 | MWGhLNpHv5z6MB4GA1UdEQQXMBWCE3Rlc3QtY24uZXhhbXBsZS5jb20wCgYIKoZI 22 | zj0EAwIDRwAwRAIgRtlJshmnNsQKwvMYMTiF4a8XXu1aytz3cmVi8NMy4ykCIAVn 23 | cXdZYB/A9OZlZbB1J6dhVN8z9owmfdznO7ln3Iol 24 | -----END CERTIFICATE-----` 25 | 26 | func TestConfigFromCert(t *testing.T) { 27 | certs, err := pemparser.ParseCertificates([]byte(testCertPem)) 28 | if err != nil { 29 | t.Fatalf("%v", err) 30 | } 31 | if len(certs) != 1 { 32 | t.Fatalf("len(certs)=%d", len(certs)) 33 | } 34 | 35 | cert := certs[0] 36 | cfg, err := issue.ConfigFromCert(cert) 37 | if err != nil { 38 | t.Fatalf("%v", err) 39 | } 40 | if cfg.KeyType != wcrypto.KeySECP256R1 { 41 | t.Errorf("KeyType") 42 | } 43 | expected := &issue.Config{ 44 | Subject: &dname.Config{ 45 | CommonName: "test_cn", 46 | }, 47 | Names: san.MustParse("test-cn.example.com"), 48 | KeyUsage: keyusage.KeyUsageTLSClientServer.Clone(), 49 | KeyType: wcrypto.KeySECP256R1, 50 | } 51 | if err := cfg.CompatibleWith(expected); err != nil { 52 | t.Fatalf("err: %v", err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /action/issue/issue_test.go: -------------------------------------------------------------------------------- 1 | package issue_test 2 | 3 | import ( 4 | "crypto/x509" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/IPA-CyberLab/kmgm/action" 12 | "github.com/IPA-CyberLab/kmgm/action/issue" 13 | "github.com/IPA-CyberLab/kmgm/action/setup" 14 | "github.com/IPA-CyberLab/kmgm/dname" 15 | "github.com/IPA-CyberLab/kmgm/frontend" 16 | "github.com/IPA-CyberLab/kmgm/keyusage" 17 | "github.com/IPA-CyberLab/kmgm/period" 18 | "github.com/IPA-CyberLab/kmgm/storage" 19 | "github.com/IPA-CyberLab/kmgm/wcrypto" 20 | "go.uber.org/zap" 21 | ) 22 | 23 | func init() { 24 | logger, err := zap.NewDevelopment() 25 | if err != nil { 26 | panic(err) 27 | } 28 | zap.ReplaceGlobals(logger) 29 | } 30 | 31 | func testEnv(t *testing.T) (*action.Environment, func()) { 32 | t.Helper() 33 | 34 | basedir, err := ioutil.TempDir("", "kmgm_issue_test") 35 | if err != nil { 36 | t.Fatalf("ioutil.TempDir: %v", err) 37 | } 38 | 39 | stor, err := storage.New(basedir) 40 | if err != nil { 41 | t.Fatalf("storage.New: %v", err) 42 | } 43 | 44 | fe := &frontend.NonInteractive{ 45 | Logger: zap.L(), 46 | } 47 | 48 | env, err := action.NewEnvironment(fe, stor) 49 | if err != nil { 50 | t.Fatalf("action.NewEnvironment: %v", err) 51 | } 52 | env.Frontend = &frontend.NonInteractive{Logger: zap.L()} 53 | 54 | cfg := setup.DefaultConfig(nil) 55 | if err := setup.Run(env, cfg); err != nil { 56 | t.Fatalf("setup.Run: %v", err) 57 | } 58 | 59 | return env, func() { 60 | os.RemoveAll(basedir) 61 | } 62 | } 63 | 64 | func TestIssue(t *testing.T) { 65 | env, teardown := testEnv(t) 66 | t.Cleanup(teardown) 67 | 68 | profile, err := env.Profile() 69 | if err != nil { 70 | t.Fatalf("%v", err) 71 | } 72 | cacert, err := profile.ReadCACertificate() 73 | if err != nil { 74 | t.Fatalf("%v", err) 75 | } 76 | 77 | priv, err := wcrypto.GenerateKey(env.Randr, wcrypto.KeySECP256R1, "", env.Logger) 78 | if err != nil { 79 | t.Fatalf("%v", err) 80 | } 81 | pub, err := wcrypto.ExtractPublicKey(priv) 82 | if err != nil { 83 | t.Fatalf("%v", err) 84 | } 85 | 86 | t.Run("Default", func(t *testing.T) { 87 | cfg := issue.DefaultConfig(nil) 88 | 89 | certDer, err := issue.Run(env, pub, cfg) 90 | if err != nil { 91 | t.Fatalf("issue.Run: %v", err) 92 | } 93 | 94 | cert, err := x509.ParseCertificate(certDer) 95 | if err != nil { 96 | t.Fatalf("x509.ParseCertificate: %v", err) 97 | } 98 | 99 | certpool := x509.NewCertPool() 100 | certpool.AddCert(cacert) 101 | 102 | if _, err := cert.Verify(x509.VerifyOptions{ 103 | Roots: certpool, 104 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 105 | CurrentTime: time.Now(), 106 | }); err != nil { 107 | t.Errorf("%v", err) 108 | } 109 | if _, err := cert.Verify(x509.VerifyOptions{ 110 | Roots: certpool, 111 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 112 | CurrentTime: time.Now().Add(800 * 24 * time.Hour), 113 | }); err != nil { 114 | t.Errorf("%v", err) 115 | } 116 | if _, err := cert.Verify(x509.VerifyOptions{ 117 | Roots: certpool, 118 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 119 | CurrentTime: time.Now().Add(900 * 24 * time.Hour), 120 | }); err == nil { 121 | t.Errorf("should have expired") 122 | } 123 | }) 124 | 125 | t.Run("WrongKeyType", func(t *testing.T) { 126 | cfg := &issue.Config{ 127 | Subject: &dname.Config{CommonName: "test_cn"}, 128 | KeyUsage: keyusage.KeyUsageTLSClientServer.Clone(), 129 | Validity: period.ValidityPeriod{Days: 30}, 130 | KeyType: wcrypto.KeyRSA4096, 131 | } 132 | 133 | _, err := issue.Run(env, pub, cfg) 134 | if errors.Is(err, &wcrypto.UnexpectedKeyTypeErr{}) { 135 | t.Fatalf("issue.Run: %v", err) 136 | } 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /action/serve/authprofile/authprofile.go: -------------------------------------------------------------------------------- 1 | package authprofile 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/IPA-CyberLab/kmgm/action" 8 | "github.com/IPA-CyberLab/kmgm/action/setup" 9 | "github.com/IPA-CyberLab/kmgm/consts" 10 | "github.com/IPA-CyberLab/kmgm/dname" 11 | "github.com/IPA-CyberLab/kmgm/storage" 12 | "github.com/IPA-CyberLab/kmgm/wcrypto" 13 | ) 14 | 15 | // The name of internal storage profile to serve as kmgm HTTPS/gRPC server client auth. 16 | const ProfileName = consts.AuthProfileName 17 | 18 | func Ensure(env *action.Environment) (*storage.Profile, error) { 19 | slog := env.Logger.Sugar() 20 | 21 | profile, err := env.Storage.Profile(ProfileName) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | st := profile.Status(env.NowImpl()) 27 | switch st.Code { 28 | case storage.ValidCA: 29 | return profile, nil 30 | case storage.NotCA: 31 | slog.Infof("Setting up CA for kmgm HTTPS/gRPC server.") 32 | start := time.Now() 33 | defer func() { 34 | slog.Infow("Setting up CA for kmgm HTTPS/gRPC server... Done.", "took", time.Now().Sub(start)) 35 | }() 36 | 37 | // Create CA 38 | envS := env.Clone() 39 | envS.ProfileName = ProfileName 40 | 41 | cfg := setup.DefaultConfig(nil) 42 | cfg.Subject = &dname.Config{ 43 | CommonName: "kmgm serverauth CA", 44 | } 45 | cfg.KeyType = wcrypto.ServerKeyType 46 | 47 | if err := setup.Run(envS, cfg); err != nil { 48 | return nil, fmt.Errorf("Failed to setup serverauth CA: %v", err) 49 | } 50 | 51 | if st := profile.Status(env.NowImpl()); st.Code != storage.ValidCA { 52 | return nil, st 53 | } 54 | return profile, nil 55 | default: 56 | return nil, st 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /action/serve/cert.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "crypto" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/IPA-CyberLab/kmgm/action" 13 | "github.com/IPA-CyberLab/kmgm/action/issue" 14 | "github.com/IPA-CyberLab/kmgm/action/serve/authprofile" 15 | "github.com/IPA-CyberLab/kmgm/dname" 16 | "github.com/IPA-CyberLab/kmgm/keyusage" 17 | "github.com/IPA-CyberLab/kmgm/period" 18 | "github.com/IPA-CyberLab/kmgm/san" 19 | "github.com/IPA-CyberLab/kmgm/storage" 20 | "github.com/IPA-CyberLab/kmgm/wcrypto" 21 | ) 22 | 23 | func ensurePrivateKey(env *action.Environment, authp *storage.Profile) (crypto.PrivateKey, error) { 24 | priv, err := authp.ReadServerPrivateKey() 25 | if err != nil { 26 | if !errors.Is(err, os.ErrNotExist) { 27 | return nil, err 28 | } 29 | 30 | priv, err := wcrypto.GenerateKey(env.Randr, wcrypto.ServerKeyType, "server ", env.Logger) 31 | if err != nil { 32 | return nil, err 33 | } 34 | if err := authp.WriteServerPrivateKey(priv); err != nil { 35 | return nil, err 36 | } 37 | 38 | return priv, nil 39 | } 40 | 41 | return priv, nil 42 | } 43 | 44 | func ensureServerCert(env *action.Environment, authp *storage.Profile, ns san.Names) (*tls.Certificate, string, error) { 45 | l := env.Logger.Sugar().Named("ensureServerCert") 46 | 47 | priv, err := ensurePrivateKey(env, authp) 48 | if err != nil { 49 | return nil, "", err 50 | } 51 | pub, err := wcrypto.ExtractPublicKey(priv) 52 | if err != nil { 53 | return nil, "", err 54 | } 55 | 56 | now := time.Now() 57 | 58 | cacert, err := authp.ReadCACertificate() 59 | if err != nil { 60 | return nil, "", err 61 | } 62 | if err := wcrypto.VerifyCACert(cacert, now); err != nil { 63 | return nil, "", err 64 | } 65 | 66 | cert, err := authp.ReadServerCertificate() 67 | if err == nil { 68 | // FIXME[P2]: also check if ns matches 69 | err = wcrypto.VerifyServerCert(cert, cacert, now) 70 | if err != nil { 71 | l.Infof("Successfully read the server certificate, but it was not valid: %v", err) 72 | cert = nil 73 | } 74 | } 75 | if err != nil { 76 | var srvEnv action.Environment 77 | srvEnv = *env 78 | srvEnv.ProfileName = authprofile.ProfileName 79 | issueCfg := issue.Config{ 80 | Subject: &dname.Config{ 81 | CommonName: "kmgm server", 82 | }, 83 | Names: ns, 84 | KeyUsage: keyusage.KeyUsageTLSServer.Clone(), 85 | Validity: period.FarFuture, 86 | KeyType: wcrypto.ServerKeyType, 87 | 88 | NoIssueDBEntry: true, 89 | } 90 | certDer, err := issue.Run(&srvEnv, pub, &issueCfg) 91 | if err != nil { 92 | return nil, "", fmt.Errorf("Failed to issue server cert: %w", err) 93 | } 94 | cert, err = x509.ParseCertificate(certDer) 95 | if err != nil { 96 | return nil, "", fmt.Errorf("Failed to parse server cert: %w", err) 97 | } 98 | 99 | if err := authp.WriteServerCertificate(cert); err != nil { 100 | return nil, "", err 101 | } 102 | } 103 | 104 | pub, ok := cert.PublicKey.(crypto.PublicKey) 105 | if !ok { 106 | return nil, "", errors.New("Failed to extract cert's public key as crypto.PublicKey") 107 | } 108 | tlscert := &tls.Certificate{ 109 | Certificate: [][]byte{cert.Raw, cacert.Raw}, 110 | PrivateKey: priv, 111 | } 112 | pubkeyhash, err := wcrypto.PubKeyPinString(pub) 113 | if err != nil { 114 | return nil, "", err 115 | } 116 | return tlscert, pubkeyhash, nil 117 | } 118 | -------------------------------------------------------------------------------- /action/serve/certificateservice/service.go: -------------------------------------------------------------------------------- 1 | package certificateservice 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | 14 | "github.com/IPA-CyberLab/kmgm/action" 15 | "github.com/IPA-CyberLab/kmgm/action/issue" 16 | "github.com/IPA-CyberLab/kmgm/action/setup" 17 | "github.com/IPA-CyberLab/kmgm/dname" 18 | "github.com/IPA-CyberLab/kmgm/keyusage" 19 | "github.com/IPA-CyberLab/kmgm/pb" 20 | "github.com/IPA-CyberLab/kmgm/pemparser" 21 | "github.com/IPA-CyberLab/kmgm/period" 22 | "github.com/IPA-CyberLab/kmgm/remote/user" 23 | "github.com/IPA-CyberLab/kmgm/san" 24 | "github.com/IPA-CyberLab/kmgm/storage" 25 | "github.com/IPA-CyberLab/kmgm/storage/issuedb" 26 | "github.com/IPA-CyberLab/kmgm/wcrypto" 27 | ) 28 | 29 | type Service struct { 30 | pb.UnimplementedCertificateServiceServer 31 | 32 | env *action.Environment 33 | } 34 | 35 | var _ = pb.CertificateServiceServer(&Service{}) 36 | 37 | func New(env *action.Environment) (*Service, error) { 38 | return &Service{env: env}, nil 39 | } 40 | 41 | func PbKeyTypeToKeyType(pkt pb.KeyType) (wcrypto.KeyType, error) { 42 | switch pkt { 43 | case pb.KeyType_KEYTYPE_UNSPECIFIED: 44 | return wcrypto.KeyAny, nil 45 | case pb.KeyType_KEYTYPE_RSA4096: 46 | return wcrypto.KeyRSA4096, nil 47 | case pb.KeyType_KEYTYPE_SECP256R1: 48 | return wcrypto.KeySECP256R1, nil 49 | case pb.KeyType_KEYTYPE_RSA2048: 50 | return wcrypto.KeyRSA2048, nil 51 | default: 52 | return wcrypto.KeyAny, fmt.Errorf("Don't know how to convert pb.KeyType(%d)", pkt) 53 | } 54 | } 55 | 56 | func (svc *Service) SetupCA(ctx context.Context, req *pb.SetupCARequest) (*pb.SetupCAResponse, error) { 57 | slog := svc.env.Logger.Sugar() 58 | _ = slog 59 | 60 | u := user.FromContext(ctx) 61 | if !u.IsAllowedToSetupCA(req.Profile) { 62 | return nil, status.Errorf(codes.Unauthenticated, "%v is not allowed to issue certificate.", u) 63 | } 64 | 65 | kt, err := PbKeyTypeToKeyType(req.KeyType) 66 | if err != nil { 67 | return nil, status.Errorf(codes.InvalidArgument, "%v", err) 68 | } 69 | 70 | cfg := &setup.Config{ 71 | Subject: dname.FromProtoStruct(req.Subject), 72 | Validity: period.FarFuture, 73 | KeyType: kt, 74 | } 75 | if req.NotAfterUnixtime != 0 { 76 | cfg.Validity = period.ValidityPeriod{ 77 | NotAfter: time.Unix(req.NotAfterUnixtime, 0).UTC(), 78 | } 79 | } 80 | 81 | envP := svc.env.Clone() 82 | envP.ProfileName = req.Profile 83 | if err := setup.Run(envP, cfg); err != nil { 84 | if errors.Is(err, setup.ErrValidCAExist) { 85 | return nil, status.Errorf(codes.AlreadyExists, "%v", err) 86 | } 87 | 88 | return nil, status.Errorf(codes.Internal, "%v", err) 89 | } 90 | 91 | return &pb.SetupCAResponse{}, nil 92 | } 93 | 94 | func (svc *Service) IssuePreflight(ctx context.Context, req *pb.IssuePreflightRequest) (*pb.IssuePreflightResponse, error) { 95 | slog := svc.env.Logger.Sugar() 96 | 97 | u := user.FromContext(ctx) 98 | if !u.IsAllowedToIssueCertificate(req.Profile) { 99 | return nil, grpc.Errorf(codes.Unauthenticated, "%v is not allowed to issue certificate.", u) 100 | } 101 | 102 | if err := storage.VerifyProfileName(req.Profile); err != nil { 103 | return nil, grpc.Errorf(codes.InvalidArgument, "%v", err) 104 | } 105 | 106 | profile, err := svc.env.Storage.Profile(req.Profile) 107 | if err != nil { 108 | slog.Infof("IssuePreflight: Storage.Profile(%q) returned err: %v", req.Profile, err) 109 | return nil, grpc.Errorf(codes.NotFound, "Failed to access specified profile.") 110 | } 111 | if st := profile.Status(time.Now()); st.Code != storage.ValidCA { 112 | return nil, grpc.Errorf(codes.Internal, "Can't issue certificate from CA profile %q: %v", req.Profile, st) 113 | } 114 | 115 | return &pb.IssuePreflightResponse{}, nil 116 | } 117 | 118 | func (svc *Service) IssueCertificate(ctx context.Context, req *pb.IssueCertificateRequest) (*pb.IssueCertificateResponse, error) { 119 | // FIXME[P1]: log while issue -> rely on grpc middleware logging? 120 | u := user.FromContext(ctx) 121 | if !u.IsAllowedToIssueCertificate(req.Profile) { 122 | return nil, grpc.Errorf(codes.Unauthenticated, "%v is not allowed to issue certificate.", u) 123 | } 124 | 125 | pub, err := x509.ParsePKIXPublicKey(req.PublicKey) 126 | if err != nil { 127 | return nil, grpc.Errorf(codes.InvalidArgument, "Failed to parse PublicKey.") 128 | } 129 | 130 | ns, err := san.FromProtoStruct(req.Names) 131 | if err != nil { 132 | return nil, grpc.Errorf(codes.InvalidArgument, "Failed to parse Names.") 133 | } 134 | 135 | cfg := &issue.Config{ 136 | Subject: dname.FromProtoStruct(req.Subject), 137 | Names: ns, 138 | KeyUsage: keyusage.FromProtoStruct(req.KeyUsage), 139 | Validity: period.ValidityPeriod{ 140 | NotAfter: time.Unix(req.NotAfterUnixtime, 0).UTC(), 141 | }, 142 | } 143 | envP := svc.env.Clone() 144 | envP.ProfileName = req.Profile 145 | certDer, err := issue.Run(envP, pub, cfg) 146 | if err != nil { 147 | return nil, grpc.Errorf(codes.Internal, "%v", err) 148 | } 149 | 150 | return &pb.IssueCertificateResponse{ 151 | Certificate: certDer, 152 | }, nil 153 | } 154 | 155 | func (svc *Service) GetCertificate(ctx context.Context, req *pb.GetCertificateRequest) (*pb.GetCertificateResponse, error) { 156 | u := user.FromContext(ctx) 157 | if !u.IsAllowedToGetCertificate() { 158 | return nil, grpc.Errorf(codes.Unauthenticated, "%v is not allowed to get certificate.", u) 159 | } 160 | env := svc.env 161 | 162 | profile, err := env.Storage.Profile(req.Profile) 163 | if err != nil { 164 | return nil, grpc.Errorf(codes.Internal, "%v", err) 165 | } 166 | 167 | if req.SerialNumber == 0 { 168 | cacert, err := profile.ReadCACertificate() 169 | if err != nil { 170 | return nil, grpc.Errorf(codes.Internal, "%v", err) 171 | } 172 | 173 | return &pb.GetCertificateResponse{Certificate: cacert.Raw}, nil 174 | } 175 | 176 | db, err := issuedb.New(profile.IssueDBPath()) 177 | if err != nil { 178 | return nil, grpc.Errorf(codes.Internal, "%v", err) 179 | } 180 | 181 | e, err := db.Query(req.SerialNumber) 182 | if err != nil { 183 | return nil, grpc.Errorf(codes.Internal, "%v", err) 184 | } 185 | 186 | cs, err := pemparser.ParseCertificates([]byte(e.CertificatePEM)) 187 | if err != nil { 188 | return nil, grpc.Errorf(codes.Internal, "%v", err) 189 | } 190 | if len(cs) != 1 { 191 | return nil, grpc.Errorf(codes.Internal, "multiple certificates unexpected: %v", err) 192 | } 193 | c := cs[0] 194 | 195 | return &pb.GetCertificateResponse{Certificate: c.Raw}, nil 196 | } 197 | -------------------------------------------------------------------------------- /action/serve/certshandler/handler.go: -------------------------------------------------------------------------------- 1 | package certshandler 2 | 3 | import ( 4 | "crypto/x509" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/IPA-CyberLab/kmgm/action" 12 | "github.com/IPA-CyberLab/kmgm/httperr" 13 | "github.com/IPA-CyberLab/kmgm/pemparser" 14 | "github.com/IPA-CyberLab/kmgm/storage/issuedb" 15 | ) 16 | 17 | type Handler struct { 18 | env *action.Environment 19 | } 20 | 21 | func New(env *action.Environment) http.Handler { 22 | return &Handler{env} 23 | } 24 | 25 | func serveCertificate(w http.ResponseWriter, cert *x509.Certificate, filename string) error { 26 | w.Header().Set("X-Content-Type-Options", "nosniff") 27 | w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(filename)) 28 | w.Header().Set("Content-Type", "application/x-pem-file") 29 | certPem := pemparser.MarshalCertificateDer(cert.Raw) 30 | if _, err := w.Write(certPem); err != nil { 31 | return fmt.Errorf("Failed to output PEM: %w", err) 32 | } 33 | return nil 34 | } 35 | 36 | func (h *Handler) serveHTTPIfPossible(w http.ResponseWriter, r *http.Request) error { 37 | if r.Method != "GET" { 38 | return httperr.ErrorWithStatusCode{ 39 | StatusCode: http.StatusBadRequest, 40 | Err: errors.New("Unsupported method")} 41 | } 42 | 43 | env := h.env.Clone() 44 | slog := env.Logger.Sugar() 45 | 46 | var queryStr string 47 | 48 | args := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") 49 | switch len(args) { 50 | case 1: 51 | queryStr = args[0] 52 | case 2: 53 | env.ProfileName = args[0] 54 | queryStr = args[1] 55 | default: 56 | return httperr.ErrorWithStatusCode{ 57 | StatusCode: http.StatusNotFound, 58 | Err: errors.New("bad path")} 59 | } 60 | queryStr = strings.TrimSuffix(queryStr, ".pem") 61 | 62 | slog.Debugf("certshandler GET profile: %s, queryStr: %s", env.ProfileName, queryStr) 63 | 64 | profile, err := env.Profile() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | switch queryStr { 70 | case "": 71 | w.Header().Set("Content-Type", "text/plain") 72 | w.Write([]byte("/issue/[profileName]/[query].pem\n")) 73 | return nil 74 | case "cacert": 75 | cacert, err := profile.ReadCACertificate() 76 | if err != nil { 77 | return fmt.Errorf("Failed to query cacert: %w", err) 78 | } 79 | 80 | return serveCertificate(w, cacert, "cacert.pem") 81 | } 82 | 83 | db, err := issuedb.New(profile.IssueDBPath()) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | db.Query(123) 89 | 90 | return nil 91 | } 92 | 93 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 94 | slog := h.env.Logger.Sugar() 95 | 96 | if err := h.serveHTTPIfPossible(w, r); err != nil { 97 | slog.Warnw("Failed to process request", "err", err) 98 | http.Error(w, fmt.Sprintf("Failed to process request: %v", err), httperr.StatusCodeFromError(err)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /action/serve/config.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/IPA-CyberLab/kmgm/san" 7 | ) 8 | 9 | type Config struct { 10 | ListenAddr string `flags:"listen-addr,server listen host:addr (:34680)"` 11 | ReusePort bool `flags:"reuse-port,set SO_REUSEPORT on listener socket"` 12 | 13 | Names san.Names `flags:"subject-alt-name,set subjectAltNames to use on server certificate,san"` 14 | 15 | IssueHttp int `flags:"issue-http,enable certificate issue via HTTP API"` 16 | 17 | ExposeMetrics bool `flags:"expose-metrics,expose /metrics HTTP endpoint for prometheus to crawl"` 18 | 19 | AutoShutdown time.Duration `flags:"auto-shutdown,auto shutdown server after specified time,,duration"` 20 | 21 | // Enable node bootstrapping with the given auth provider. 22 | Bootstrap TokenAuthProvider 23 | } 24 | 25 | func DefaultConfig() (*Config, error) { 26 | cfg := &Config{ 27 | ListenAddr: ":34680", 28 | ReusePort: false, 29 | IssueHttp: 0, 30 | } 31 | return cfg, nil 32 | } 33 | -------------------------------------------------------------------------------- /action/serve/hello_service.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/IPA-CyberLab/kmgm/pb" 7 | "github.com/IPA-CyberLab/kmgm/remote/user" 8 | ) 9 | 10 | type helloService struct { 11 | pb.UnimplementedHelloServiceServer 12 | } 13 | 14 | var _ = pb.HelloServiceServer(&helloService{}) 15 | 16 | func (helloService) Hello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) { 17 | u := user.FromContext(ctx) 18 | return &pb.HelloResponse{ 19 | ApiVersion: pb.ApiVersion, 20 | AuthenticationType: u.Type, 21 | AuthenticatedUser: u.Name, 22 | }, nil 23 | } 24 | -------------------------------------------------------------------------------- /action/serve/httpzaplog/handler.go: -------------------------------------------------------------------------------- 1 | package httpzaplog 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type Handler struct { 10 | Upstream http.Handler 11 | *zap.Logger 12 | } 13 | 14 | func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 15 | var remoteCN string 16 | if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { 17 | peerCert := r.TLS.PeerCertificates[0] 18 | remoteCN = peerCert.Subject.CommonName 19 | } 20 | 21 | h.Logger.Info("ServeHTTP", 22 | zap.String("url", r.URL.String()), 23 | zap.String("remote_addr", r.RemoteAddr), 24 | zap.String("remote_common_name", remoteCN), 25 | ) 26 | 27 | h.Upstream.ServeHTTP(w, r) 28 | } 29 | -------------------------------------------------------------------------------- /action/serve/tokenauth.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "log" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type TokenAuthProvider interface { 18 | Authenticate(token string, now time.Time) error 19 | LogHelpMessage(listenAddr, pubkeyhash string) 20 | } 21 | 22 | type FixedTokenAuthProvider struct { 23 | Token string 24 | NotAfter time.Time 25 | Logger *zap.Logger 26 | } 27 | 28 | var _ = TokenAuthProvider(&FixedTokenAuthProvider{}) 29 | 30 | var ErrBadToken = errors.New("Bad token.") 31 | var ErrTokenExpired = errors.New("Token expired.") 32 | 33 | func (ta *FixedTokenAuthProvider) Authenticate(t string, now time.Time) error { 34 | slog := ta.Logger.Sugar() 35 | 36 | if t != ta.Token { 37 | slog.Infof("Bad token %q provided.") 38 | return ErrBadToken 39 | } 40 | if now.After(ta.NotAfter) { 41 | slog.Infof("Token provided has expired. It was valid until %v.", ta.NotAfter) 42 | return ErrTokenExpired 43 | } 44 | slog.Debugf("Token auth succeeded.") 45 | return nil 46 | } 47 | 48 | func (ta *FixedTokenAuthProvider) LogHelpMessage(listenAddr, pubkeyhash string) { 49 | slog := ta.Logger.Sugar() 50 | 51 | // FIXME[P3]: make NotAfter configurable. 52 | slog.Infof("Node bootstrap enabled for 15 minutes using token: %s", ta.Token) 53 | slog.Infof("For your convenience, bootstrap command-line to be executed on your clients would look like: kmgm client --server %s --pinnedpubkey %s --token %s bootstrap", FormatListenAddr(listenAddr), pubkeyhash, ta.Token) 54 | } 55 | 56 | type tokenFileAuthProvider struct { 57 | path string 58 | logger *zap.Logger 59 | } 60 | 61 | var _ = TokenAuthProvider(&tokenFileAuthProvider{}) 62 | 63 | func NewTokenFileAuthProvider(path string, logger *zap.Logger) (TokenAuthProvider, error) { 64 | slog := logger.Sugar() 65 | 66 | apath, err := filepath.Abs(path) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | ta := &tokenFileAuthProvider{path: apath, logger: logger} 72 | slog.Debugf("Initialized tokenFileAuthProvider{%q}", apath) 73 | 74 | if _, err := ioutil.ReadFile(ta.path); err != nil { 75 | slog.Infof("Failed to read token file: %v", err) 76 | } 77 | 78 | return ta, nil 79 | } 80 | 81 | func (ta *tokenFileAuthProvider) Authenticate(t string, now time.Time) error { 82 | slog := ta.logger.Sugar() 83 | 84 | st, err := os.Stat(ta.path) 85 | if err != nil { 86 | slog.Warnf("Failed to stat token file: %v", err) 87 | return ErrBadToken 88 | } 89 | modt := st.ModTime() 90 | // FIXME[P3]: make NotAfter configurable. 91 | if now.Sub(modt) > 15*time.Minute { 92 | slog.Warnf("The token file is too old. It must be modified w/in 15 min to be valid") 93 | return ErrBadToken 94 | } 95 | 96 | bs, err := ioutil.ReadFile(ta.path) 97 | if err != nil { 98 | slog.Warnf("Failed to read token file: %v", err) 99 | return ErrBadToken 100 | } 101 | token := strings.TrimSpace(string(bs)) 102 | if t != token { 103 | slog.Infof("Expected token %q, but got %q.", token, t) 104 | return ErrBadToken 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (ta *tokenFileAuthProvider) LogHelpMessage(listenAddr, pubkeyhash string) { 111 | slog := ta.logger.Sugar() 112 | 113 | slog.Infof("Node bootstrap enabled. Token is read from file %q.", ta.path) 114 | slog.Infof("For your convenience, bootstrap command-line to be executed on your clients would look like: kmgm client --server %s --pinnedpubkey %s --token [token] bootstrap", FormatListenAddr(listenAddr), pubkeyhash) 115 | } 116 | 117 | var ipDocker = net.IPv4(172, 17, 0, 1) 118 | 119 | // FormatListenAddr takes a hostport str, and appends an interface ip addr as 120 | // a host if the original host was empty or 0.0.0.0. 121 | func FormatListenAddr(listenAddr string) string { 122 | host, port, err := net.SplitHostPort(listenAddr) 123 | if err != nil { 124 | log.Panicf("Failed to net.SplitHostPort(%q): %v", listenAddr, err) 125 | } 126 | if host == "" || host == "0.0.0.0" { 127 | if addrs, err := net.InterfaceAddrs(); err == nil { 128 | type candidate struct { 129 | Host string 130 | Score int 131 | } 132 | cs := make([]candidate, 0, len(addrs)) 133 | for _, addr := range addrs { 134 | if ipaddr, ok := addr.(*net.IPNet); ok { 135 | ip := ipaddr.IP 136 | c := candidate{Host: ip.String(), Score: 100} 137 | if ip4 := ip.To4(); len(ip4) == net.IPv4len { 138 | c.Score += 50 139 | } 140 | if ip.IsLoopback() { 141 | c.Score -= 10 142 | } 143 | if ip.Equal(ipDocker) { 144 | c.Score -= 20 145 | } 146 | if ip.IsLinkLocalUnicast() { 147 | c.Score -= 30 148 | } 149 | cs = append(cs, c) 150 | } 151 | } 152 | 153 | // sort by .Score desc 154 | sort.Slice(cs, func(i, j int) bool { return cs[i].Score > cs[j].Score }) 155 | host = cs[0].Host 156 | } 157 | } 158 | 159 | return net.JoinHostPort(host, port) 160 | } 161 | -------------------------------------------------------------------------------- /action/serve/tokenauth_test.go: -------------------------------------------------------------------------------- 1 | package serve_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | 11 | "github.com/IPA-CyberLab/kmgm/action/serve" 12 | ) 13 | 14 | var TestLogger *zap.Logger 15 | 16 | func init() { 17 | logger, err := zap.NewDevelopment() 18 | if err != nil { 19 | panic(err) 20 | } 21 | zap.ReplaceGlobals(logger) 22 | TestLogger = logger 23 | } 24 | 25 | func TestTokenFileAuthProvider(t *testing.T) { 26 | tmpfile, err := ioutil.TempFile("", "tokenfile") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | defer os.Remove(tmpfile.Name()) 31 | 32 | now := time.Now() 33 | 34 | ta, err := serve.NewTokenFileAuthProvider(tmpfile.Name(), TestLogger) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | if err := ta.Authenticate("foobar", now); err == nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if _, err := tmpfile.Write([]byte(" validtoken\t\t \n")); err != nil { 43 | t.Fatal(err) 44 | } 45 | // We need Close() here since we read the tokenfile in the following ta.Authenticate calls. 46 | if err := tmpfile.Close(); err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | if err := ta.Authenticate("validtoken", now); err != nil { 51 | t.Fatal(err) 52 | } 53 | if err := ta.Authenticate("invalidtoken", now); err == nil { 54 | t.Fatal(err) 55 | } 56 | if err := ta.Authenticate("validtoken", now.Add(20*time.Minute)); err == nil { 57 | t.Fatal(err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /action/serve/version_service.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/IPA-CyberLab/kmgm/pb" 7 | "github.com/IPA-CyberLab/kmgm/version" 8 | ) 9 | 10 | type versionService struct { 11 | pb.UnimplementedVersionServiceServer 12 | } 13 | 14 | var _ = pb.VersionServiceServer(&versionService{}) 15 | 16 | func (versionService) GetVersion(ctx context.Context, req *pb.GetVersionRequest) (*pb.GetVersionResponse, error) { 17 | return &pb.GetVersionResponse{ 18 | Version: version.Version, 19 | Commit: version.Commit, 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /action/setup/config.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "crypto/x509" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/IPA-CyberLab/kmgm/dname" 10 | "github.com/IPA-CyberLab/kmgm/period" 11 | "github.com/IPA-CyberLab/kmgm/wcrypto" 12 | ) 13 | 14 | var ( 15 | ErrSubjectEmpty = errors.New("CA Subject must not be empty") 16 | ErrValidityPeriodExpired = errors.New("Declining to setup CA which expires within 30 seconds") 17 | ErrKeyTypeAny = errors.New("KeyType cannot be KeyAny, please specify a specific key algorithm such as KeyRSA4096") 18 | ) 19 | 20 | type Config struct { 21 | Subject *dname.Config `yaml:"subject" flags:""` 22 | Validity period.ValidityPeriod `yaml:"validity" flags:"validity,time duration/timestamp where the cert is valid to (examples: 30d, 1y, 20220530)"` 23 | KeyType wcrypto.KeyType `yaml:"keyType" flags:"key-type,private key type (rsa, ecdsa),t"` 24 | 25 | NameConstraints NameConstraints `yaml:"nameConstraints"` 26 | } 27 | 28 | func DefaultConfig(baseSubject *dname.Config) *Config { 29 | return &Config{ 30 | Subject: dname.DefaultConfig(" CA", baseSubject), 31 | KeyType: wcrypto.KeyRSA4096, 32 | Validity: period.FarFuture, 33 | } 34 | } 35 | 36 | func EmptyConfig() *Config { 37 | return &Config{ 38 | Subject: &dname.Config{}, 39 | } 40 | } 41 | 42 | func ConfigFromCert(cert *x509.Certificate) (*Config, error) { 43 | kt, err := wcrypto.KeyTypeOfPub(cert.PublicKey) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return &Config{ 49 | Subject: dname.FromPkixName(cert.Subject), 50 | KeyType: kt, 51 | Validity: period.ValidityPeriod{NotAfter: cert.NotAfter}, 52 | }, nil 53 | } 54 | 55 | func (a *Config) CompatibleWith(b *Config) error { 56 | if err := a.Subject.CompatibleWith(b.Subject); err != nil { 57 | return err 58 | } 59 | if a.KeyType != b.KeyType { 60 | return fmt.Errorf("KeyType mismatch: %v != %v", a.KeyType, b.KeyType) 61 | } 62 | return nil 63 | } 64 | 65 | const expireThreshold = 30 * time.Second 66 | 67 | func (cfg *Config) Verify(now time.Time) error { 68 | if err := cfg.Subject.Verify(); err != nil { 69 | return fmt.Errorf("Subject.%w", err) 70 | } 71 | if cfg.Subject.IsEmpty() { 72 | return ErrSubjectEmpty 73 | } 74 | if cfg.Validity.GetNotAfter(now).Before(now.Add(expireThreshold)) { 75 | return ErrValidityPeriodExpired 76 | } 77 | if cfg.KeyType == wcrypto.KeyAny { 78 | return ErrKeyTypeAny 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /action/setup/config_test.go: -------------------------------------------------------------------------------- 1 | package setup_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/IPA-CyberLab/kmgm/action/setup" 9 | "github.com/IPA-CyberLab/kmgm/dname" 10 | "github.com/IPA-CyberLab/kmgm/period" 11 | "github.com/IPA-CyberLab/kmgm/wcrypto" 12 | ) 13 | 14 | func TestConfigVerify_SubjectEmpty(t *testing.T) { 15 | cfg := &setup.Config{ 16 | Subject: &dname.Config{}, 17 | KeyType: wcrypto.KeyRSA4096, 18 | Validity: period.FarFuture, 19 | } 20 | 21 | if err := cfg.Verify(time.Now()); !errors.Is(err, setup.ErrSubjectEmpty) { 22 | t.Errorf("Unexpected: %v", err) 23 | } 24 | } 25 | 26 | func TestConfigVerify_Expired(t *testing.T) { 27 | now := time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC) 28 | 29 | cfg := &setup.Config{ 30 | Subject: &dname.Config{CommonName: "foo"}, 31 | KeyType: wcrypto.KeyAny, 32 | Validity: period.ValidityPeriod{NotAfter: now.Add(-1 * time.Hour)}, 33 | } 34 | 35 | if err := cfg.Verify(now); !errors.Is(err, setup.ErrValidityPeriodExpired) { 36 | t.Errorf("Unexpected: %v", err) 37 | } 38 | } 39 | 40 | func TestConfigVerify_KeyTypeAny(t *testing.T) { 41 | cfg := &setup.Config{ 42 | Subject: &dname.Config{CommonName: "foo"}, 43 | KeyType: wcrypto.KeyAny, 44 | Validity: period.FarFuture, 45 | } 46 | 47 | if err := cfg.Verify(time.Now()); !errors.Is(err, setup.ErrKeyTypeAny) { 48 | t.Errorf("Unexpected: %v", err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /action/setup/nameconstraints.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | type NameConstraints struct { 9 | PermittedDNSDomains []string 10 | ExcludedDNSDomains []string 11 | 12 | PermittedIPRanges []*net.IPNet 13 | ExcludedIPRanges []*net.IPNet 14 | } 15 | 16 | func (p *NameConstraints) UnmarshalYAML(unmarshal func(interface{}) error) error { 17 | var ss []string 18 | if err := unmarshal(&ss); err != nil { 19 | return err 20 | } 21 | 22 | for _, s := range ss { 23 | excludeRule := false 24 | if s[0] == '-' { 25 | excludeRule = true 26 | s = s[1:] 27 | } else if s[0] == '+' { 28 | s = s[1:] 29 | } 30 | 31 | _, net, err := net.ParseCIDR(s) 32 | if err == nil { 33 | if excludeRule { 34 | p.ExcludedIPRanges = append(p.ExcludedIPRanges, net) 35 | } else { 36 | p.PermittedIPRanges = append(p.PermittedIPRanges, net) 37 | } 38 | } else { 39 | if excludeRule { 40 | p.ExcludedDNSDomains = append(p.ExcludedDNSDomains, s) 41 | } else { 42 | p.PermittedDNSDomains = append(p.PermittedDNSDomains, s) 43 | } 44 | } 45 | } 46 | return nil 47 | } 48 | 49 | func (nc *NameConstraints) IsEmpty() bool { 50 | if len(nc.PermittedDNSDomains) > 0 { 51 | return false 52 | } 53 | if len(nc.ExcludedDNSDomains) > 0 { 54 | return false 55 | } 56 | if len(nc.PermittedIPRanges) > 0 { 57 | return false 58 | } 59 | if len(nc.ExcludedIPRanges) > 0 { 60 | return false 61 | } 62 | 63 | return true 64 | } 65 | 66 | func (nc *NameConstraints) Strings() []string { 67 | var ss []string 68 | for _, e := range nc.PermittedDNSDomains { 69 | ss = append(ss, fmt.Sprintf("+%s", e)) 70 | } 71 | for _, e := range nc.ExcludedDNSDomains { 72 | ss = append(ss, fmt.Sprintf("-%s", e)) 73 | } 74 | for _, e := range nc.PermittedIPRanges { 75 | ss = append(ss, fmt.Sprintf("+%v", e)) 76 | } 77 | for _, e := range nc.ExcludedIPRanges { 78 | ss = append(ss, fmt.Sprintf("-%v", e)) 79 | } 80 | 81 | return ss 82 | } 83 | -------------------------------------------------------------------------------- /action/setup/nameconstraints_test.go: -------------------------------------------------------------------------------- 1 | package setup_test 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "gopkg.in/yaml.v3" 9 | 10 | "github.com/IPA-CyberLab/kmgm/action/setup" 11 | ) 12 | 13 | func TestNameConstraints_UnmarshalYAML(t *testing.T) { 14 | src := ` 15 | - example.org 16 | - 192.0.2.0/24 17 | - 2001:db8:1::/48 18 | - -10.0.0.0/8 19 | - "-bad.example" 20 | - "+ipa.go.jp" 21 | ` 22 | 23 | var nc setup.NameConstraints 24 | if err := yaml.Unmarshal([]byte(src), &nc); err != nil { 25 | t.Fatalf("unmarshal failed: %v", err) 26 | } 27 | 28 | actual := nc.Strings() 29 | expected := strings.Split("+example.org +ipa.go.jp -bad.example +192.0.2.0/24 +2001:db8:1::/48 -10.0.0.0/8", " ") 30 | if !reflect.DeepEqual(actual, expected) { 31 | t.Errorf("unexp: %s", actual) 32 | } 33 | 34 | remarshaled, err := yaml.Marshal(actual) 35 | if err != nil { 36 | t.Fatalf("marshal failed: %v", err) 37 | } 38 | 39 | var nc2 setup.NameConstraints 40 | if err := yaml.Unmarshal(remarshaled, &nc2); err != nil { 41 | t.Fatalf("unmarshal failed: %v", err) 42 | } 43 | 44 | if !reflect.DeepEqual(nc, nc2) { 45 | t.Errorf("noneq") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /action/setup/setup.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "crypto" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "math/big" 9 | "time" 10 | 11 | // zx509 "github.com/zmap/zcrypto/x509" 12 | // "github.com/zmap/zlint" 13 | 14 | "github.com/IPA-CyberLab/kmgm/action" 15 | "github.com/IPA-CyberLab/kmgm/consts" 16 | "github.com/IPA-CyberLab/kmgm/frontend/validate" 17 | "github.com/IPA-CyberLab/kmgm/storage" 18 | "github.com/IPA-CyberLab/kmgm/storage/issuedb" 19 | "github.com/IPA-CyberLab/kmgm/wcrypto" 20 | ) 21 | 22 | func createCertificate(env *action.Environment, cfg *Config, priv crypto.PrivateKey) ([]byte, error) { 23 | slog := env.Logger.Sugar().With( 24 | "profile", env.ProfileName, 25 | "subject", cfg.Subject.ToPkixName().String(), 26 | "notafter", cfg.Validity, 27 | ) 28 | 29 | start := time.Now() 30 | slog.Infow("Generating self-signed CA certificate...") 31 | 32 | pub, err := wcrypto.ExtractPublicKey(priv) 33 | if err != nil { 34 | return nil, err 35 | } 36 | pubkeyhash, err := wcrypto.PubKeyPinString(pub) 37 | if err != nil { 38 | pubkeyhash = fmt.Sprintf("Error: %v", err) 39 | } 40 | slog = slog.With("pubkeyhash", pubkeyhash) 41 | 42 | kt, err := wcrypto.KeyTypeOfPub(pub) 43 | if err != nil { 44 | return nil, err 45 | } 46 | if err := cfg.KeyType.CompatibleWith(kt); err != nil { 47 | return nil, err 48 | } 49 | 50 | ski, err := wcrypto.SubjectKeyIdFromPubkey(pub) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | now := env.NowImpl() 56 | t := &x509.Certificate{ 57 | // AuthorityKeyId meaningless for self-signed 58 | BasicConstraintsValid: true, 59 | 60 | // Subject Alternate Name Values should be empty for CA: 61 | // - DNSNames 62 | // - EmailAddresses 63 | // - IPAddresses 64 | // - URIs 65 | 66 | // https://tools.ietf.org/html/rfc5280#section-4.2.2.1 67 | // OCSPServer: []string{}, 68 | // IssuingCertificateURL: []string{}, 69 | 70 | // https://tools.ietf.org/html/rfc3647 71 | // PolicyIdentifiers: []asn1.ObjectIdentifier{}, 72 | 73 | IsCA: true, 74 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 75 | // ExtKeyUsage: []ExtKeyUsage{}, 76 | // UnknownExtKeyUsage: []asn1.ObjectIdentifier{}, 77 | 78 | // ExtraExtensions: []pkix.Extension, 79 | 80 | // pathlen of 0 : Sign end-user certs only 81 | MaxPathLen: 0, 82 | MaxPathLenZero: true, 83 | 84 | NotAfter: cfg.Validity.GetNotAfter(now).UTC(), 85 | NotBefore: now.Add(-consts.NodesOutOfSyncThreshold).UTC(), 86 | 87 | // FIXME[P2]: https://crypto.stackexchange.com/questions/257/unpredictability-of-x-509-serial-numbers 88 | SerialNumber: new(big.Int).SetInt64(1), 89 | 90 | // SignatureAlgorithm will be auto-specified by x509 package 91 | 92 | Subject: cfg.Subject.ToPkixName(), 93 | 94 | // https://security.stackexchange.com/questions/200295/the-difference-between-subject-key-identifier-and-sha1fingerprint-in-x509-certif 95 | // https://security.stackexchange.com/questions/27797/what-damage-could-be-done-if-a-malicious-certificate-had-an-identical-subject-k?rq=1 96 | SubjectKeyId: ski, 97 | 98 | // CRLDistributionPoints: FIXME 99 | 100 | PermittedDNSDomainsCritical: !cfg.NameConstraints.IsEmpty(), 101 | PermittedDNSDomains: cfg.NameConstraints.PermittedDNSDomains, 102 | ExcludedDNSDomains: cfg.NameConstraints.ExcludedDNSDomains, 103 | PermittedIPRanges: cfg.NameConstraints.PermittedIPRanges, 104 | ExcludedIPRanges: cfg.NameConstraints.ExcludedIPRanges, 105 | 106 | // FIXME[P2]: Support more name constraints: 107 | // https://tools.ietf.org/html/rfc5280#section-4.2.1.10 108 | // PermittedEmailAddresses 109 | // ExcludedEmailAddresses 110 | // PermittedURIDomains 111 | // ExcludedURIDomains 112 | } 113 | 114 | parent := t // self signed cert 115 | 116 | certDer, err := x509.CreateCertificate(env.Randr, t, parent, pub, priv) 117 | if err != nil { 118 | return nil, fmt.Errorf("Create a self-signed CA cert failed: %w", err) 119 | } 120 | slog.Infow("Generating a self-signed CA certificate... Done.", "took", time.Since(start)) 121 | return certDer, nil 122 | } 123 | 124 | var ErrValidCAExist = errors.New("Valid CA already exists.") 125 | 126 | func Run(env *action.Environment, cfg *Config) error { 127 | slog := env.Logger.Sugar() 128 | 129 | profile, err := env.Profile() 130 | if err != nil { 131 | return err 132 | } 133 | 134 | st := profile.Status(env.NowImpl()) 135 | switch st.Code { 136 | case storage.NotCA: 137 | break 138 | case storage.ValidCA: 139 | return ErrValidCAExist 140 | case storage.Broken: 141 | return fmt.Errorf("Broken CA already exists.") 142 | case storage.Expired: 143 | return fmt.Errorf("Expired CA already exists.") 144 | } 145 | 146 | idb, err := issuedb.New(profile.IssueDBPath()) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | if err := validate.MkdirAndCheckWritable(profile.CAPrivateKeyPath()); err != nil { 152 | return fmt.Errorf("Prepare private key destination: %w", err) 153 | } 154 | if err := validate.MkdirAndCheckWritable(profile.CACertPath()); err != nil { 155 | return fmt.Errorf("Prepare CA cert destination: %w", err) 156 | } 157 | if err := idb.Initialize(); err != nil { 158 | return err 159 | } 160 | 161 | var priv crypto.PrivateKey 162 | ktype := cfg.KeyType 163 | if env.PregenKeySupplier != nil { 164 | slog.Errorf("!!!DANGEROUS - FOR TEST ONLY!!! Using unsafe, pregenerated key of type %v", ktype) 165 | priv = env.PregenKeySupplier(ktype) 166 | } else { 167 | priv, err = wcrypto.GenerateKey(env.Randr, ktype, "CA", env.Logger) 168 | if err != nil { 169 | return err 170 | } 171 | } 172 | if err := profile.WriteCAPrivateKey(priv); err != nil { 173 | return err 174 | } 175 | slog.Infof("The CA private key saved to file: %s", profile.CAPrivateKeyPath()) 176 | 177 | certDer, err := createCertificate(env, cfg, priv) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | if err := profile.WriteCACertificateDer(certDer); err != nil { 183 | return err 184 | } 185 | slog.Infof("The CA certificate saved to file: %s", profile.CACertPath()) 186 | 187 | return nil 188 | } 189 | -------------------------------------------------------------------------------- /action/setup/setup_test.go: -------------------------------------------------------------------------------- 1 | package setup_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/IPA-CyberLab/kmgm/action" 10 | "github.com/IPA-CyberLab/kmgm/action/setup" 11 | "github.com/IPA-CyberLab/kmgm/frontend" 12 | "github.com/IPA-CyberLab/kmgm/storage" 13 | ) 14 | 15 | func init() { 16 | logger, err := zap.NewDevelopment() 17 | if err != nil { 18 | panic(err) 19 | } 20 | zap.ReplaceGlobals(logger) 21 | } 22 | 23 | func testEnv(t *testing.T) (*action.Environment, func()) { 24 | t.Helper() 25 | 26 | basedir, err := os.MkdirTemp("", "kmgm_issue_test") 27 | if err != nil { 28 | t.Fatalf("ioutil.TempDir: %v", err) 29 | } 30 | 31 | stor, err := storage.New(basedir) 32 | if err != nil { 33 | t.Fatalf("storage.New: %v", err) 34 | } 35 | 36 | fe := &frontend.NonInteractive{ 37 | Logger: zap.L(), 38 | } 39 | 40 | env, err := action.NewEnvironment(fe, stor) 41 | if err != nil { 42 | t.Fatalf("action.NewEnvironment: %v", err) 43 | } 44 | env.Frontend = &frontend.NonInteractive{Logger: zap.L()} 45 | 46 | return env, func() { 47 | os.RemoveAll(basedir) 48 | } 49 | } 50 | 51 | func TestSetup_Default(t *testing.T) { 52 | env, teardown := testEnv(t) 53 | t.Cleanup(teardown) 54 | 55 | cfg := setup.DefaultConfig(nil) 56 | if err := setup.Run(env, cfg); err != nil { 57 | t.Fatalf("setup.Run: %v", err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | plugins: 3 | - remote: buf.build/grpc/go:v1.5.1 4 | out: . 5 | opt: paths=source_relative 6 | - remote: buf.build/protocolbuffers/go:v1.35.1 7 | out: . 8 | opt: paths=source_relative 9 | -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | breaking: 3 | use: 4 | - FILE 5 | lint: 6 | use: 7 | - DEFAULT 8 | -------------------------------------------------------------------------------- /cmd/kmgm/app/appflags/appflags.go: -------------------------------------------------------------------------------- 1 | package appflags 2 | 3 | type AppFlags struct { 4 | BaseDir string `yaml:"baseDir" flags:"basedir,The root directory storing all kmgm data,"` 5 | Profile string `yaml:"profile" flags:"profile,Name of the profile to operate against"` 6 | Config string `flags:"config,Read the specified YAML config file instead of interactive prompt.,c,path"` 7 | LogJson bool `flags:"log-json,Format logs in json"` 8 | NoGeoIp bool `flags:"no-geo-ip,Disable querying ip-api.com for geolocation data"` 9 | LogLocation bool `flags:"log-location,Annotate logs with code location where the log was output"` 10 | NoDefault bool `yaml:"noDefault" flags:"no-default,Disable populating default values on non-interactive mode,"` 11 | Verbose bool `flags:"verbose,Enable verbose output"` 12 | NonInteractive bool `flags:"non-interactive,Use non-interactive frontend, which auto proceeds with default answers."` 13 | } 14 | -------------------------------------------------------------------------------- /cmd/kmgm/batch/batch.go: -------------------------------------------------------------------------------- 1 | package batch 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/urfave/cli/v2" 9 | "go.uber.org/multierr" 10 | "gopkg.in/yaml.v3" 11 | 12 | "github.com/IPA-CyberLab/kmgm/action" 13 | "github.com/IPA-CyberLab/kmgm/action/issue" 14 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/app/appflags" 15 | issuecmd "github.com/IPA-CyberLab/kmgm/cmd/kmgm/issue" 16 | setupcmd "github.com/IPA-CyberLab/kmgm/cmd/kmgm/setup" 17 | "github.com/IPA-CyberLab/kmgm/dname" 18 | "github.com/IPA-CyberLab/kmgm/frontend" 19 | "github.com/IPA-CyberLab/kmgm/period" 20 | "github.com/IPA-CyberLab/kmgm/storage" 21 | "github.com/IPA-CyberLab/kmgm/structflags" 22 | ) 23 | 24 | const ConfigTemplateText = ` 25 | --- 26 | # kmgm pki batch config 27 | baseDir: /my/pki/dir 28 | 29 | setup: 30 | subject: 31 | commonName: my CA 32 | validity: farfuture 33 | 34 | copyCACertPath: my-ca.crt 35 | 36 | issues: 37 | - certPath: leaf1.cert.pem 38 | privateKeyPath: leaf1.key.pem 39 | subject: 40 | commonName: leaf1 41 | subjectAltNames: 42 | - leaf1.example 43 | keyType: ecdsa 44 | validity: farfuture 45 | - certPath: leaf2.cert.pem 46 | privateKeyPath: leaf2.key.pem 47 | subject: 48 | commonName: leaf2 49 | validity: 90d 50 | renewBefore: 7d 51 | 52 | ` 53 | 54 | type Config struct { 55 | Setup *setupcmd.Config `yaml:"setup"` 56 | 57 | Issues []*issuecmd.Config `yaml:"issues"` 58 | 59 | // This is here to avoid yaml.v3 Decoder with KnownFields(true) throwing error for valid AppFlags fields 60 | XXX_AppFlags appflags.AppFlags `yaml:",inline"` 61 | } 62 | 63 | var ErrYamlMustBeProvided = errors.New("batch: yaml config must be provided. Try `kmgm -c [config.yaml] batch`") 64 | 65 | func Action(c *cli.Context) error { 66 | af := c.App.Metadata["AppFlags"].(*appflags.AppFlags) 67 | 68 | env := action.GlobalEnvironment 69 | slog := env.Logger.Sugar() 70 | 71 | var cfg *Config 72 | if c.Bool("dump-template") || !af.NoDefault { 73 | slog.Infof("Constructing default config.") 74 | 75 | cfg = &Config{ 76 | Setup: setupcmd.DefaultConfig(env), 77 | } 78 | } else { 79 | slog.Infof("Config is from scratch.") 80 | 81 | cfg = &Config{ 82 | Setup: setupcmd.EmptyConfig(), 83 | } 84 | } 85 | 86 | if c.Bool("dump-template") { 87 | if err := frontend.DumpTemplate(ConfigTemplateText, cfg); err != nil { 88 | return err 89 | } 90 | return nil 91 | } 92 | 93 | cfgbs, ok := c.App.Metadata["config"] 94 | if !ok { 95 | return ErrYamlMustBeProvided 96 | } 97 | 98 | decodeConfig := func() error { 99 | r := bytes.NewBuffer(cfgbs.([]byte)) 100 | 101 | d := yaml.NewDecoder(r) 102 | d.KnownFields(true) 103 | 104 | return d.Decode(cfg) 105 | } 106 | if err := decodeConfig(); err != nil { 107 | return err 108 | } 109 | 110 | profile, err := env.Profile() 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if err := setupcmd.EnsureCA(env, cfg.Setup, profile, setupcmd.AllowNonInteractiveSetupAndRequireCompatibleConfig); err != nil { 116 | return err 117 | } 118 | if err := setupcmd.CopyCACert(env, cfg.Setup, profile); err != nil { 119 | return err 120 | } 121 | 122 | if !af.NoDefault { 123 | slog.Infof("Rereading config with to process cert issue with updated defaults.") 124 | 125 | origTemplate := issuecmd.UnmarshalConfigTemplate 126 | defer func() { 127 | issuecmd.UnmarshalConfigTemplate = origTemplate 128 | }() 129 | 130 | now := env.NowImpl() 131 | st := profile.Status(now) 132 | if st.Code != storage.ValidCA { 133 | return fmt.Errorf("BUG: CA profile %q is not valid: %v", env.ProfileName, st) 134 | } 135 | baseSubject := dname.FromPkixName(st.CACert.Subject) 136 | issuecmd.UnmarshalConfigTemplate = &issuecmd.Config{ 137 | Issue: issue.DefaultConfig(baseSubject), 138 | RenewBefore: period.DaysAuto, 139 | } 140 | 141 | cfg = &Config{Setup: cfg.Setup} 142 | if err := decodeConfig(); err != nil { 143 | return err 144 | } 145 | } 146 | 147 | processIssue := func(issueCfg *issuecmd.Config) error { 148 | if issueCfg.PrivateKeyPath == "" { 149 | return fmt.Errorf("privateKeyPath must be specified") 150 | } 151 | if err := issuecmd.PrepareKeyTypePath(env, &issueCfg.Issue.KeyType, &issueCfg.PrivateKeyPath); err != nil { 152 | return err 153 | } 154 | 155 | if issueCfg.CertPath == "" { 156 | return fmt.Errorf("certPath must be specified") 157 | } 158 | newCertPath, err := issuecmd.PromptCertPath(env, issueCfg.PrivateKeyPath, issueCfg.CertPath) 159 | if err != nil { 160 | return err 161 | } 162 | issueCfg.CertPath = newCertPath 163 | 164 | if err := issueCfg.Verify(env, af.NoDefault); err != nil { 165 | return err 166 | } 167 | if err := issuecmd.IssuePrivateKeyAndCertificateFile(c.Context, env, issuecmd.Local{}, issueCfg); err != nil { 168 | return err 169 | } 170 | return nil 171 | } 172 | 173 | var merr error 174 | for i, issueCfg := range cfg.Issues { 175 | slog.Infof("batch: processing issue[%d]: %v", i, issueCfg.Issue.Subject) 176 | err := processIssue(issueCfg) 177 | if err != nil { 178 | slog.Errorf("batch: issue[%d]: %v", i, err) 179 | merr = multierr.Append(merr, fmt.Errorf("batch: issue[%d]: %w", i, err)) 180 | } 181 | } 182 | 183 | return merr 184 | } 185 | 186 | var Command = &cli.Command{ 187 | Name: "batch", 188 | Usage: "Processes a set of kmgm commands.", 189 | Flags: append(structflags.MustPopulateFlagsFromStruct(Config{}), 190 | &cli.BoolFlag{ 191 | Name: "dump-template", 192 | Usage: "dump configuration template yaml without making actual changes", 193 | }, 194 | ), 195 | Action: Action, 196 | } 197 | -------------------------------------------------------------------------------- /cmd/kmgm/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | 7 | "github.com/urfave/cli/v2" 8 | 9 | "github.com/IPA-CyberLab/kmgm/action" 10 | "github.com/IPA-CyberLab/kmgm/storage" 11 | "github.com/IPA-CyberLab/kmgm/storage/issuedb" 12 | ) 13 | 14 | // FIXME[PX]: [DB op] list all issued certs 15 | // FIXME[PX]: [DB op] check that issued certs expires > threshold 16 | 17 | const dateFormat = "06/01/02" 18 | 19 | func CertInfo(c *x509.Certificate) string { 20 | return fmt.Sprintf("%s %s %v", 21 | c.NotBefore.Format(dateFormat), 22 | c.NotAfter.Format(dateFormat), 23 | c.Subject) 24 | } 25 | 26 | func LsProfile(env *action.Environment) error { 27 | now := env.NowImpl() 28 | 29 | ps, err := env.Storage.Profiles() 30 | if err != nil { 31 | return fmt.Errorf("Failed to list profiles: %w", err) 32 | } 33 | 34 | for _, p := range ps { 35 | fmt.Printf("%s %s\n", p.Name(), p.Status(now)) 36 | } 37 | 38 | return nil 39 | } 40 | 41 | var Command = &cli.Command{ 42 | Name: "list", 43 | Usage: "List certificates issued", 44 | Aliases: []string{"ls"}, 45 | Flags: []cli.Flag{ 46 | &cli.BoolFlag{ 47 | Name: "profile", 48 | Aliases: []string{"p", "profiles"}, 49 | Usage: "Print list of profiles", 50 | }, 51 | }, 52 | Action: func(c *cli.Context) error { 53 | env := action.GlobalEnvironment 54 | slog := env.Logger.Sugar() 55 | 56 | // FIXME[P3]: Unless verbose, omit time/level logging as well 57 | 58 | if c.Bool("profile") { 59 | if err := LsProfile(env); err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | profile, err := env.Profile() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | now := env.NowImpl() 71 | st := profile.Status(now) 72 | if st.Code != storage.ValidCA { 73 | if st.Code == storage.Expired { 74 | slog.Warnf("Expired %s") 75 | } else { 76 | slog.Infof("Could not find a valid CA profile %q: %v", env.ProfileName, st) 77 | return nil 78 | } 79 | } 80 | 81 | db, err := issuedb.New(profile.IssueDBPath()) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | es, err := db.Entries() 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // 1 2 3 4 5 6 7 92 | // 01234567890123456789012345678901234567890123456789012345678901234567890123456789 93 | // 01234567 0123456789012345678 94 | fmt.Printf(" YY/MM/DD YY/MM/DD\n") 95 | fmt.Printf("Status SerialNumber NotBefor NotAfter Subject\n") 96 | for _, e := range es { 97 | cert, err := e.ParseCertificate() 98 | var infotxt string 99 | if err != nil { 100 | infotxt = fmt.Sprintf("error: Failed to parse PEM: %v", err) 101 | } else { 102 | infotxt = CertInfo(cert) 103 | } 104 | 105 | switch e.State { 106 | case issuedb.IssueInProgress: 107 | fmt.Printf("issueing %19d\n", e.SerialNumber) 108 | 109 | case issuedb.ActiveCertificate: 110 | fmt.Printf("active %19d %s\n", e.SerialNumber, infotxt) 111 | } 112 | } 113 | 114 | return nil 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /cmd/kmgm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/app" 10 | ) 11 | 12 | type ExitCoder interface { 13 | ExitCode() int 14 | } 15 | 16 | func ExitCodeOfError(err error) int { 17 | for { 18 | if ec, ok := err.(ExitCoder); ok { 19 | return ec.ExitCode() 20 | } 21 | 22 | if err = errors.Unwrap(err); err == nil { 23 | break 24 | } 25 | } 26 | 27 | return 1 28 | } 29 | 30 | func main() { 31 | if err := app.New().Run(os.Args); err != nil { 32 | // omit stacktrace 33 | zap.L().WithOptions(zap.AddStacktrace(zap.FatalLevel)).Error(err.Error()) 34 | os.Exit(ExitCodeOfError(err)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cmd/kmgm/remote/bootstrap/bootstrap.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "errors" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/urfave/cli/v2" 11 | 12 | action "github.com/IPA-CyberLab/kmgm/action" 13 | localissue "github.com/IPA-CyberLab/kmgm/action/issue" 14 | "github.com/IPA-CyberLab/kmgm/action/serve/authprofile" 15 | "github.com/IPA-CyberLab/kmgm/dname" 16 | "github.com/IPA-CyberLab/kmgm/frontend/validate" 17 | "github.com/IPA-CyberLab/kmgm/keyusage" 18 | "github.com/IPA-CyberLab/kmgm/remote/issue" 19 | "github.com/IPA-CyberLab/kmgm/storage" 20 | "github.com/IPA-CyberLab/kmgm/period" 21 | "github.com/IPA-CyberLab/kmgm/wcrypto" 22 | ) 23 | 24 | func EnsureKey(env *action.Environment) (crypto.PrivateKey, error) { 25 | slog := env.Logger.Sugar() 26 | privPath := env.Storage.ClientPrivateKeyPath() 27 | 28 | priv, err := storage.ReadPrivateKeyFile(privPath) 29 | if err == nil { 30 | slog.Infof("Using existing client private key %q.", privPath) 31 | return priv, nil 32 | } 33 | if !errors.Is(err, os.ErrNotExist) { 34 | return nil, err 35 | } 36 | 37 | if err := validate.MkdirAndCheckWritable(privPath); err != nil { 38 | return nil, err 39 | } 40 | priv, err = wcrypto.GenerateKey(env.Randr, wcrypto.ServerKeyType, "clientauth", env.Logger) 41 | if err != nil { 42 | return nil, err 43 | } 44 | if err := storage.WritePrivateKeyFile(privPath, priv); err != nil { 45 | return nil, err 46 | } 47 | slog.Infof("Wrote client private key to %q.", privPath) 48 | return priv, nil 49 | } 50 | 51 | func IssueCertPair(ctx context.Context, env *action.Environment) error { 52 | slog := env.Logger.Sugar() 53 | 54 | priv, err := EnsureKey(env) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | certPath := env.Storage.ClientCertPath() 60 | _, err = storage.ReadCertificateFile(certPath) 61 | if err == nil { 62 | slog.Warnf("Existing kmgm client cert found. Not requesting new cert.") 63 | slog.Warnf("To force re-bootstrapping, delete file %q and try again.", certPath) 64 | 65 | return nil 66 | } 67 | if !errors.Is(err, os.ErrNotExist) { 68 | return fmt.Errorf("Error when reading existing client auth cert %q. Try removing the file and retry: %w", certPath, err) 69 | } 70 | if err := validate.MkdirAndCheckWritable(certPath); err != nil { 71 | return err 72 | } 73 | 74 | pub, err := wcrypto.ExtractPublicKey(priv) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | hostname, err := os.Hostname() 80 | if err != nil { 81 | return err 82 | } 83 | cfg := &localissue.Config{ 84 | Subject: &dname.Config{ 85 | CommonName: hostname, 86 | }, 87 | KeyUsage: keyusage.KeyUsageTLSClient, 88 | Validity: period.FarFuture, 89 | KeyType: wcrypto.ServerKeyType, 90 | } 91 | certDer, err := issue.IssueCertificate(ctx, env, pub, cfg) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | if err := storage.WriteCertificateDerFile(certPath, certDer); err != nil { 97 | return err 98 | } 99 | slog.Infof("Wrote issued client cert to %q.", certPath) 100 | return nil 101 | } 102 | 103 | var Command = &cli.Command{ 104 | Name: "bootstrap", 105 | Usage: "Register this client to the kmgm HTTPS/gRPC server", 106 | Flags: []cli.Flag{}, 107 | Action: func(c *cli.Context) error { 108 | env := action.GlobalEnvironment 109 | slog := env.Logger.Sugar() 110 | 111 | if env.ProfileName != storage.DefaultProfileName && 112 | env.ProfileName != authprofile.ProfileName { 113 | slog.Warnf("Specified --profile %q setting is ignored in bootstrap cmd.", env.ProfileName) 114 | } 115 | env.ProfileName = authprofile.ProfileName 116 | 117 | if err := env.EnsureClientConn(c.Context); err != nil { 118 | return err 119 | } 120 | if err := IssueCertPair(c.Context, env); err != nil { 121 | return err 122 | } 123 | 124 | // Rewrite ConnectionInfo to use the issued client certificate instead of bootstrap token. 125 | env.ConnectionInfo.AccessToken = "" 126 | env.ConnectionInfo.ClientCertificateFile = env.Storage.ClientCertPath() 127 | env.ConnectionInfo.ClientPrivateKeyFile = env.Storage.ClientPrivateKeyPath() 128 | 129 | if err := env.SaveConnectionInfo(); err != nil { 130 | return err 131 | } 132 | 133 | return nil 134 | }, 135 | } 136 | -------------------------------------------------------------------------------- /cmd/kmgm/remote/issue/issue.go: -------------------------------------------------------------------------------- 1 | package issue 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/x509" 7 | 8 | "github.com/urfave/cli/v2" 9 | 10 | "github.com/IPA-CyberLab/kmgm/action" 11 | localissue "github.com/IPA-CyberLab/kmgm/cmd/kmgm/issue" 12 | "github.com/IPA-CyberLab/kmgm/pb" 13 | "github.com/IPA-CyberLab/kmgm/remote/issue" 14 | "github.com/IPA-CyberLab/kmgm/structflags" 15 | ) 16 | 17 | type Remote struct { 18 | } 19 | 20 | var _ = localissue.Strategy(Remote{}) 21 | 22 | func (Remote) EnsureCA(ctx context.Context, env *action.Environment) error { 23 | if err := env.EnsureClientConn(ctx); err != nil { 24 | return err 25 | } 26 | 27 | sc := pb.NewCertificateServiceClient(env.ClientConn) 28 | resp, err := sc.IssuePreflight(ctx, &pb.IssuePreflightRequest{ 29 | Profile: env.ProfileName, 30 | }) 31 | if err != nil { 32 | return err 33 | } 34 | _ = resp 35 | 36 | return nil 37 | } 38 | 39 | func (Remote) CACert(ctx context.Context, env *action.Environment) *x509.Certificate { 40 | slog := env.Logger.Sugar() 41 | 42 | if err := env.EnsureClientConn(ctx); err != nil { 43 | slog.Debugf("CASubject: EnsureClientConn: %v", err) 44 | return nil 45 | } 46 | 47 | sc := pb.NewCertificateServiceClient(env.ClientConn) 48 | resp, err := sc.GetCertificate(ctx, &pb.GetCertificateRequest{ 49 | Profile: env.ProfileName, 50 | SerialNumber: 0, 51 | }) 52 | if err != nil { 53 | slog.Debugf("CASubject: GetCertificate: %v", err) 54 | return nil 55 | } 56 | cert, err := x509.ParseCertificate(resp.Certificate) 57 | if err != nil { 58 | slog.Debugf("CASubject: ParseCertificate: %v", err) 59 | return nil 60 | } 61 | 62 | return cert 63 | } 64 | 65 | func (Remote) Issue(ctx context.Context, env *action.Environment, pub crypto.PublicKey, cfg *issue.Config) ([]byte, error) { 66 | return issue.IssueCertificate(ctx, env, pub, cfg) 67 | } 68 | 69 | var Command = &cli.Command{ 70 | Name: "issue", 71 | Usage: "Issue a new certificate or renew an existing certificate. Generates private key if needed.", 72 | Flags: structflags.MustPopulateFlagsFromStruct(localissue.Config{}), 73 | Action: func(c *cli.Context) error { 74 | return localissue.ActionImpl(Remote{}, c) 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /cmd/kmgm/remote/show/show.go: -------------------------------------------------------------------------------- 1 | package show 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "strconv" 7 | 8 | "github.com/IPA-CyberLab/kmgm/action" 9 | localshow "github.com/IPA-CyberLab/kmgm/cmd/kmgm/show" 10 | "github.com/IPA-CyberLab/kmgm/pb" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func FindCertificateWithPrefix(ctx context.Context, env *action.Environment, prefix string) (*x509.Certificate, error) { 15 | if err := env.EnsureClientConn(ctx); err != nil { 16 | return nil, err 17 | } 18 | 19 | sc := pb.NewCertificateServiceClient(env.ClientConn) 20 | 21 | var err error 22 | sn := int64(0) 23 | 24 | if prefix != "" && prefix != "ca" { 25 | sn, err = strconv.ParseInt(prefix, 10, 64) 26 | if err != nil { 27 | return nil, err 28 | } 29 | } 30 | // FIXME[P1] actually perform prefix match 31 | 32 | resp, err := sc.GetCertificate(ctx, &pb.GetCertificateRequest{ 33 | Profile: env.ProfileName, 34 | SerialNumber: sn, 35 | }) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | cert, err := x509.ParseCertificate(resp.Certificate) 41 | if err != nil { 42 | return nil, err 43 | } 44 | return cert, nil 45 | } 46 | 47 | var Command = &cli.Command{ 48 | Name: "show", 49 | Usage: "Show CA status, an existing certificate and/or its key.", 50 | UsageText: localshow.Command.UsageText, 51 | Flags: localshow.Command.Flags, 52 | Action: localshow.ActionImpl(FindCertificateWithPrefix), 53 | } 54 | -------------------------------------------------------------------------------- /cmd/kmgm/remote/subcommand.go: -------------------------------------------------------------------------------- 1 | package remote 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | 6 | action "github.com/IPA-CyberLab/kmgm/action" 7 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/remote/bootstrap" 8 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/remote/issue" 9 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/remote/show" 10 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/remote/version" 11 | "github.com/IPA-CyberLab/kmgm/remote" 12 | "github.com/IPA-CyberLab/kmgm/structflags" 13 | ) 14 | 15 | var Command = &cli.Command{ 16 | Name: "client", 17 | Aliases: []string{"c", "cli"}, 18 | Usage: "Interact with remote CA", 19 | Flags: structflags.MustPopulateFlagsFromStruct(remote.ConnectionInfo{}), 20 | Before: func(c *cli.Context) error { 21 | env := action.GlobalEnvironment 22 | if err := env.LoadConnectionInfo(); err != nil { 23 | return err 24 | } 25 | if err := structflags.PopulateStructFromCliContext(&env.ConnectionInfo, c); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | }, 31 | Subcommands: []*cli.Command{ 32 | bootstrap.Command, 33 | issue.Command, 34 | version.Command, 35 | show.Command, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /cmd/kmgm/remote/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | action "github.com/IPA-CyberLab/kmgm/action" 9 | "github.com/IPA-CyberLab/kmgm/pb" 10 | ) 11 | 12 | func queryVersion(ctx context.Context, env *action.Environment) error { 13 | slog := env.Logger.Sugar() 14 | 15 | sc := pb.NewVersionServiceClient(env.ClientConn) 16 | resp, err := sc.GetVersion(ctx, &pb.GetVersionRequest{}) 17 | if err != nil { 18 | return err 19 | } 20 | slog.Infof("Version: %s", resp.Version) 21 | slog.Infof("Commit: %s", resp.Commit) 22 | 23 | return nil 24 | } 25 | 26 | var Command = &cli.Command{ 27 | Name: "version", 28 | Usage: "Query remote CA kmgm version", 29 | Action: func(c *cli.Context) error { 30 | env := action.GlobalEnvironment 31 | if err := env.EnsureClientConn(c.Context); err != nil { 32 | return err 33 | } 34 | 35 | return queryVersion(c.Context, env) 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /cmd/kmgm/remote_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/serve/testserver" 9 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/testkmgm" 10 | "github.com/IPA-CyberLab/kmgm/storage" 11 | "github.com/IPA-CyberLab/kmgm/testutils" 12 | ) 13 | 14 | func TestServe_Noop(t *testing.T) { 15 | _ = testserver.Run(t) 16 | } 17 | 18 | func TestServe_Issue(t *testing.T) { 19 | ts := testserver.Run(t) 20 | basedir := testutils.PrepareBasedir(t) 21 | 22 | t.Run("bootstrap", func(t *testing.T) { 23 | logs, err := testkmgm.Run(t, context.Background(), basedir, nil, []string{"client", "--server", ts.AddrPort, "--cacert", ts.CACertPath, "--token", testserver.BootstrapToken, "bootstrap"}, testkmgm.NowDefault) 24 | testutils.ExpectErr(t, err, nil) 25 | testutils.ExpectLogMessage(t, logs, "Wrote server connection info to file ") 26 | }) 27 | 28 | t.Run("nonexistent profile", func(t *testing.T) { 29 | _, err := testkmgm.Run(t, context.Background(), basedir, nil, []string{"--profile", "noexist", "client", "issue"}, testkmgm.NowDefault) 30 | testutils.ExpectErrMessage(t, err, `Can't issue certificate from CA profile "noexist"`) 31 | }) 32 | 33 | t.Run("specify profile", func(t *testing.T) { 34 | yaml := []byte(` 35 | noDefault: true 36 | 37 | subject: 38 | commonName: myCA 39 | validity: farfuture 40 | keyType: ecdsa 41 | `) 42 | logs, err := testkmgm.Run(t, context.Background(), ts.Basedir, yaml, []string{"--profile", "myprofile", "setup"}, testkmgm.NowDefault) 43 | testutils.ExpectErr(t, err, nil) 44 | testutils.ExpectLogMessage(t, logs, "CA setup successfully completed") 45 | 46 | certPath := filepath.Join(basedir, "issue.cert.pem") 47 | logs, err = testkmgm.Run(t, context.Background(), basedir, nil, []string{"--profile", "myprofile", "client", "issue", "--cert", certPath}, testkmgm.NowDefault) 48 | testutils.ExpectErr(t, err, nil) 49 | testutils.ExpectLogMessage(t, logs, `Generating certificate... Done.`) 50 | 51 | cert, err := storage.ReadCertificateFile(certPath) 52 | if err != nil { 53 | t.Fatalf("cert read: %v", err) 54 | } 55 | 56 | if cert.Issuer.String() != "CN=myCA" { 57 | t.Fatalf("unexpected cert issuer: %v", cert.Issuer) 58 | } 59 | }) 60 | 61 | } 62 | -------------------------------------------------------------------------------- /cmd/kmgm/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/urfave/cli/v2" 9 | 10 | action "github.com/IPA-CyberLab/kmgm/action" 11 | "github.com/IPA-CyberLab/kmgm/action/serve" 12 | "github.com/IPA-CyberLab/kmgm/structflags" 13 | "github.com/IPA-CyberLab/kmgm/wcrypto" 14 | ) 15 | 16 | var IssueHttpDefaultShutdown = 5 * time.Minute 17 | 18 | var Command = &cli.Command{ 19 | Name: "serve", 20 | Usage: "Start Komagome PKI HTTPS/gRPC API Server", 21 | Aliases: []string{"s", "srv"}, 22 | Flags: append(structflags.MustPopulateFlagsFromStruct(serve.Config{}), 23 | &cli.BoolFlag{ 24 | Name: "bootstrap", 25 | Usage: "enable node bootstrapping via (generated) token.", 26 | }, 27 | &cli.StringFlag{ 28 | Name: "bootstrap-token", 29 | Usage: "enable node bootstrapping using the specified token.", 30 | }, 31 | &cli.PathFlag{ 32 | Name: "bootstrap-token-file", 33 | Usage: "enable node bootstrapping using the specified token file.", 34 | }, 35 | ), 36 | Action: func(c *cli.Context) error { 37 | env := action.GlobalEnvironment 38 | 39 | cfg, err := serve.DefaultConfig() 40 | if err != nil { 41 | return err 42 | } 43 | if err := structflags.PopulateStructFromCliContext(cfg, c); err != nil { 44 | return err 45 | } 46 | 47 | if !c.IsSet("auto-shutdown") && cfg.IssueHttp > 0 { 48 | cfg.AutoShutdown = IssueHttpDefaultShutdown 49 | } 50 | 51 | if c.IsSet("bootstrap") || c.IsSet("bootstrap-token") { 52 | if c.IsSet("bootstrap-token-file") { 53 | return errors.New("--bootstrap-token-file can't be specified when --bootstrap,--bootstrap-token is specified.") 54 | } 55 | 56 | token := c.String("bootstrap-token") 57 | if token == "" { 58 | token, err = wcrypto.GenBase64Token(env.Randr, env.Logger) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | cfg.Bootstrap = &serve.FixedTokenAuthProvider{ 64 | Token: token, 65 | NotAfter: time.Now().Add(15 * time.Minute), 66 | Logger: env.Logger, 67 | } 68 | } else if c.IsSet("bootstrap-token-file") { 69 | ta, err := serve.NewTokenFileAuthProvider( 70 | c.String("bootstrap-token-file"), env.Logger) 71 | if err != nil { 72 | return fmt.Errorf("Failed to initialize tokenFileAuthProvider: %w", err) 73 | } 74 | cfg.Bootstrap = ta 75 | } 76 | 77 | return serve.Run(c.Context, env, cfg) 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /cmd/kmgm/serve/testserver/testserver.go: -------------------------------------------------------------------------------- 1 | package testserver 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "fmt" 7 | "net" 8 | "path/filepath" 9 | "testing" 10 | "time" 11 | 12 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/testkmgm" 13 | "github.com/IPA-CyberLab/kmgm/storage" 14 | "github.com/IPA-CyberLab/kmgm/testutils" 15 | "github.com/IPA-CyberLab/kmgm/wcrypto" 16 | "github.com/prometheus/client_golang/prometheus" 17 | ) 18 | 19 | const BootstrapToken = "testtoken" 20 | 21 | type TestServer struct { 22 | AddrPort string 23 | CACertPath string 24 | CACert *x509.Certificate 25 | PubKeyHash string 26 | Basedir string 27 | } 28 | 29 | type option struct { 30 | RunSetup bool 31 | } 32 | 33 | type Option func(*option) 34 | 35 | func RunSetup(o *option) { o.RunSetup = true } 36 | 37 | func Run(t *testing.T, tsos ...Option) *TestServer { 38 | t.Helper() 39 | 40 | o := &option{} 41 | for _, tso := range tsos { 42 | tso(o) 43 | } 44 | 45 | r := prometheus.NewRegistry() 46 | prometheus.DefaultRegisterer = r 47 | prometheus.DefaultGatherer = r 48 | 49 | basedir := testutils.PrepareBasedir(t) 50 | 51 | if o.RunSetup { 52 | logs, err := testkmgm.Run(t, context.Background(), basedir, nil, []string{"setup"}, testkmgm.NowDefault) 53 | if err != nil { 54 | t.Fatalf("Failed to run setup: %v", err) 55 | } 56 | testutils.ExpectLogMessage(t, logs, "CA setup successfully completed") 57 | } 58 | 59 | testPort := 34000 60 | addrPort := fmt.Sprintf("127.0.0.1:%d", testPort) 61 | cacertPath := filepath.Join(basedir, ".kmgm_server/cacert.pem") 62 | 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | 65 | joinC := make(chan struct{}) 66 | go func() { 67 | logs, err := testkmgm.Run(t, ctx, basedir, nil, []string{"serve", "--reuse-port", "--listen-addr", addrPort, "--bootstrap-token", BootstrapToken}, testkmgm.NowDefault) 68 | _ = err // expectErr(t, err, context.Canceled) // not always reliable 69 | testutils.ExpectLogMessage(t, logs, "Started listening") 70 | close(joinC) 71 | }() 72 | 73 | for i := 0; i < 10; i++ { 74 | conn, err := net.Dial("tcp", addrPort) 75 | if err != nil { 76 | t.Logf("net.Dial(%s) error: %v", addrPort, err) 77 | 78 | time.Sleep(100 * time.Millisecond) 79 | continue 80 | } 81 | conn.Close() 82 | t.Logf("net.Dial(%s) success", addrPort) 83 | break 84 | } 85 | 86 | cacert, err := storage.ReadCertificateFile(cacertPath) 87 | if err != nil { 88 | t.Fatalf("Failed to read cacert: %v", err) 89 | } 90 | pubkeyhash, err := wcrypto.PubKeyPinString(cacert.PublicKey) 91 | if err != nil { 92 | t.Fatalf("Failed to compute pubkeyhash: %v", err) 93 | } 94 | 95 | t.Cleanup(func() { 96 | cancel() 97 | <-joinC 98 | }) 99 | return &TestServer{ 100 | AddrPort: addrPort, 101 | CACertPath: cacertPath, 102 | CACert: cacert, 103 | PubKeyHash: pubkeyhash, 104 | Basedir: basedir, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /cmd/kmgm/show/format.go: -------------------------------------------------------------------------------- 1 | package show 2 | 3 | import "fmt" 4 | 5 | type FormatType int 6 | 7 | const ( 8 | FormatFull FormatType = iota 9 | FormatPEM 10 | ) 11 | 12 | func (t FormatType) String() string { 13 | switch t { 14 | case FormatFull: 15 | return "full" 16 | case FormatPEM: 17 | return "pem" 18 | default: 19 | return fmt.Sprintf("unknown_formattype_%d", int(t)) 20 | } 21 | } 22 | 23 | func FormatTypeFromString(s string) (FormatType, error) { 24 | switch s { 25 | case "full": 26 | return FormatFull, nil 27 | case "pem": 28 | return FormatPEM, nil 29 | default: 30 | return FormatFull, fmt.Errorf("Unknown format %q.", s) 31 | } 32 | } 33 | 34 | func (t FormatType) ShouldOutputInfo() bool { 35 | switch t { 36 | case FormatFull: 37 | return true 38 | default: 39 | return false 40 | } 41 | } 42 | 43 | func (t FormatType) ShouldOutputPEM() bool { 44 | switch t { 45 | case FormatFull, FormatPEM: 46 | return true 47 | default: 48 | return false 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/kmgm/show/ifchanged.go: -------------------------------------------------------------------------------- 1 | package show 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | ) 9 | 10 | type IfChangedWriteFile struct { 11 | f *os.File 12 | originalContent []byte 13 | buf bytes.Buffer 14 | } 15 | 16 | var _ io.Writer = &IfChangedWriteFile{} 17 | 18 | func NewIfChangedWriteFile(path string) (*IfChangedWriteFile, error) { 19 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666) 20 | if err != nil { 21 | f.Close() 22 | return nil, err 23 | } 24 | originalContent, err := ioutil.ReadAll(f) 25 | if err != nil { 26 | f.Close() 27 | return nil, err 28 | } 29 | 30 | return &IfChangedWriteFile{ 31 | f: f, 32 | originalContent: originalContent, 33 | }, nil 34 | } 35 | 36 | func (wf *IfChangedWriteFile) Write(bs []byte) (int, error) { 37 | return wf.buf.Write(bs) 38 | } 39 | 40 | func (wf *IfChangedWriteFile) Close() error { 41 | bs := wf.buf.Bytes() 42 | if !bytes.Equal(wf.originalContent, bs) { 43 | if _, err := wf.f.Seek(0, os.SEEK_SET); err != nil { 44 | wf.f.Close() 45 | return err 46 | } 47 | if _, err := wf.f.Write(bs); err != nil { 48 | wf.f.Close() 49 | return err 50 | } 51 | } 52 | 53 | if err := wf.f.Close(); err != nil { 54 | return err 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /cmd/kmgm/testkmgm/gen.go: -------------------------------------------------------------------------------- 1 | package testkmgm 2 | 3 | //go:generate go run generate_testkeys.go 4 | -------------------------------------------------------------------------------- /cmd/kmgm/testkmgm/generate_testkeys.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "crypto" 7 | "crypto/rand" 8 | "fmt" 9 | "os" 10 | 11 | "go.uber.org/zap" 12 | 13 | "github.com/IPA-CyberLab/kmgm/pemparser" 14 | "github.com/IPA-CyberLab/kmgm/wcrypto" 15 | ) 16 | 17 | func main() { 18 | logger := zap.NewExample().Sugar() 19 | 20 | rsa2048keys := []crypto.PrivateKey{} 21 | for i := 0; i < 10; i++ { 22 | logger.Infof("Generating rsa2048key[%d]:", i) 23 | pk, err := wcrypto.GenerateKey(rand.Reader, wcrypto.KeyRSA2048, "testkeys", logger.Desugar()) 24 | if err != nil { 25 | panic(err) 26 | } 27 | rsa2048keys = append(rsa2048keys, pk) 28 | } 29 | 30 | rsa4096keys := []crypto.PrivateKey{} 31 | for i := 0; i < 10; i++ { 32 | logger.Infof("Generating rsa4096key[%d]:", i) 33 | pk, err := wcrypto.GenerateKey(rand.Reader, wcrypto.KeyRSA4096, "testkeys", logger.Desugar()) 34 | if err != nil { 35 | panic(err) 36 | } 37 | rsa4096keys = append(rsa4096keys, pk) 38 | } 39 | 40 | eckeys := []crypto.PrivateKey{} 41 | for i := 0; i < 10; i++ { 42 | logger.Infof("Generating eckey[%d]:", i) 43 | pk, err := wcrypto.GenerateKey(rand.Reader, wcrypto.KeySECP256R1, "testkeys", logger.Desugar()) 44 | if err != nil { 45 | panic(err) 46 | } 47 | eckeys = append(eckeys, pk) 48 | } 49 | 50 | f, err := os.Create("testkeys.go") 51 | if err != nil { 52 | panic(err) 53 | } 54 | defer f.Close() 55 | 56 | fmt.Fprintln(f, "package testkmgm") 57 | fmt.Fprintln(f, "// This file is autogenerated from generate_testkeys.go. Do not modify manually.") 58 | 59 | fmt.Fprintf(f, "var RSA2048Keys = []string{\n") 60 | for _, pk := range rsa2048keys { 61 | pemData, err := pemparser.MarshalPrivateKey(pk) 62 | if err != nil { 63 | panic(err) 64 | } 65 | fmt.Fprintf(f, " `%s`,\n", pemData) 66 | } 67 | fmt.Fprintln(f, "}") 68 | 69 | fmt.Fprintf(f, "var RSA4096Keys = []string{\n") 70 | for _, pk := range rsa4096keys { 71 | pemData, err := pemparser.MarshalPrivateKey(pk) 72 | if err != nil { 73 | panic(err) 74 | } 75 | fmt.Fprintf(f, " `%s`,\n", pemData) 76 | } 77 | fmt.Fprintln(f, "}") 78 | 79 | fmt.Fprintf(f, "var ECKeys = []string{\n") 80 | for _, pk := range eckeys { 81 | pemData, err := pemparser.MarshalPrivateKey(pk) 82 | if err != nil { 83 | panic(err) 84 | } 85 | fmt.Fprintf(f, " `%s`,\n", pemData) 86 | } 87 | fmt.Fprintln(f, "}") 88 | } 89 | -------------------------------------------------------------------------------- /cmd/kmgm/testkmgm/testkeys_util.go: -------------------------------------------------------------------------------- 1 | package testkmgm 2 | 3 | import ( 4 | "crypto" 5 | "os" 6 | 7 | "github.com/IPA-CyberLab/kmgm/pemparser" 8 | "github.com/IPA-CyberLab/kmgm/wcrypto" 9 | ) 10 | 11 | var rsa2048KeyIndex int = 0 12 | var rsa4096KeyIndex int = 0 13 | var ecKeyIndex int = 0 14 | 15 | func ResetPreGenKeyIndex() { 16 | rsa2048KeyIndex = 0 17 | rsa4096KeyIndex = 0 18 | ecKeyIndex = 0 19 | } 20 | 21 | func GetPregenKeyPEM(ktype wcrypto.KeyType) []byte { 22 | switch ktype { 23 | case wcrypto.KeyRSA2048: 24 | pemstr := RSA2048Keys[rsa2048KeyIndex%len(RSA2048Keys)] 25 | rsa2048KeyIndex++ 26 | return []byte(pemstr) 27 | case wcrypto.KeyRSA4096: 28 | pemstr := RSA4096Keys[rsa4096KeyIndex%len(RSA4096Keys)] 29 | rsa4096KeyIndex++ 30 | return []byte(pemstr) 31 | case wcrypto.KeySECP256R1: 32 | pemstr := ECKeys[ecKeyIndex%len(ECKeys)] 33 | ecKeyIndex++ 34 | return []byte(pemstr) 35 | default: 36 | panic("not available") 37 | } 38 | } 39 | 40 | func WritePregenKeyPEMToFile(ktype wcrypto.KeyType, path string) crypto.PrivateKey { 41 | pemData := GetPregenKeyPEM(ktype) 42 | pk, err := pemparser.ParsePrivateKey(pemData) 43 | if err != nil { 44 | panic(err) 45 | } 46 | if err := os.WriteFile(path, pemData, 0644); err != nil { 47 | panic(err) 48 | } 49 | return pk 50 | } 51 | 52 | func GetPregenKey(ktype wcrypto.KeyType) crypto.PrivateKey { 53 | pemData := GetPregenKeyPEM(ktype) 54 | pk, err := pemparser.ParsePrivateKey(pemData) 55 | if err != nil { 56 | panic(err) 57 | } 58 | return pk 59 | } 60 | -------------------------------------------------------------------------------- /cmd/kmgm/testkmgm/testkmgm.go: -------------------------------------------------------------------------------- 1 | package testkmgm 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io/ioutil" 7 | mrand "math/rand" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/urfave/cli/v2" 13 | "go.uber.org/zap" 14 | "go.uber.org/zap/zapcore" 15 | "go.uber.org/zap/zaptest/observer" 16 | 17 | "github.com/IPA-CyberLab/kmgm/action" 18 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/app" 19 | "github.com/IPA-CyberLab/kmgm/frontend" 20 | "github.com/IPA-CyberLab/kmgm/storage" 21 | ) 22 | 23 | var NowDefault = time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC) 24 | 25 | func mockNowImpl(t time.Time) func() time.Time { 26 | return func() time.Time { 27 | return t 28 | } 29 | } 30 | 31 | type mockClock struct { 32 | t time.Time 33 | } 34 | 35 | func (c mockClock) Now() time.Time { 36 | return c.t 37 | } 38 | 39 | func (c mockClock) NewTicker(duration time.Duration) *time.Ticker { 40 | panic("not implemented") 41 | return nil 42 | } 43 | 44 | func Env(t *testing.T, basedir string, mockNow time.Time) *action.Environment { 45 | stor, err := storage.New(basedir) 46 | if err != nil { 47 | t.Fatalf("storage.New: %v", err) 48 | } 49 | 50 | fe := &frontend.NonInteractive{ 51 | Logger: zap.L(), 52 | } 53 | 54 | env, err := action.NewEnvironment(fe, stor) 55 | if err != nil { 56 | panic(err) 57 | } 58 | env.Frontend = &frontend.NonInteractive{Logger: zap.L()} 59 | env.NowImpl = mockNowImpl(mockNow) 60 | env.Randr = MrandReader{} 61 | env.PregenKeySupplier = GetPregenKey 62 | 63 | return env 64 | } 65 | 66 | type MrandReader struct{} 67 | 68 | func (MrandReader) Read(p []byte) (int, error) { 69 | return mrand.Read(p) 70 | } 71 | 72 | func Run(t *testing.T, ctx context.Context, basedir string, configYaml []byte, args []string, mockNow time.Time) (*observer.ObservedLogs, error) { 73 | t.Helper() 74 | 75 | a := app.New() 76 | 77 | zobs, logs := observer.New(zapcore.DebugLevel) 78 | 79 | logger := zap.New(zobs, zap.WithClock(mockClock{mockNow})) 80 | a.Metadata["Logger"] = logger 81 | a.Metadata["NowImpl"] = mockNowImpl(mockNow) 82 | a.Metadata["Randr"] = MrandReader{} 83 | a.Metadata["PregenKeySupplier"] = GetPregenKey 84 | 85 | var stdoutBuf bytes.Buffer 86 | var stderrBuf bytes.Buffer 87 | a.Writer = &stdoutBuf 88 | a.ErrWriter = &stderrBuf 89 | 90 | // replace `ExitErrHandler` to avoid exiting the test process. 91 | a.ExitErrHandler = func(cCtx *cli.Context, err error) {} 92 | 93 | var tmpfile *os.File 94 | if configYaml != nil { 95 | var err error 96 | tmpfile, err = ioutil.TempFile("", "kmgm-testconfig-*.yml") 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | defer os.Remove(tmpfile.Name()) 101 | 102 | if _, err := tmpfile.Write(configYaml); err != nil { 103 | t.Fatal(err) 104 | } 105 | // We need Close() here since we read the tmpfile later in the test. 106 | if err := tmpfile.Close(); err != nil { 107 | t.Fatal(err) 108 | } 109 | } 110 | 111 | as := []string{"kmgm", "--non-interactive", "--verbose", "--basedir", basedir} 112 | if configYaml != nil { 113 | as = append(as, "--config", tmpfile.Name()) 114 | } 115 | as = append(as, args...) 116 | err := a.RunContext(ctx, as) 117 | 118 | t.Logf("stdout: %s", stdoutBuf.String()) 119 | t.Logf("stderr: %s", stderrBuf.String()) 120 | for _, l := range logs.All() { 121 | t.Logf("🔒 %s", l.Message) 122 | } 123 | return logs, err 124 | } 125 | -------------------------------------------------------------------------------- /cmd/kmgm/tool/dump/dump.go: -------------------------------------------------------------------------------- 1 | package dump 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/urfave/cli/v2" 9 | 10 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/show" 11 | "github.com/IPA-CyberLab/kmgm/pemparser" 12 | ) 13 | 14 | var Command = &cli.Command{ 15 | Name: "dump", 16 | Usage: "dump details of the input x509 certificate", 17 | UsageText: `kmgm tool dump`, 18 | Flags: []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "output", 21 | Aliases: []string{"o"}, 22 | Usage: "Output format. (full, pem)", 23 | Value: "full", 24 | }, 25 | &cli.StringFlag{ 26 | Name: "input", 27 | Aliases: []string{"i"}, 28 | Usage: "The certificate file.", 29 | Value: "-", 30 | }, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | ftstr := c.String("output") 34 | ft, err := show.FormatTypeFromString(ftstr) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | var r io.Reader 40 | inpath := c.String("input") 41 | if inpath == "-" { 42 | r = os.Stdin 43 | } else { 44 | f, err := os.Open(inpath) 45 | if err != nil { 46 | return err 47 | } 48 | r = f 49 | defer f.Close() 50 | } 51 | 52 | bs, err := ioutil.ReadAll(r) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | certs, err := pemparser.ParseCertificates(bs) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | w := os.Stdout 63 | for _, cert := range certs { 64 | show.PrintCertInfo(w, cert, ft) 65 | } 66 | 67 | return nil 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /cmd/kmgm/tool/pubkeyhash/format.go: -------------------------------------------------------------------------------- 1 | package pubkeyhash 2 | 3 | import "fmt" 4 | 5 | type FormatType int 6 | 7 | const ( 8 | FormatFull FormatType = iota 9 | FormatHashOnly 10 | ) 11 | 12 | func FormatTypeFromString(s string) (FormatType, error) { 13 | switch s { 14 | case "full": 15 | return FormatFull, nil 16 | case "hashonly": 17 | return FormatHashOnly, nil 18 | default: 19 | return FormatFull, fmt.Errorf("Unknown format %q.", s) 20 | } 21 | } 22 | 23 | func (ft FormatType) ShouldOutputLabel() bool { 24 | switch ft { 25 | case FormatFull: 26 | return true 27 | default: 28 | return false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/kmgm/tool/pubkeyhash/pubkeyhash.go: -------------------------------------------------------------------------------- 1 | package pubkeyhash 2 | 3 | import ( 4 | "crypto" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "reflect" 12 | "strings" 13 | 14 | "github.com/urfave/cli/v2" 15 | "go.uber.org/zap" 16 | 17 | "github.com/IPA-CyberLab/kmgm/action" 18 | "github.com/IPA-CyberLab/kmgm/pemparser" 19 | "github.com/IPA-CyberLab/kmgm/wcrypto" 20 | ) 21 | 22 | type KeyHash struct { 23 | Label string 24 | Hash string 25 | } 26 | 27 | func ExtractPublicKeyHashesFromPem(bs []byte, logger *zap.Logger) ([]KeyHash, error) { 28 | slog := logger.Sugar() 29 | 30 | khs := make([]KeyHash, 0) 31 | err := pemparser.ForeachPemBlock(bs, func(b *pem.Block) error { 32 | label := "" 33 | var pub crypto.PublicKey 34 | 35 | if priv, err := pemparser.ParseSinglePrivateKeyBlock(b); err == nil { 36 | label = fmt.Sprintf("PrivateKey Type=%q", b.Type) 37 | pub, err = wcrypto.ExtractPublicKey(priv) 38 | if err != nil { 39 | return err 40 | } 41 | } else { 42 | switch b.Type { 43 | case pemparser.CertificatePemType: 44 | cert, err := x509.ParseCertificate(b.Bytes) 45 | if err != nil { 46 | slog.Errorf("Failed to parse a %s block: %v", b.Type, err) 47 | return nil // ignore err to continue parsing to next pem block 48 | } 49 | 50 | label = fmt.Sprintf("Certificate %v", cert.Subject) 51 | pub = cert.PublicKey 52 | case pemparser.PublicKeyPemType: 53 | pubi, err := x509.ParsePKIXPublicKey(b.Bytes) 54 | if err != nil { 55 | slog.Errorf("Failed to parse a %s block: %v", b.Type, err) 56 | return nil // ignore err to continue parsing to next pem block 57 | } 58 | 59 | var ok bool 60 | pub, ok = pubi.(crypto.PublicKey) 61 | if !ok { 62 | slog.Errorf("Unknown public key type: %v", reflect.TypeOf(pubi)) 63 | return nil // ignore err to continue parsing to next pem block 64 | } 65 | label = fmt.Sprintf("PublicKey Type=%v", b.Type) 66 | } 67 | } 68 | if pub == nil { 69 | slog.Errorf("Could not extract a public key from block: Type: %q.", b.Type) 70 | return nil 71 | } 72 | 73 | hash, err := wcrypto.PubKeyPinString(pub) 74 | if err != nil { 75 | slog.Errorf("Failed to generate pubkeyhash: %v", err) 76 | return nil // ignore err to continue parsing other pem blocks 77 | } 78 | kh := KeyHash{Label: label, Hash: hash} 79 | 80 | for i, e := range khs { 81 | if kh.Hash == e.Hash { 82 | // overwrite label if Certificate label 83 | if strings.HasPrefix(kh.Label, "Certificate") { 84 | khs[i].Label = kh.Label 85 | } 86 | 87 | // continue parsing to next pem block 88 | return nil 89 | } 90 | } 91 | khs = append(khs, kh) 92 | return nil 93 | }) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return khs, nil 98 | } 99 | 100 | var Command = &cli.Command{ 101 | Name: "pubkeyhash", 102 | Usage: "Dump pubkeyhash of the specified public key/certificate", 103 | Aliases: []string{"hash"}, 104 | Flags: []cli.Flag{ 105 | &cli.StringFlag{ 106 | Name: "output", 107 | Aliases: []string{"o"}, 108 | Usage: "Output format. (full, hashonly)", 109 | Value: "full", 110 | }, 111 | &cli.PathFlag{ 112 | Name: "file", 113 | Aliases: []string{"f"}, 114 | Usage: "PEM file containing public key/certificates", 115 | Value: "-", 116 | }, 117 | }, 118 | Action: func(c *cli.Context) error { 119 | env := action.GlobalEnvironment 120 | 121 | ftstr := c.String("output") 122 | ft, err := FormatTypeFromString(ftstr) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | var r io.Reader 128 | 129 | path := c.String("file") 130 | if path == "-" { 131 | r = os.Stdin 132 | } else { 133 | f, err := os.Open(path) 134 | if err != nil { 135 | return err 136 | } 137 | defer f.Close() 138 | 139 | r = f 140 | } 141 | 142 | bs, err := ioutil.ReadAll(r) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | khs, err := ExtractPublicKeyHashesFromPem(bs, env.Logger) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | for _, e := range khs { 153 | if ft.ShouldOutputLabel() { 154 | fmt.Printf("# %s\n", e.Label) 155 | } 156 | fmt.Printf("%s\n", e.Hash) 157 | } 158 | 159 | return nil 160 | }, 161 | } 162 | -------------------------------------------------------------------------------- /cmd/kmgm/tool/tool.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | 6 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/tool/dump" 7 | "github.com/IPA-CyberLab/kmgm/cmd/kmgm/tool/pubkeyhash" 8 | ) 9 | 10 | var Command = &cli.Command{ 11 | Name: "tool", 12 | Usage: "misc tools", 13 | Aliases: []string{"t"}, 14 | Subcommands: []*cli.Command{ 15 | pubkeyhash.Command, 16 | dump.Command, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | import ( 4 | "encoding/asn1" 5 | "time" 6 | ) 7 | 8 | const NodesOutOfSyncThreshold = 1 * time.Minute 9 | const AuthProfileName = ".kmgm_server" 10 | const PrometheusNamespace = "kmgm" 11 | 12 | var ( 13 | OIDExtensionKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 15} 14 | OIDExtensionExtendedKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37} 15 | OIDExtKeyUsageServerAuth = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 1} 16 | OIDExtKeyUsageClientAuth = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2} 17 | ) 18 | -------------------------------------------------------------------------------- /dname/protoconv.go: -------------------------------------------------------------------------------- 1 | package dname 2 | 3 | import "github.com/IPA-CyberLab/kmgm/pb" 4 | 5 | func FromProtoStruct(s *pb.DistinguishedName) *Config { 6 | if s == nil { 7 | return &Config{} 8 | } 9 | 10 | return &Config{ 11 | CommonName: s.CommonName, 12 | Organization: s.Organization, 13 | OrganizationalUnit: s.OrganizationalUnit, 14 | Country: s.Country, 15 | Locality: s.Locality, 16 | Province: s.Province, 17 | StreetAddress: s.StreetAddress, 18 | PostalCode: s.PostalCode, 19 | } 20 | } 21 | 22 | func (c *Config) ToProtoStruct() *pb.DistinguishedName { 23 | return &pb.DistinguishedName{ 24 | CommonName: c.CommonName, 25 | Organization: c.Organization, 26 | OrganizationalUnit: c.OrganizationalUnit, 27 | Country: c.Country, 28 | Locality: c.Locality, 29 | Province: c.Province, 30 | StreetAddress: c.StreetAddress, 31 | PostalCode: c.PostalCode, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/tutorials/nginx/.gitignore: -------------------------------------------------------------------------------- 1 | tls/*.pem 2 | -------------------------------------------------------------------------------- /docs/tutorials/nginx/README.md: -------------------------------------------------------------------------------- 1 | # nginx tutorial 2 | In this tutorial, we setup a [nginx](https://www.nginx.com/) serving HTTPS using a certificate issued by [kmgm](https://github.com/IPA-CyberLab/kmgm). 3 | 4 | ## Prerequisites 5 | - kmgm commandline tool installed. To install kmgm, please refer to the instructions [here](https://github.com/IPA-CyberLab/kmgm/blob/master/README.md#installation). 6 | - kmgm git repository checkout on `/home/example/kmgm/` (Please replace the path with your actual checkout path in the instructions below) 7 | - To focus on kmgm usage, we use a pre-prepared nginx.conf and directory structure in this tutorial. 8 | - Checkout the repository via: `git clone https://github.com/IPA-CyberLab/kmgm` 9 | - docker installation. Instructions [here](https://docs.docker.com/get-docker/). 10 | - We use docker in this tutorial to run nginx instance in a disposable manner. kmgm itself doesn't have any dependency to docker, so you can also run nginx directly on your machine. 11 | 12 | ## Setup a CA 13 | Setup a new CA with the following command: 14 | 15 | ```bash 16 | $ kmgm setup 17 | ``` 18 | 19 | kmgm launches `$EDITOR` to customize the CA settings. In this tutorial, the default settings are enough, so just close the editor. 20 | 21 | ## Generate a private key and issue a certificate 22 | Let's change the working directory so we can save some typing later. 23 | ```bash 24 | $ cd docs/tutorials/nginx/tls 25 | ``` 26 | 27 | Generate a new private key and issue a certificate using the CA setup in the previous section: 28 | 29 | ```bash 30 | $ kmgm issue 31 | ``` 32 | 33 | kmgm will prompt you for the private key file path. Press the return key to proceed with the default, which is a `key.pem` right under the current working directory. kmgm will generate and write a new private key if it doesn't exist. 34 | ``` 35 | ✔ Private key file: /home/example/kmgm/docs/tutorials/nginx/tls/key.pem 36 | ``` 37 | 38 | Next, kmgm will prompt you for the certificate file path. Again, press the return key to proceed with the default. kmgm will issue a fresh new certificate on the specified path, or renew an existing certificate if the file already exists: 39 | ``` 40 | ✔ Certificate pem file: /home/example/kmgm/docs/tutorials/nginx/tls/key.pem 41 | ``` 42 | 43 | Finally, kmgm launches `$EDITOR` to customize the certificate details. In this tutorial, the default settings are enough, so just close the editor, and kmgm will handle the rest: 44 | 45 | ``` 46 | INFO Generating key... {"usage": "", "type": "rsa"} 47 | INFO Generating key... Done. {"usage": "", "type": "rsa", "took": 0.838800879} 48 | INFO Allocated sn: 6362397607487909327 49 | INFO Generating certificate... 50 | INFO Generating certificate... Done. {"took": 0.006699632} 51 | ``` 52 | 53 | After the kmgm command finishes, you should have the pem files populated: 54 | ``` 55 | $ ls -l 56 | total 8 57 | -rw-r--r-- 1 example example 1939 May 25 23:30 cert.pem 58 | -r-------- 1 example example 3243 May 25 23:30 key.pem 59 | ``` 60 | 61 | ## Run nginx 62 | Now that you have a private key and a certificate, let's configure a `nginx.conf` to use them: 63 | ```nginx 64 | events {} 65 | 66 | http { 67 | server { 68 | listen 443 ssl http2; 69 | 70 | ssl_certificate /etc/tls/cert.pem; # replace with the certificate file path 71 | ssl_certificate_key /etc/tls/key.pem; # replace with the key file path 72 | ssl_session_timeout 1d; 73 | ssl_session_cache shared:SSL:10m; 74 | ssl_session_tickets off; 75 | 76 | ssl_protocols TLSv1.3; 77 | ssl_prefer_server_ciphers off; 78 | 79 | root /pub; # replace with the directory which you wish to publish 80 | } 81 | } 82 | ``` 83 | 84 | If you have docker installed, we can run nginx like below: 85 | ```bash 86 | $ cd .. # cd to [checkout]/docs/tutorials/nginx 87 | $ docker run --rm -v `pwd`/tls:/etc/tls:ro -v `pwd`/conf:/etc/nginx:ro -v `pwd`/pub:/pub:ro -p 8443:443 nginx 88 | ``` 89 | 90 | You should be able to test the nginx instance by navigating your web browser to https://[your hostname]:8443/. 91 | 92 | ## CA root 93 | When you navigate to the nginx instance, your browser would warn you that the CA is invalid. 94 | ![Chrome warning][chrome-warning] 95 | 96 | To avoid the warning, we need to import the CA we just setup as a trusted root. To show the CA certificate info, use: 97 | ```bash 98 | $ kmgm show ca 99 | ``` 100 | 101 | To output only pem part, do 102 | ```bash 103 | $ kmgm show -o pem ca 104 | ``` 105 | or, to save the result to a pem file, 106 | ```bash 107 | $ kmgm show -o pem -f ca.pem 108 | ``` 109 | 110 | If you are using Chrome and would like to import the CA certificate, type `chrome://settings/certificates` into the omnibox (URL bar), and click on `Import`. 111 | 112 | To import the certificate on Windows, you might want to name the file with `.crt` extension to be recognized as certificate file. 113 | 114 | 115 | [chrome-warning]: https://raw.githubusercontent.com/IPA-CyberLab/kmgm/master/docs/tutorials/nginx/chrome-warning.png 116 | -------------------------------------------------------------------------------- /docs/tutorials/nginx/chrome-warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IPA-CyberLab/kmgm/27dbe309ec3d4b83fde1f020762164cf4a8581d0/docs/tutorials/nginx/chrome-warning.png -------------------------------------------------------------------------------- /docs/tutorials/nginx/conf/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | server { 5 | listen 443 ssl http2; 6 | 7 | ssl_certificate /etc/tls/cert.pem; 8 | ssl_certificate_key /etc/tls/key.pem; 9 | ssl_session_timeout 1d; 10 | ssl_session_cache shared:SSL:10m; 11 | ssl_session_tickets off; 12 | 13 | ssl_protocols TLSv1.3; 14 | ssl_prefer_server_ciphers off; 15 | 16 | root /pub; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/tutorials/nginx/docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd $(dirname $0) 3 | docker run --rm -v `pwd`/tls:/etc/tls:ro -v `pwd`/conf:/etc/nginx:ro -v `pwd`/pub:/pub:ro -p 443:443 nginx 4 | -------------------------------------------------------------------------------- /docs/tutorials/nginx/pub/index.html: -------------------------------------------------------------------------------- 1 | 2 |