From 31c0ff9165482af6bbcfbb495f254975cb10e21f Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Wed, 24 Apr 2024 18:35:15 -0700 Subject: [PATCH] token PKI staging --- docs/operations-guide.md | 6 +++- http/api/tokenpki.go | 29 ++++++++++++--- storage/file/file.go | 62 +++++++++++++++++++++++++++++---- storage/mysql/mysql.go | 42 +++++++++++++++++----- storage/mysql/query.sql | 20 ++++++++++- storage/mysql/schema.00001.sql | 1 + storage/mysql/schema.sql | 6 ++-- storage/mysql/sqlc.yaml | 8 +++++ storage/mysql/sqlc/db.go | 2 +- storage/mysql/sqlc/models.go | 32 +++++++++-------- storage/mysql/sqlc/query.sql.go | 49 ++++++++++++++++++++++---- storage/storage.go | 4 ++- storage/test/test.go | 17 +++++++-- 13 files changed, 229 insertions(+), 49 deletions(-) create mode 100644 storage/mysql/schema.00001.sql diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 5af2313..38edd85 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -92,6 +92,8 @@ The `/v1/tokens/{name}` endpoints deal with the raw DEP OAuth tokens in JSON for For the PUT operation you can supply a "force" URL parameter which will override the matching consumer key check. +The PUT endpoint is discouraged; instead you should perform the full PKI exchange with the "tokenpki" endpoints. If you import only the "raw" OAuth tokens then NanoDEP will not have access to the correct private key for the associated DEP name. This private key is used for some modern DEP operations and won't be possible. + #### Assigner * Endpoint: `GET, PUT /v1/assigner/{name}` @@ -205,7 +207,7 @@ For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this **The first argument is required** and specifies the path to the token file downloaded from the Apple portal. This script has one optional argument: -- If you spply a "1" as the second argument it will override ("force" mode) the consumer key check to be able to save a differing consumer key. +- If you supply a "1" as the second argument it will override ("force" mode) the consumer key check to be able to save a differing consumer key. ##### Example usage @@ -540,6 +542,8 @@ In "decrypt and decode tokens" mode (that is, by specifying the path to the down **Note: `deptokens` is not required to use NanoDEP: `depserver` contains this functionality built-in using the tools/scripts (or via the API) directly. See above documentation.** +**Note: `deptokens` is discouraged for use with NanoDEP's `depserver`. The private key and certificate for the PKI exchange is not preserved when only uploading OAuth tokens. Some modern DEP functionality will not be possible. See the note above regarding the Tokens API.** + ### Switches Command line switches for the `deptokens` tool. diff --git a/http/api/tokenpki.go b/http/api/tokenpki.go index 7a4bfb7..eac8086 100644 --- a/http/api/tokenpki.go +++ b/http/api/tokenpki.go @@ -16,14 +16,27 @@ import ( "github.com/micromdm/nanodep/tokenpki" ) -type TokenPKIRetriever interface { - RetrieveTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) +type TokenPKIStagingRetriever interface { + RetrieveStagingTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) +} + +type TokenPKICurrentRetriever interface { + RetrieveCurrentTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) +} + +type TokenPKIUpstager interface { + UpstageTokenPKI(ctx context.Context, name string) error } type TokenPKIStorer interface { StoreTokenPKI(ctx context.Context, name string, pemCert []byte, pemKey []byte) error } +type DecryptTokenPKIStorage interface { + TokenPKIStagingRetriever + TokenPKIUpstager +} + // PEMRSAPrivateKey returns key as a PEM block. func PEMRSAPrivateKey(key *rsa.PrivateKey) []byte { block := &pem.Block{ @@ -98,7 +111,7 @@ func GetCertTokenPKIHandler(store TokenPKIStorer, logger log.Logger) http.Handle // Note the whole URL path is used as the DEP name. This necessitates // stripping the URL prefix before using this handler. Also note we expose Go // errors to the output as this is meant for "API" users. -func DecryptTokenPKIHandler(store TokenPKIRetriever, tokenStore AuthTokensStore, logger log.Logger) http.HandlerFunc { +func DecryptTokenPKIHandler(store DecryptTokenPKIStorage, tokenStore AuthTokensStore, logger log.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { logger := ctxlog.Logger(r.Context(), logger) if r.URL.Path == "" { @@ -115,7 +128,7 @@ func DecryptTokenPKIHandler(store TokenPKIRetriever, tokenStore AuthTokensStore, return } defer r.Body.Close() - certBytes, keyBytes, err := store.RetrieveTokenPKI(r.Context(), r.URL.Path) + certBytes, keyBytes, err := store.RetrieveStagingTokenPKI(r.Context(), r.URL.Path) if err != nil { logger.Info("msg", "retrieving token keypair", "err", err) jsonError(w, err) @@ -146,6 +159,14 @@ func DecryptTokenPKIHandler(store TokenPKIRetriever, tokenStore AuthTokensStore, jsonError(w, err) return } + // decryption and unmarshal of tokens successful, now "upgrade" + // our staging token PKI to the real thing. + err = store.UpstageTokenPKI(r.Context(), r.URL.Path) + if err != nil { + logger.Info("msg", "upstaging token PKI", "err", err) + jsonError(w, err) + return + } storeTokens(r.Context(), logger, r.URL.Path, tokens, tokenStore, w, force) } } diff --git a/storage/file/file.go b/storage/file/file.go index e06f1a3..49910f2 100644 --- a/storage/file/file.go +++ b/storage/file/file.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path" @@ -159,26 +160,73 @@ func (s *FileStorage) StoreCursor(_ context.Context, name, cursor string) error // StoreTokenPKI stores the PEM bytes in pemCert and pemKey to disk for name DEP name. func (s *FileStorage) StoreTokenPKI(_ context.Context, name string, pemCert []byte, pemKey []byte) error { - if err := os.WriteFile(s.tokenpkiFilename(name, "cert"), pemCert, 0664); err != nil { + if err := os.WriteFile(s.tokenpkiFilename(name, "staging.cert"), pemCert, 0664); err != nil { return err } - if err := os.WriteFile(s.tokenpkiFilename(name, "key"), pemKey, 0664); err != nil { + if err := os.WriteFile(s.tokenpkiFilename(name, "staging.key"), pemKey, 0664); err != nil { return err } return nil } -// RetrieveTokenPKI reads and returns the PEM bytes for the DEP token exchange -// certificate and private key from disk using name DEP name. -func (s *FileStorage) RetrieveTokenPKI(_ context.Context, name string) ([]byte, []byte, error) { - certBytes, err := os.ReadFile(s.tokenpkiFilename(name, "cert")) +// copyFile non-atomically copies file at path src to file at path dst. +func copyFile(dst, src string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} + +// UpstageTokenPKI copies the staging PKI certificate and key to the current PKI certificate and key. +// Warning: this operation is not atomic. +func (s *FileStorage) UpstageTokenPKI(ctx context.Context, name string) error { + err := copyFile( + s.tokenpkiFilename(name, "cert"), + s.tokenpkiFilename(name, "staging.cert"), + ) + if err != nil { + return err + } + return copyFile( + s.tokenpkiFilename(name, "key"), + s.tokenpkiFilename(name, "staging.key"), + ) +} + +// RetrieveStagingTokenPKI reads and returns the PEM bytes for the staged +// DEP token exchange certificate and private key from disk using name DEP name. +func (s *FileStorage) RetrieveStagingTokenPKI(ctx context.Context, name string) ([]byte, []byte, error) { + return s.retrieveTokenPKIExtn(name, "staging.") +} + +// RetrieveCurrentTokenPKI reads and returns the PEM bytes for the previously- +// upstaged DEP token exchange certificate and private key from disk using +// name DEP name. +func (s *FileStorage) RetrieveCurrentTokenPKI(_ context.Context, name string) ([]byte, []byte, error) { + return s.retrieveTokenPKIExtn(name, "") +} + +// retrieveTokenPKIExtn reads and returns the PEM bytes for the DEP token exchange +// certificate and private key from disk using name DEP name and extn type. +func (s *FileStorage) retrieveTokenPKIExtn(name, extn string) ([]byte, []byte, error) { + certBytes, err := os.ReadFile(s.tokenpkiFilename(name, extn+"cert")) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil, fmt.Errorf("%v: %w", err, storage.ErrNotFound) } return nil, nil, err } - keyBytes, err := os.ReadFile(s.tokenpkiFilename(name, "key")) + keyBytes, err := os.ReadFile(s.tokenpkiFilename(name, extn+"key")) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil, fmt.Errorf("%v: %w", err, storage.ErrNotFound) diff --git a/storage/mysql/mysql.go b/storage/mysql/mysql.go index 219f466..6de0a22 100644 --- a/storage/mysql/mysql.go +++ b/storage/mysql/mysql.go @@ -238,17 +238,17 @@ ON DUPLICATE KEY UPDATE return err } -// StoreTokenPKI stores the PEM bytes in pemCert and pemKey for name DEP name. +// StoreTokenPKI stores the staging PEM bytes in pemCert and pemKey for name DEP name. func (s *MySQLStorage) StoreTokenPKI(ctx context.Context, name string, pemCert []byte, pemKey []byte) error { _, err := s.db.ExecContext( ctx, ` INSERT INTO dep_names - (name, tokenpki_cert_pem, tokenpki_key_pem) + (name, tokenpki_staging_cert_pem, tokenpki_staging_key_pem) VALUES (?, ?, ?) as new ON DUPLICATE KEY UPDATE - tokenpki_cert_pem = new.tokenpki_cert_pem, - tokenpki_key_pem = new.tokenpki_key_pem;`, + tokenpki_staging_cert_pem = new.tokenpki_staging_cert_pem, + tokenpki_staging_key_pem = new.tokenpki_staging_key_pem;`, name, pemCert, pemKey, @@ -256,10 +256,36 @@ ON DUPLICATE KEY UPDATE return err } -// RetrieveTokenPKI reads the PEM bytes for the DEP token exchange certificate -// and private key using name DEP name. -func (s *MySQLStorage) RetrieveTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) { - keypair, err := s.q.GetKeypair(ctx, name) +// UpstageTokenPKI copies the staging PKI certificate and private key to the +// current PKI certificate and private key. +func (s *MySQLStorage) UpstageTokenPKI(ctx context.Context, name string) error { + err := s.q.UpstageKeypair(ctx, name) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%v: %w", err, storage.ErrNotFound) + } + return err +} + +// RetrieveStagingTokenPKI returns the PEM bytes for the staged DEP +// token exchange certificate and private key using name DEP name. +func (s *MySQLStorage) RetrieveStagingTokenPKI(ctx context.Context, name string) ([]byte, []byte, error) { + keypair, err := s.q.GetStagingKeypair(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, fmt.Errorf("%v: %w", err, storage.ErrNotFound) + } + return nil, nil, err + } + if keypair.TokenpkiStagingCertPem == nil { // tokenpki_staging_cert_pem and tokenpki_staging_key_pem are set together + return nil, nil, fmt.Errorf("empty certificate: %w", storage.ErrNotFound) + } + return keypair.TokenpkiStagingCertPem, keypair.TokenpkiStagingKeyPem, nil +} + +// RetrieveCurrentTokenPKI returns the PEM bytes for the previously-upstaged DEP +// token exchange certificate and private key using name DEP name. +func (s *MySQLStorage) RetrieveCurrentTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) { + keypair, err := s.q.GetCurrentKeypair(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil, fmt.Errorf("%v: %w", err, storage.ErrNotFound) diff --git a/storage/mysql/query.sql b/storage/mysql/query.sql index aaf5b2f..152a732 100644 --- a/storage/mysql/query.sql +++ b/storage/mysql/query.sql @@ -4,7 +4,7 @@ SELECT config_base_url FROM dep_names WHERE name = ?; -- name: GetSyncerCursor :one SELECT syncer_cursor FROM dep_names WHERE name = ?; --- name: GetKeypair :one +-- name: GetCurrentKeypair :one SELECT tokenpki_cert_pem, tokenpki_key_pem @@ -13,6 +13,24 @@ FROM WHERE name = ?; +-- name: GetStagingKeypair :one +SELECT + tokenpki_staging_cert_pem, + tokenpki_staging_key_pem +FROM + dep_names +WHERE + name = ?; + +-- name: UpstageKeypair :exec +UPDATE + dep_names +SET + tokenpki_cert_pem = tokenpki_staging_cert_pem, + tokenpki_key_pem = tokenpki_staging_key_pem +WHERE + name = ?; + -- name: GetAuthTokens :one SELECT consumer_key, diff --git a/storage/mysql/schema.00001.sql b/storage/mysql/schema.00001.sql new file mode 100644 index 0000000..1912927 --- /dev/null +++ b/storage/mysql/schema.00001.sql @@ -0,0 +1 @@ +ALTER TABLE dep_names ADD COLUMN tokenpki_staging_cert_pem TEXT NULL, tokenpki_staging_cert_pem TEXT NULL; diff --git a/storage/mysql/schema.sql b/storage/mysql/schema.sql index 7e4cdf0..b86fe42 100644 --- a/storage/mysql/schema.sql +++ b/storage/mysql/schema.sql @@ -12,8 +12,10 @@ CREATE TABLE dep_names ( config_base_url VARCHAR(255) NULL, -- Token PKI - tokenpki_cert_pem TEXT NULL, - tokenpki_key_pem TEXT NULL, + tokenpki_cert_pem TEXT NULL, + tokenpki_key_pem TEXT NULL, + tokenpki_staging_cert_pem TEXT NULL, + tokenpki_staging_key_pem TEXT NULL, -- Syncer -- From Apple docs: "The string can be up to 1000 characters". diff --git a/storage/mysql/sqlc.yaml b/storage/mysql/sqlc.yaml index e105b43..e3342f3 100644 --- a/storage/mysql/sqlc.yaml +++ b/storage/mysql/sqlc.yaml @@ -16,6 +16,14 @@ sql: go_type: type: "byte" slice: true + - column: "dep_names.tokenpki_staging_cert_pem" + go_type: + type: "byte" + slice: true + - column: "dep_names.tokenpki_staging_key_pem" + go_type: + type: "byte" + slice: true - column: "dep_names.access_token_expiry" go_type: type: "sql.NullString" diff --git a/storage/mysql/sqlc/db.go b/storage/mysql/sqlc/db.go index 6a77d41..c5852e0 100644 --- a/storage/mysql/sqlc/db.go +++ b/storage/mysql/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.21.0 +// sqlc v1.26.0 package sqlc diff --git a/storage/mysql/sqlc/models.go b/storage/mysql/sqlc/models.go index 8cd7b2f..0160c35 100644 --- a/storage/mysql/sqlc/models.go +++ b/storage/mysql/sqlc/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.21.0 +// sqlc v1.26.0 package sqlc @@ -9,18 +9,20 @@ import ( ) type DepName struct { - Name string - ConsumerKey sql.NullString - ConsumerSecret sql.NullString - AccessToken sql.NullString - AccessSecret sql.NullString - AccessTokenExpiry sql.NullString - ConfigBaseUrl sql.NullString - TokenpkiCertPem []byte - TokenpkiKeyPem []byte - SyncerCursor sql.NullString - AssignerProfileUuid sql.NullString - AssignerProfileUuidAt sql.NullString - CreatedAt sql.NullTime - UpdatedAt sql.NullTime + Name string + ConsumerKey sql.NullString + ConsumerSecret sql.NullString + AccessToken sql.NullString + AccessSecret sql.NullString + AccessTokenExpiry sql.NullString + ConfigBaseUrl sql.NullString + TokenpkiCertPem []byte + TokenpkiKeyPem []byte + TokenpkiStagingCertPem []byte + TokenpkiStagingKeyPem []byte + SyncerCursor sql.NullString + AssignerProfileUuid sql.NullString + AssignerProfileUuidAt sql.NullString + CreatedAt sql.NullTime + UpdatedAt sql.NullTime } diff --git a/storage/mysql/sqlc/query.sql.go b/storage/mysql/sqlc/query.sql.go index 36e8cad..f1469a4 100644 --- a/storage/mysql/sqlc/query.sql.go +++ b/storage/mysql/sqlc/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.21.0 +// sqlc v1.26.0 // source: query.sql package sqlc @@ -77,7 +77,7 @@ func (q *Queries) GetConfigBaseURL(ctx context.Context, name string) (sql.NullSt return config_base_url, err } -const getKeypair = `-- name: GetKeypair :one +const getCurrentKeypair = `-- name: GetCurrentKeypair :one SELECT tokenpki_cert_pem, tokenpki_key_pem @@ -87,18 +87,40 @@ WHERE name = ? ` -type GetKeypairRow struct { +type GetCurrentKeypairRow struct { TokenpkiCertPem []byte TokenpkiKeyPem []byte } -func (q *Queries) GetKeypair(ctx context.Context, name string) (GetKeypairRow, error) { - row := q.db.QueryRowContext(ctx, getKeypair, name) - var i GetKeypairRow +func (q *Queries) GetCurrentKeypair(ctx context.Context, name string) (GetCurrentKeypairRow, error) { + row := q.db.QueryRowContext(ctx, getCurrentKeypair, name) + var i GetCurrentKeypairRow err := row.Scan(&i.TokenpkiCertPem, &i.TokenpkiKeyPem) return i, err } +const getStagingKeypair = `-- name: GetStagingKeypair :one +SELECT + tokenpki_staging_cert_pem, + tokenpki_staging_key_pem +FROM + dep_names +WHERE + name = ? +` + +type GetStagingKeypairRow struct { + TokenpkiStagingCertPem []byte + TokenpkiStagingKeyPem []byte +} + +func (q *Queries) GetStagingKeypair(ctx context.Context, name string) (GetStagingKeypairRow, error) { + row := q.db.QueryRowContext(ctx, getStagingKeypair, name) + var i GetStagingKeypairRow + err := row.Scan(&i.TokenpkiStagingCertPem, &i.TokenpkiStagingKeyPem) + return i, err +} + const getSyncerCursor = `-- name: GetSyncerCursor :one SELECT syncer_cursor FROM dep_names WHERE name = ? ` @@ -109,3 +131,18 @@ func (q *Queries) GetSyncerCursor(ctx context.Context, name string) (sql.NullStr err := row.Scan(&syncer_cursor) return syncer_cursor, err } + +const upstageKeypair = `-- name: UpstageKeypair :exec +UPDATE + dep_names +SET + tokenpki_cert_pem = tokenpki_staging_cert_pem, + tokenpki_key_pem = tokenpki_staging_key_pem +WHERE + name = ? +` + +func (q *Queries) UpstageKeypair(ctx context.Context, name string) error { + _, err := q.db.ExecContext(ctx, upstageKeypair, name) + return err +} diff --git a/storage/storage.go b/storage/storage.go index b913e2e..2fdf458 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -20,6 +20,8 @@ type AllStorage interface { api.AuthTokensStorer api.ConfigStorer api.TokenPKIStorer - api.TokenPKIRetriever + api.TokenPKIStagingRetriever + api.TokenPKICurrentRetriever + api.TokenPKIUpstager api.AssignerProfileStorer } diff --git a/storage/test/test.go b/storage/test/test.go index 5e7088b..328f357 100644 --- a/storage/test/test.go +++ b/storage/test/test.go @@ -33,7 +33,7 @@ func TestWithStorages(t *testing.T, ctx context.Context, store storage.AllStorag // TestEmpty tests retrieval methods on an empty/missing name. func TestEmpty(t *testing.T, ctx context.Context, name string, s storage.AllStorage) { - if _, _, err := s.RetrieveTokenPKI(ctx, name); !errors.Is(err, storage.ErrNotFound) { + if _, _, err := s.RetrieveStagingTokenPKI(ctx, name); !errors.Is(err, storage.ErrNotFound) { t.Fatalf("unexpected error: %s", err) } @@ -65,13 +65,13 @@ func TestEmpty(t *testing.T, ctx context.Context, name string, s storage.AllStor func TestWitName(t *testing.T, ctx context.Context, name string, s storage.AllStorage) { // PKI storing and retrieval. - if _, _, err := s.RetrieveTokenPKI(ctx, name); !errors.Is(err, storage.ErrNotFound) { + if _, _, err := s.RetrieveStagingTokenPKI(ctx, name); !errors.Is(err, storage.ErrNotFound) { t.Fatalf("unexpected error: %s", err) } pemCert, pemKey := generatePKI(t, "basicdn", 1) err := s.StoreTokenPKI(ctx, name, pemCert, pemKey) checkErr(t, err) - pemCert2, pemKey2, err := s.RetrieveTokenPKI(ctx, name) + pemCert2, pemKey2, err := s.RetrieveStagingTokenPKI(ctx, name) checkErr(t, err) if !bytes.Equal(pemCert, pemCert2) { t.Fatalf("pem cert mismatch: %s vs. %s", pemCert, pemCert2) @@ -80,6 +80,17 @@ func TestWitName(t *testing.T, ctx context.Context, name string, s storage.AllSt t.Fatalf("pem key mismatch: %s vs. %s", pemKey, pemKey2) } + err = s.UpstageTokenPKI(ctx, name) + checkErr(t, err) + pemCert3, pemKey3, err := s.RetrieveCurrentTokenPKI(ctx, name) + checkErr(t, err) + if !bytes.Equal(pemCert, pemCert3) { + t.Fatalf("pem cert mismatch: %s vs. %s", pemCert, pemCert3) + } + if !bytes.Equal(pemKey, pemKey3) { + t.Fatalf("pem key mismatch: %s vs. %s", pemKey, pemKey3) + } + // Token storing and retrieval. if _, err := s.RetrieveAuthTokens(ctx, name); !errors.Is(err, storage.ErrNotFound) { t.Fatalf("unexpected error: %s", err)