From c3d3c4ca43dfb91b0f9660cf820573493ee27770 Mon Sep 17 00:00:00 2001 From: Aleksandr Maus Date: Mon, 23 May 2022 22:07:34 -0400 Subject: [PATCH] Agent configuration encryption (#398) --- CHANGELOG.next.asciidoc | 1 + Makefile | 1 + NOTICE.txt | 246 ++++++++++++++++++ dev-tools/notice/NOTICE.txt.append | 215 +++++++++++++++ go.mod | 1 + go.sum | 3 + internal/pkg/agent/application/application.go | 2 +- .../pkg/agent/application/application_test.go | 3 + .../pkg/agent/application/info/agent_id.go | 5 +- internal/pkg/agent/application/paths/files.go | 34 ++- .../agent/application/paths/paths_darwin.go | 8 + .../agent/application/paths/paths_linux.go | 18 ++ .../agent/application/paths/paths_windows.go | 13 +- .../modifiers/monitoring_decorator_test.go | 107 +++----- .../pkg/agent/application/secret/secret.go | 126 +++++++++ .../agent/application/secret/secret_test.go | 71 +++++ .../agent/application/upgrade/step_mark.go | 4 +- .../pkg/agent/application/upgrade/upgrade.go | 139 +++++++++- internal/pkg/agent/cmd/enroll_cmd.go | 12 +- internal/pkg/agent/cmd/enroll_cmd_test.go | 16 +- internal/pkg/agent/cmd/run.go | 2 +- .../pkg/agent/configuration/configuration.go | 24 -- .../pkg/agent/operation/monitoring_test.go | 9 +- ...crypted_disk_storage_windows_linux_test.go | 112 ++++++++ .../pkg/agent/storage/encrypted_disk_store.go | 181 +++++++++++++ internal/pkg/agent/storage/storage.go | 16 ++ .../pkg/agent/storage/store/state_store.go | 5 +- internal/pkg/agent/vault/aesgcm.go | 122 +++++++++ internal/pkg/agent/vault/aesgcm_test.go | 198 ++++++++++++++ internal/pkg/agent/vault/seed.go | 93 +++++++ internal/pkg/agent/vault/seed_darwin.go | 13 + internal/pkg/agent/vault/seed_test.go | 36 +++ internal/pkg/agent/vault/vault_darwin.c | 218 ++++++++++++++++ internal/pkg/agent/vault/vault_darwin.go | 146 +++++++++++ internal/pkg/agent/vault/vault_linux.go | 143 ++++++++++ internal/pkg/agent/vault/vault_test.go | 120 +++++++++ internal/pkg/agent/vault/vault_windows.go | 129 +++++++++ .../composable/providers/agent/agent_test.go | 3 + internal/pkg/config/operations/inspector.go | 2 +- internal/pkg/testutils/testutils.go | 25 ++ 40 files changed, 2490 insertions(+), 132 deletions(-) create mode 100644 dev-tools/notice/NOTICE.txt.append create mode 100644 internal/pkg/agent/application/paths/paths_linux.go create mode 100644 internal/pkg/agent/application/secret/secret.go create mode 100644 internal/pkg/agent/application/secret/secret_test.go create mode 100644 internal/pkg/agent/storage/encrypted_disk_storage_windows_linux_test.go create mode 100644 internal/pkg/agent/storage/encrypted_disk_store.go create mode 100644 internal/pkg/agent/vault/aesgcm.go create mode 100644 internal/pkg/agent/vault/aesgcm_test.go create mode 100644 internal/pkg/agent/vault/seed.go create mode 100644 internal/pkg/agent/vault/seed_darwin.go create mode 100644 internal/pkg/agent/vault/seed_test.go create mode 100644 internal/pkg/agent/vault/vault_darwin.c create mode 100644 internal/pkg/agent/vault/vault_darwin.go create mode 100644 internal/pkg/agent/vault/vault_linux.go create mode 100644 internal/pkg/agent/vault/vault_test.go create mode 100644 internal/pkg/agent/vault/vault_windows.go create mode 100644 internal/pkg/testutils/testutils.go diff --git a/CHANGELOG.next.asciidoc b/CHANGELOG.next.asciidoc index b66aaa1745d..a4bd5feffd5 100644 --- a/CHANGELOG.next.asciidoc +++ b/CHANGELOG.next.asciidoc @@ -180,4 +180,5 @@ - Fix download verification in snapshot builds. {issue}252[252] - Add support for kubernetes cronjobs {pull}279[279] - Increase the download artifact timeout to 10mins and add log download statistics. {pull}308[308] +- Save the agent configuration and the state encrypted on the disk. {issue}535[535] {pull}398[398] - Bump node.js version for heartbeat/synthetics to 16.15.0 diff --git a/Makefile b/Makefile index 6cec987c2dd..37022ff7d7d 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ notice: -noticeTemplate dev-tools/notice/NOTICE.txt.tmpl \ -noticeOut NOTICE.txt \ -depsOut "" + cat dev-tools/notice/NOTICE.txt.append >> NOTICE.txt ## check-ci: Run all the checks under the ci, this doesn't include the linter which is run via a github action. .PHONY: check-ci diff --git a/NOTICE.txt b/NOTICE.txt index 77b3e14c0c2..5cd23e0750d 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -103,6 +103,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Dependency : github.com/billgraziano/dpapi +Version: v0.4.0 +Licence type (autodetected): MIT +-------------------------------------------------------------------------------- + +Contents of probable licence file $GOMODCACHE/github.com/billgraziano/dpapi@v0.4.0/LICENSE: + +MIT License + +Copyright (c) 2019 Bill Graziano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + -------------------------------------------------------------------------------- Dependency : github.com/blakesmith/ar Version: v0.0.0-20150311145944-8bd4349a67f2 @@ -17417,3 +17448,218 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +Source : https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/mDNSMacOSX/PreferencePane/BonjourPrefTool/BonjourPrefTool.m +Version: 1310.80.1 +Licence type: Apache-2.0 +-------------------------------------------------------------------------------- + +Function CreateAccessWithUid is based on a modification of MyMakeUidAccess +at https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/mDNSMacOSX/PreferencePane/BonjourPrefTool/BonjourPrefTool.m +which is licensed under Apache 2.0 + + +Contents of the file that refers to the licence https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/LICENSE + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/dev-tools/notice/NOTICE.txt.append b/dev-tools/notice/NOTICE.txt.append new file mode 100644 index 00000000000..ddff17888e0 --- /dev/null +++ b/dev-tools/notice/NOTICE.txt.append @@ -0,0 +1,215 @@ +-------------------------------------------------------------------------------- +Source : https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/mDNSMacOSX/PreferencePane/BonjourPrefTool/BonjourPrefTool.m +Version: 1310.80.1 +Licence type: Apache-2.0 +-------------------------------------------------------------------------------- + +Function CreateAccessWithUid is based on a modification of MyMakeUidAccess +at https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/mDNSMacOSX/PreferencePane/BonjourPrefTool/BonjourPrefTool.m +which is licensed under Apache 2.0 + + +Contents of the file that refers to the licence https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/LICENSE + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/go.mod b/go.mod index a87767dc58a..e081cb3a865 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.17 require ( github.com/Microsoft/go-winio v0.5.2 github.com/antlr/antlr4 v0.0.0-20200820155224-be881fa6b91d + github.com/billgraziano/dpapi v0.4.0 github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2 github.com/cavaliercoder/go-rpm v0.0.0-20190131055624-7a9c54e3d83e github.com/coreos/go-systemd/v22 v22.3.2 diff --git a/go.sum b/go.sum index 27cf96a59f9..4cd3152234b 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bi-zone/go-winio v0.4.15 h1:viLHm+U7bzIkfVHuWgc3Wp/sT5zaLoRG7XdOEy1b12w= github.com/bi-zone/go-winio v0.4.15/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= +github.com/billgraziano/dpapi v0.4.0 h1:t39THI1Ld1hkkLVrhkOX6u5TUxwzRddOffq4jcwh2AE= +github.com/billgraziano/dpapi v0.4.0/go.mod h1:gi1Lin0jvovT53j0EXITkY6UPb3hTfI92POaZgj9JBA= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= @@ -1507,6 +1509,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200828161417-c663848e9a16/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/pkg/agent/application/application.go b/internal/pkg/agent/application/application.go index 9c578e9ab8a..7bc0089940f 100644 --- a/internal/pkg/agent/application/application.go +++ b/internal/pkg/agent/application/application.go @@ -105,7 +105,7 @@ func createApplication( func mergeFleetConfig(rawConfig *config.Config) (storage.Store, *configuration.Configuration, error) { path := paths.AgentConfigFile() - store := storage.NewDiskStore(path) + store := storage.NewEncryptedDiskStore(path) reader, err := store.Load() if err != nil { return store, nil, errors.New(err, "could not initialize config store", diff --git a/internal/pkg/agent/application/application_test.go b/internal/pkg/agent/application/application_test.go index e2ea9e6e613..7e792b322f2 100644 --- a/internal/pkg/agent/application/application_test.go +++ b/internal/pkg/agent/application/application_test.go @@ -11,9 +11,12 @@ import ( "github.com/stretchr/testify/require" "github.com/elastic/elastic-agent/internal/pkg/config" + "github.com/elastic/elastic-agent/internal/pkg/testutils" ) func TestMergeFleetConfig(t *testing.T) { + testutils.InitStorage(t) + cfg := map[string]interface{}{ "fleet": map[string]interface{}{ "enabled": true, diff --git a/internal/pkg/agent/application/info/agent_id.go b/internal/pkg/agent/application/info/agent_id.go index d4736165131..e376a0fbfb4 100644 --- a/internal/pkg/agent/application/info/agent_id.go +++ b/internal/pkg/agent/application/info/agent_id.go @@ -52,7 +52,7 @@ func updateLogLevel(level string) error { } agentConfigFile := paths.AgentConfigFile() - diskStore := storage.NewDiskStore(agentConfigFile) + diskStore := storage.NewEncryptedDiskStore(agentConfigFile) ai.LogLevel = level return updateAgentInfo(diskStore, ai) @@ -189,10 +189,11 @@ func loadAgentInfo(forceUpdate bool, logLevel string, createAgentID bool) (*pers if err := idLock.TryLock(); err != nil { return nil, err } + //nolint:errcheck // keeping the same behavior, and making linter happy defer idLock.Unlock() agentConfigFile := paths.AgentConfigFile() - diskStore := storage.NewDiskStore(agentConfigFile) + diskStore := storage.NewEncryptedDiskStore(agentConfigFile) agentinfo, err := getInfoFromStore(diskStore, logLevel) if err != nil { diff --git a/internal/pkg/agent/application/paths/files.go b/internal/pkg/agent/application/paths/files.go index 304df54bded..7d35549e840 100644 --- a/internal/pkg/agent/application/paths/files.go +++ b/internal/pkg/agent/application/paths/files.go @@ -14,8 +14,11 @@ import ( // defaultAgentCapabilitiesFile is a name of file used to store agent capabilities const defaultAgentCapabilitiesFile = "capabilities.yml" -// defaultAgentFleetFile is a name of file used to store agent information -const defaultAgentFleetFile = "fleet.yml" +// defaultAgentFleetYmlFile is a name of file used to store agent information +const defaultAgentFleetYmlFile = "fleet.yml" + +// defaultAgentFleetFile is a name of file used to store agent information encrypted +const defaultAgentFleetFile = "fleet.enc" // defaultAgentEnrollFile is a name of file used to enroll agent on first-start const defaultAgentEnrollFile = "enroll.yml" @@ -23,8 +26,24 @@ const defaultAgentEnrollFile = "enroll.yml" // defaultAgentActionStoreFile is the file that will contain the action that can be replayed after restart. const defaultAgentActionStoreFile = "action_store.yml" -// defaultAgentStateStoreFile is the file that will contain the action that can be replayed after restart. -const defaultAgentStateStoreFile = "state.yml" +// defaultAgentStateStoreYmlFile is the file that will contain the action that can be replayed after restart. +const defaultAgentStateStoreYmlFile = "state.yml" + +// defaultAgentStateStoreFile is the file that will contain the action that can be replayed after restart encrypted. +const defaultAgentStateStoreFile = "state.enc" + +// AgentConfigYmlFile is a name of file used to store agent information +func AgentConfigYmlFile() string { + return filepath.Join(Config(), defaultAgentFleetYmlFile) +} + +// AgentConfigYmlFileLock is a locker for agent config file updates. +func AgentConfigYmlFileLock() *filelock.AppLocker { + return filelock.NewAppLocker( + Config(), + fmt.Sprintf("%s.lock", defaultAgentFleetYmlFile), + ) +} // AgentConfigFile is a name of file used to store agent information func AgentConfigFile() string { @@ -54,7 +73,12 @@ func AgentActionStoreFile() string { return filepath.Join(Home(), defaultAgentActionStoreFile) } -// AgentStateStoreFile is the file that contains the persisted state of the agent including the action that can be replayed after restart. +// AgentStateStoreYmlFile is the file that contains the persisted state of the agent including the action that can be replayed after restart. +func AgentStateStoreYmlFile() string { + return filepath.Join(Home(), defaultAgentStateStoreYmlFile) +} + +// AgentStateStoreFile is the file that contains the persisted state of the agent including the action that can be replayed after restart encrypted. func AgentStateStoreFile() string { return filepath.Join(Home(), defaultAgentStateStoreFile) } diff --git a/internal/pkg/agent/application/paths/paths_darwin.go b/internal/pkg/agent/application/paths/paths_darwin.go index 8482d88d533..1a60c53ff8d 100644 --- a/internal/pkg/agent/application/paths/paths_darwin.go +++ b/internal/pkg/agent/application/paths/paths_darwin.go @@ -27,9 +27,17 @@ const ( ShellWrapper = `#!/bin/sh exec /Library/Elastic/Agent/elastic-agent $@ ` + + // defaultAgentVaultName is keychain item name for mac + defaultAgentVaultName = "co.elastic.elastic-agent" ) // ArePathsEqual determines whether paths are equal taking case sensitivity of os into account. func ArePathsEqual(expected, actual string) bool { return expected == actual } + +// AgentVaultPath is keychain name on Mac OS +func AgentVaultPath() string { + return defaultAgentVaultName +} diff --git a/internal/pkg/agent/application/paths/paths_linux.go b/internal/pkg/agent/application/paths/paths_linux.go new file mode 100644 index 00000000000..22faeb5f75a --- /dev/null +++ b/internal/pkg/agent/application/paths/paths_linux.go @@ -0,0 +1,18 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux +// +build linux + +package paths + +import "path/filepath" + +// defaultAgentVaultPath is the directory for linux where the vault store is located or the +const defaultAgentVaultPath = "vault" + +// AgentVaultPath is the directory that contains all the files for the value +func AgentVaultPath() string { + return filepath.Join(Home(), defaultAgentVaultPath) +} diff --git a/internal/pkg/agent/application/paths/paths_windows.go b/internal/pkg/agent/application/paths/paths_windows.go index 3250b847e98..2fc6fd008a0 100644 --- a/internal/pkg/agent/application/paths/paths_windows.go +++ b/internal/pkg/agent/application/paths/paths_windows.go @@ -7,7 +7,10 @@ package paths -import "strings" +import ( + "path/filepath" + "strings" +) const ( // BinaryName is the name of the installed binary. @@ -27,9 +30,17 @@ const ( // ShellWrapper is the wrapper that is installed. ShellWrapper = "" // no wrapper on Windows + + // defaultAgentVaultPath is the directory for windows where the vault store is located or the + defaultAgentVaultPath = "vault" ) // ArePathsEqual determines whether paths are equal taking case sensitivity of os into account. func ArePathsEqual(expected, actual string) bool { return strings.EqualFold(expected, actual) } + +// AgentVaultPath is the directory that contains all the files for the value +func AgentVaultPath() string { + return filepath.Join(Home(), defaultAgentVaultPath) +} diff --git a/internal/pkg/agent/application/pipeline/emitter/modifiers/monitoring_decorator_test.go b/internal/pkg/agent/application/pipeline/emitter/modifiers/monitoring_decorator_test.go index 64af1a93cb6..735e27cd725 100644 --- a/internal/pkg/agent/application/pipeline/emitter/modifiers/monitoring_decorator_test.go +++ b/internal/pkg/agent/application/pipeline/emitter/modifiers/monitoring_decorator_test.go @@ -11,93 +11,42 @@ import ( "github.com/elastic/elastic-agent/internal/pkg/agent/application/info" "github.com/elastic/elastic-agent/internal/pkg/agent/program" "github.com/elastic/elastic-agent/internal/pkg/agent/transpiler" + "github.com/elastic/elastic-agent/internal/pkg/testutils" ) func TestMonitoringInjection(t *testing.T) { - agentInfo, err := info.NewAgentInfo(true) - if err != nil { - t.Fatal(err) - } - ast, err := transpiler.NewAST(inputConfigMap) - if err != nil { - t.Fatal(err) - } - - programsToRun, err := program.Programs(agentInfo, ast) - if err != nil { - t.Fatal(err) + tests := []struct { + name string + inputConfig map[string]interface{} + uname string + }{ + { + name: "testMonitoringInjection", + inputConfig: inputConfigMap, + uname: "monitoring-uname", + }, + { + name: "testMonitoringInjectionDefaults", + inputConfig: inputConfigMapDefaults, + uname: "xxx", + }, } - if len(programsToRun) != 1 { - t.Fatal(fmt.Errorf("programsToRun expected to have %d entries", 1)) - } - -GROUPLOOP: - for group, ptr := range programsToRun { - programsCount := len(ptr) - newPtr, err := InjectMonitoring(agentInfo, group, ast, ptr) - if err != nil { - t.Error(err) - continue GROUPLOOP - } - - if programsCount+1 != len(newPtr) { - t.Errorf("incorrect programs to run count, expected: %d, got %d", programsCount+1, len(newPtr)) - continue GROUPLOOP - } - - for _, p := range newPtr { - if p.Spec.Name != MonitoringName { - continue - } - - cm, err := p.Config.Map() - if err != nil { - t.Error(err) - continue GROUPLOOP - } - - outputCfg, found := cm[outputKey] - if !found { - t.Errorf("output not found for '%s'", group) - continue GROUPLOOP - } - - outputMap, ok := outputCfg.(map[string]interface{}) - if !ok { - t.Errorf("output is not a map for '%s'", group) - continue GROUPLOOP - } - - esCfg, found := outputMap["elasticsearch"] - if !found { - t.Errorf("elasticsearch output not found for '%s'", group) - continue GROUPLOOP - } - - esMap, ok := esCfg.(map[string]interface{}) - if !ok { - t.Errorf("output.elasticsearch is not a map for '%s'", group) - continue GROUPLOOP - } - - if uname, found := esMap["username"]; !found { - t.Errorf("output.elasticsearch.username output not found for '%s'", group) - continue GROUPLOOP - } else if uname != "monitoring-uname" { - t.Errorf("output.elasticsearch.username has incorrect value expected '%s', got '%s for %s", "monitoring-uname", uname, group) - continue GROUPLOOP - } - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + testMonitoringInjection(t, tc.inputConfig, tc.uname) + }) } } -func TestMonitoringInjectionDefaults(t *testing.T) { +func testMonitoringInjection(t *testing.T, inputConfig map[string]interface{}, testUname string) { + testutils.InitStorage(t) + agentInfo, err := info.NewAgentInfo(true) if err != nil { t.Fatal(err) } - ast, err := transpiler.NewAST(inputConfigMapDefaults) + ast, err := transpiler.NewAST(inputConfig) if err != nil { t.Fatal(err) } @@ -163,7 +112,7 @@ GROUPLOOP: if uname, found := esMap["username"]; !found { t.Errorf("output.elasticsearch.username output not found for '%s'", group) continue GROUPLOOP - } else if uname != "xxx" { + } else if uname != testUname { t.Errorf("output.elasticsearch.username has incorrect value expected '%s', got '%s for %s", "monitoring-uname", uname, group) continue GROUPLOOP } @@ -172,6 +121,8 @@ GROUPLOOP: } func TestMonitoringToLogstashInjection(t *testing.T) { + testutils.InitStorage(t) + agentInfo, err := info.NewAgentInfo(true) if err != nil { t.Fatal(err) @@ -251,6 +202,8 @@ GROUPLOOP: } func TestMonitoringInjectionDisabled(t *testing.T) { + testutils.InitStorage(t) + agentInfo, err := info.NewAgentInfo(true) if err != nil { t.Fatal(err) @@ -340,6 +293,8 @@ GROUPLOOP: } func TestChangeInMonitoringWithChangeInInput(t *testing.T) { + testutils.InitStorage(t) + agentInfo, err := info.NewAgentInfo(true) if err != nil { t.Fatal(err) diff --git a/internal/pkg/agent/application/secret/secret.go b/internal/pkg/agent/application/secret/secret.go new file mode 100644 index 00000000000..341064b116a --- /dev/null +++ b/internal/pkg/agent/application/secret/secret.go @@ -0,0 +1,126 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package secret + +import ( + "encoding/json" + "runtime" + "time" + + "github.com/elastic/elastic-agent/internal/pkg/agent/application/paths" + "github.com/elastic/elastic-agent/internal/pkg/agent/vault" +) + +const agentSecretKey = "secret" + +// Secret is the structure that is JSON serialized and stored +type Secret struct { + Value []byte `json:"v"` // binary value + CreatedOn time.Time `json:"t"` // date/time the secret was created on +} + +type options struct { + vaultPath string +} + +type OptionFunc func(o *options) + +// WithVaultPath allows to specify the vault path, doesn't apply for darwin +func WithVaultPath(vaultPath string) OptionFunc { + return func(o *options) { + if runtime.GOOS == "darwin" { + return + } + o.vaultPath = vaultPath + } +} + +// CreateAgentSecret creates agent secret key if it doesn't exist +func CreateAgentSecret(opts ...OptionFunc) error { + return Create(agentSecretKey, opts...) +} + +// Create creates secret and stores it in the vault under given key +func Create(key string, opts ...OptionFunc) error { + options := applyOptions(opts...) + v, err := vault.New(options.vaultPath) + if err != nil { + return err + } + defer v.Close() + + // Check if the key exists + exists, err := v.Exists(key) + if err != nil { + return err + } + if exists { + return nil + } + + // Create new AES256 key + k, err := vault.NewKey(vault.AES256) + if err != nil { + return err + } + + secret := Secret{ + Value: k, + CreatedOn: time.Now().UTC(), + } + + b, err := json.Marshal(secret) + if err != nil { + return err + } + + return v.Set(key, b) +} + +// GetAgentSecret read the agent secret from the vault +func GetAgentSecret(opts ...OptionFunc) (secret Secret, err error) { + return Get(agentSecretKey, opts...) +} + +// Get reads the secret key from the vault +func Get(key string, opts ...OptionFunc) (secret Secret, err error) { + options := applyOptions(opts...) + v, err := vault.New(options.vaultPath) + if err != nil { + return secret, err + } + defer v.Close() + + b, err := v.Get(key) + if err != nil { + return secret, err + } + + err = json.Unmarshal(b, &secret) + return secret, err +} + +// Remove removes the secret key from the vault +func Remove(key string, opts ...OptionFunc) error { + options := applyOptions(opts...) + v, err := vault.New(options.vaultPath) + if err != nil { + return err + } + defer v.Close() + + return v.Remove(key) +} + +func applyOptions(opts ...OptionFunc) options { + o := options{ + vaultPath: paths.AgentVaultPath(), + } + + for _, opt := range opts { + opt(&o) + } + return o +} diff --git a/internal/pkg/agent/application/secret/secret_test.go b/internal/pkg/agent/application/secret/secret_test.go new file mode 100644 index 00000000000..cd5d2864753 --- /dev/null +++ b/internal/pkg/agent/application/secret/secret_test.go @@ -0,0 +1,71 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || windows +// +build linux windows + +package secret + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/elastic/elastic-agent/internal/pkg/agent/vault" +) + +func getTestVaultPath(t *testing.T) string { + dir := t.TempDir() + return filepath.Join(dir, "vault", "co.elastic.agent") +} + +func getTestOptions(t *testing.T) []OptionFunc { + return []OptionFunc{ + WithVaultPath(getTestVaultPath(t)), + } +} + +func TestCreate(t *testing.T) { + vault.DisableRootCheck() + + opts := getTestOptions(t) + + start := time.Now().UTC() + keys := []string{"secret1", "secret2", "secret3"} + for _, key := range keys { + err := Create(key, opts...) + if err != nil { + t.Fatal(err) + } + } + end := time.Now().UTC() + + for _, key := range keys { + secret, err := Get(key, opts...) + if err != nil { + t.Error(err) + } + + if secret.CreatedOn.Before(start) || secret.CreatedOn.After(end) { + t.Errorf("invalid created on date/time: %v", secret.CreatedOn) + } + + diff := cmp.Diff(int(vault.AES256), len(secret.Value)) + if diff != "" { + t.Error(diff) + } + } + + for _, key := range keys { + err := Remove(key, opts...) + if err != nil { + t.Fatal(err) + } + } + + os.RemoveAll(filepath.Dir(getTestVaultPath(t))) +} diff --git a/internal/pkg/agent/application/upgrade/step_mark.go b/internal/pkg/agent/application/upgrade/step_mark.go index c168f8e5ad0..e176e4c5b96 100644 --- a/internal/pkg/agent/application/upgrade/step_mark.go +++ b/internal/pkg/agent/application/upgrade/step_mark.go @@ -39,7 +39,7 @@ type UpdateMarker struct { } // markUpgrade marks update happened so we can handle grace period -func (h *Upgrader) markUpgrade(ctx context.Context, hash string, action Action) error { +func (u *Upgrader) markUpgrade(_ context.Context, hash string, action Action) error { prevVersion := release.Version() prevHash := release.Commit() if len(prevHash) > hashLen { @@ -74,7 +74,7 @@ func (h *Upgrader) markUpgrade(ctx context.Context, hash string, action Action) // UpdateActiveCommit updates active.commit file to point to active version. func UpdateActiveCommit(hash string) error { activeCommitPath := filepath.Join(paths.Top(), agentCommitFile) - if err := ioutil.WriteFile(activeCommitPath, []byte(hash), 0644); err != nil { + if err := ioutil.WriteFile(activeCommitPath, []byte(hash), 0600); err != nil { return errors.New(err, errors.TypeFilesystem, "failed to update active commit", errors.M(errors.MetaKeyPath, activeCommitPath)) } diff --git a/internal/pkg/agent/application/upgrade/upgrade.go b/internal/pkg/agent/application/upgrade/upgrade.go index 00a6dc1a68b..81fb7a78444 100644 --- a/internal/pkg/agent/application/upgrade/upgrade.go +++ b/internal/pkg/agent/application/upgrade/upgrade.go @@ -5,11 +5,13 @@ package upgrade import ( + "bytes" "context" "fmt" "io/ioutil" "os" "path/filepath" + "runtime" "strings" "github.com/otiai10/copy" @@ -18,8 +20,10 @@ import ( "github.com/elastic/elastic-agent/internal/pkg/agent/application/info" "github.com/elastic/elastic-agent/internal/pkg/agent/application/paths" "github.com/elastic/elastic-agent/internal/pkg/agent/application/reexec" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/secret" "github.com/elastic/elastic-agent/internal/pkg/agent/errors" "github.com/elastic/elastic-agent/internal/pkg/agent/program" + "github.com/elastic/elastic-agent/internal/pkg/agent/storage" "github.com/elastic/elastic-agent/internal/pkg/artifact" "github.com/elastic/elastic-agent/internal/pkg/capabilities" "github.com/elastic/elastic-agent/internal/pkg/core/state" @@ -32,6 +36,7 @@ const ( agentName = "elastic-agent" hashLen = 6 agentCommitFile = ".elastic-agent.active.commit" + darwin = "darwin" ) var ( @@ -134,7 +139,7 @@ func (u *Upgrader) Upgrade(ctx context.Context, a Action, reexecNow bool) (_ ree u.reportUpdating(a.Version()) - sourceURI, err := u.sourceURI(a.Version(), a.SourceURI()) + sourceURI := u.sourceURI(a.SourceURI()) archivePath, err := u.downloadArtifact(ctx, a.Version(), sourceURI) if err != nil { return nil, err @@ -152,16 +157,26 @@ func (u *Upgrader) Upgrade(ctx context.Context, a Action, reexecNow bool) (_ ree if strings.HasPrefix(release.Commit(), newHash) { // not an error if action := a.FleetAction(); action != nil { + //nolint:errcheck // keeping the same behavior, and making linter happy u.ackAction(ctx, action) } u.log.Warn("upgrading to same version") return nil, nil } + // Copy vault directory for linux/windows only + if err := copyVault(newHash); err != nil { + return nil, errors.New(err, "failed to copy vault") + } + if err := copyActionStore(newHash); err != nil { return nil, errors.New(err, "failed to copy action store") } + if err := encryptConfigIfNeeded(u.log, newHash); err != nil { + return nil, errors.New(err, "failed to encrypt the configuration") + } + if err := ChangeSymlink(ctx, newHash); err != nil { rollbackInstall(ctx, newHash) return nil, err @@ -208,12 +223,12 @@ func (u *Upgrader) Ack(ctx context.Context) error { return saveMarker(marker) } -func (u *Upgrader) sourceURI(version, retrievedURI string) (string, error) { +func (u *Upgrader) sourceURI(retrievedURI string) string { if retrievedURI != "" { - return retrievedURI, nil + return retrievedURI } - return u.settings.SourceURI, nil + return u.settings.SourceURI } // ackAction is used for successful updates, it was either updated successfully or to the same version @@ -240,7 +255,7 @@ func (u *Upgrader) ackAction(ctx context.Context, action fleetapi.Action) error // and state is changed to FAILED func (u *Upgrader) reportFailure(ctx context.Context, action fleetapi.Action, err error) { // ack action - u.acker.Ack(ctx, action) + _ = u.acker.Ack(ctx, action) // report failure u.reporter.OnStateChange( @@ -262,11 +277,12 @@ func (u *Upgrader) reportUpdating(version string) { func rollbackInstall(ctx context.Context, hash string) { os.RemoveAll(filepath.Join(paths.Data(), fmt.Sprintf("%s-%s", agentName, hash))) - ChangeSymlink(ctx, release.ShortCommit()) + _ = ChangeSymlink(ctx, release.ShortCommit()) } func copyActionStore(newHash string) error { - storePaths := []string{paths.AgentActionStoreFile(), paths.AgentStateStoreFile()} + // copies legacy action_store.yml, state.yml and state.enc encrypted file if exists + storePaths := []string{paths.AgentActionStoreFile(), paths.AgentStateStoreYmlFile(), paths.AgentStateStoreFile()} for _, currentActionStorePath := range storePaths { newHome := filepath.Join(filepath.Dir(paths.Home()), fmt.Sprintf("%s-%s", agentName, newHash)) @@ -289,10 +305,107 @@ func copyActionStore(newHash string) error { return nil } +func getVaultPath(newHash string) string { + vaultPath := paths.AgentVaultPath() + if runtime.GOOS == darwin { + return vaultPath + } + newHome := filepath.Join(filepath.Dir(paths.Home()), fmt.Sprintf("%s-%s", agentName, newHash)) + return filepath.Join(newHome, filepath.Base(vaultPath)) +} + +// Copies the vault files for windows and linux +func copyVault(newHash string) error { + // No vault files to copy on darwin + if runtime.GOOS == darwin { + return nil + } + + vaultPath := paths.AgentVaultPath() + newVaultPath := getVaultPath(newHash) + + err := copyDir(vaultPath, newVaultPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + return nil +} + +// Create the key if it doesn't exist and encrypt the fleet.yml and state.yml +func encryptConfigIfNeeded(log *logger.Logger, newHash string) (err error) { + vaultPath := getVaultPath(newHash) + + err = secret.CreateAgentSecret(secret.WithVaultPath(vaultPath)) + if err != nil { + return err + } + + newHome := filepath.Join(filepath.Dir(paths.Home()), fmt.Sprintf("%s-%s", agentName, newHash)) + ymlStateStorePath := filepath.Join(newHome, filepath.Base(paths.AgentStateStoreYmlFile())) + stateStorePath := filepath.Join(newHome, filepath.Base(paths.AgentStateStoreFile())) + + files := []struct { + Src string + Dst string + }{ + { + Src: ymlStateStorePath, + Dst: stateStorePath, + }, + { + Src: paths.AgentConfigYmlFile(), + Dst: paths.AgentConfigFile(), + }, + } + for _, f := range files { + var b []byte + b, err = ioutil.ReadFile(f.Src) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + + // Encrypt yml file + store := storage.NewEncryptedDiskStore(f.Dst, storage.WithVaultPath(vaultPath)) + err = store.Save(bytes.NewReader(b)) + if err != nil { + return err + } + + // Remove yml file if no errors + defer func(fp string) { + if err != nil { + return + } + if rerr := os.Remove(fp); rerr != nil { + log.Warnf("failed to remove file: %s, err: %v", fp, rerr) + } + }(f.Src) + } + + // Do not remove AgentConfigYmlFile lock file if any error happened. + if err != nil { + return err + } + + lockFp := paths.AgentConfigYmlFile() + ".lock" + if rerr := os.Remove(lockFp); rerr != nil { + log.Warnf("failed to remove file: %s, err: %v", lockFp, rerr) + } + + return err +} + // shutdownCallback returns a callback function to be executing during shutdown once all processes are closed. // this goes through runtime directory of agent and copies all the state files created by processes to new versioned // home directory with updated process name to match new version. -func shutdownCallback(log *logger.Logger, homePath, prevVersion, newVersion, newHash string) reexec.ShutdownCallbackFn { +func shutdownCallback(_ *logger.Logger, homePath, prevVersion, newVersion, newHash string) reexec.ShutdownCallbackFn { if release.Snapshot() { // SNAPSHOT is part of newVersion prevVersion += "-SNAPSHOT" @@ -300,7 +413,7 @@ func shutdownCallback(log *logger.Logger, homePath, prevVersion, newVersion, new return func() error { runtimeDir := filepath.Join(homePath, "run") - processDirs, err := readProcessDirs(log, runtimeDir) + processDirs, err := readProcessDirs(runtimeDir) if err != nil { return err } @@ -318,15 +431,15 @@ func shutdownCallback(log *logger.Logger, homePath, prevVersion, newVersion, new } } -func readProcessDirs(log *logger.Logger, runtimeDir string) ([]string, error) { - pipelines, err := readDirs(log, runtimeDir) +func readProcessDirs(runtimeDir string) ([]string, error) { + pipelines, err := readDirs(runtimeDir) if err != nil { return nil, err } processDirs := make([]string, 0) for _, p := range pipelines { - dirs, err := readDirs(log, p) + dirs, err := readDirs(p) if err != nil { return nil, err } @@ -338,7 +451,7 @@ func readProcessDirs(log *logger.Logger, runtimeDir string) ([]string, error) { } // readDirs returns list of absolute paths to directories inside specified path. -func readDirs(log *logger.Logger, dir string) ([]string, error) { +func readDirs(dir string) ([]string, error) { dirEntries, err := os.ReadDir(dir) if err != nil && !os.IsNotExist(err) { return nil, err diff --git a/internal/pkg/agent/cmd/enroll_cmd.go b/internal/pkg/agent/cmd/enroll_cmd.go index 1cf32205fe9..6525a8a9fde 100644 --- a/internal/pkg/agent/cmd/enroll_cmd.go +++ b/internal/pkg/agent/cmd/enroll_cmd.go @@ -25,6 +25,7 @@ import ( "github.com/elastic/elastic-agent/internal/pkg/agent/application/filelock" "github.com/elastic/elastic-agent/internal/pkg/agent/application/info" "github.com/elastic/elastic-agent/internal/pkg/agent/application/paths" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/secret" "github.com/elastic/elastic-agent/internal/pkg/agent/configuration" "github.com/elastic/elastic-agent/internal/pkg/agent/control/client" "github.com/elastic/elastic-agent/internal/pkg/agent/control/proto" @@ -110,6 +111,7 @@ type enrollCmdOption struct { FixPermissions bool `yaml:"-"` DelayEnroll bool `yaml:"-"` FleetServer enrollCmdFleetServerOption `yaml:"-"` + SkipCreateSecret bool `yaml:"-"` Tags []string `yaml:"omitempty"` } @@ -157,7 +159,7 @@ func newEnrollCmd( store := storage.NewReplaceOnSuccessStore( configPath, application.DefaultAgentFleetConfig, - storage.NewDiskStore(paths.AgentConfigFile()), + storage.NewEncryptedDiskStore(paths.AgentConfigFile()), ) return newEnrollCmdWithStore( @@ -193,6 +195,14 @@ func (c *enrollCmd) Execute(ctx context.Context, streams *cli.IOStreams) error { span.End() }() + // Create encryption key from the agent before touching configuration + if !c.options.SkipCreateSecret { + err = secret.CreateAgentSecret() + if err != nil { + return err + } + } + persistentConfig, err := getPersistentConfig(c.configPath) if err != nil { return err diff --git a/internal/pkg/agent/cmd/enroll_cmd_test.go b/internal/pkg/agent/cmd/enroll_cmd_test.go index 77ae23f6c8d..a2835890778 100644 --- a/internal/pkg/agent/cmd/enroll_cmd_test.go +++ b/internal/pkg/agent/cmd/enroll_cmd_test.go @@ -14,6 +14,7 @@ import ( "net/http" "net/http/httptest" "os" + "runtime" "strconv" "testing" @@ -24,6 +25,7 @@ import ( "github.com/elastic/elastic-agent/internal/pkg/cli" "github.com/elastic/elastic-agent/internal/pkg/config" "github.com/elastic/elastic-agent/internal/pkg/core/authority" + "github.com/elastic/elastic-agent/internal/pkg/testutils" "github.com/elastic/elastic-agent/pkg/core/logger" ) @@ -46,6 +48,12 @@ func (m *mockStore) Save(in io.Reader) error { } func TestEnroll(t *testing.T) { + testutils.InitStorage(t) + skipCreateSecret := false + if runtime.GOOS == "darwin" { + skipCreateSecret = true + } + log, _ := logger.New("tst", false) t.Run("fail to save is propagated", withTLSServer( @@ -89,6 +97,7 @@ func TestEnroll(t *testing.T) { CAs: []string{caFile}, EnrollAPIKey: "my-enrollment-token", UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, }, "", store, @@ -142,6 +151,7 @@ func TestEnroll(t *testing.T) { CAs: []string{caFile}, EnrollAPIKey: "my-enrollment-api-key", UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, }, "", store, @@ -198,6 +208,7 @@ func TestEnroll(t *testing.T) { EnrollAPIKey: "my-enrollment-api-key", Insecure: true, UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, }, "", store, @@ -256,6 +267,7 @@ func TestEnroll(t *testing.T) { EnrollAPIKey: "my-enrollment-api-key", Insecure: true, UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, }, "", store, @@ -299,6 +311,7 @@ func TestEnroll(t *testing.T) { EnrollAPIKey: "my-enrollment-token", Insecure: true, UserProvidedMetadata: map[string]interface{}{"custom": "customize"}, + SkipCreateSecret: skipCreateSecret, }, "", store, @@ -392,7 +405,8 @@ func withTLSServer( s := http.Server{ Handler: m(t), TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{serverCert}, MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{serverCert}, + MinVersion: tls.VersionTLS12, }, } diff --git a/internal/pkg/agent/cmd/run.go b/internal/pkg/agent/cmd/run.go index 17f413401f0..7f3f3684d4e 100644 --- a/internal/pkg/agent/cmd/run.go +++ b/internal/pkg/agent/cmd/run.go @@ -273,7 +273,7 @@ func getOverwrites(rawConfig *config.Config) error { return nil } path := paths.AgentConfigFile() - store := storage.NewDiskStore(path) + store := storage.NewEncryptedDiskStore(path) reader, err := store.Load() if err != nil && errors.Is(err, os.ErrNotExist) { diff --git a/internal/pkg/agent/configuration/configuration.go b/internal/pkg/agent/configuration/configuration.go index 9b5246e4aa2..cfa7782be44 100644 --- a/internal/pkg/agent/configuration/configuration.go +++ b/internal/pkg/agent/configuration/configuration.go @@ -5,10 +5,7 @@ package configuration import ( - "fmt" - "github.com/elastic/elastic-agent/internal/pkg/agent/errors" - "github.com/elastic/elastic-agent/internal/pkg/agent/storage" "github.com/elastic/elastic-agent/internal/pkg/config" ) @@ -36,27 +33,6 @@ func NewFromConfig(cfg *config.Config) (*Configuration, error) { return c, nil } -// NewFromFile uses unencrypted disk store to load a configuration. -func NewFromFile(path string) (*Configuration, error) { - store := storage.NewDiskStore(path) - reader, err := store.Load() - if err != nil { - return nil, errors.New(err, "could not initialize config store", - errors.TypeFilesystem, - errors.M(errors.MetaKeyPath, path)) - } - - config, err := config.NewConfigFrom(reader) - if err != nil { - return nil, errors.New(err, - fmt.Sprintf("fail to read configuration %s for the elastic-agent", path), - errors.TypeFilesystem, - errors.M(errors.MetaKeyPath, path)) - } - - return NewFromConfig(config) -} - // AgentInfo is a set of agent information. type AgentInfo struct { ID string `json:"id" yaml:"id" config:"id"` diff --git a/internal/pkg/agent/operation/monitoring_test.go b/internal/pkg/agent/operation/monitoring_test.go index 39302d2fa34..cc365cae540 100644 --- a/internal/pkg/agent/operation/monitoring_test.go +++ b/internal/pkg/agent/operation/monitoring_test.go @@ -14,6 +14,7 @@ import ( "go.elastic.co/apm/apmtest" "github.com/elastic/elastic-agent/internal/pkg/agent/program" + "github.com/elastic/elastic-agent/internal/pkg/testutils" "github.com/elastic/elastic-agent-client/v7/pkg/proto" @@ -66,6 +67,8 @@ func TestExportedMetrics(t *testing.T) { } func TestGenerateSteps(t *testing.T) { + testutils.InitStorage(t) + const sampleOutput = "sample-output" const outputType = "logstash" @@ -222,13 +225,15 @@ func (b *testMonitor) Close() {} // Prepare executes steps in order for monitoring to work correctly func (b *testMonitor) Prepare(program.Spec, string, int, int) error { return nil } +const testPath = "path" + // LogPath describes a path where application stores logs. Empty if // application is not monitorable func (b *testMonitor) LogPath(program.Spec, string) string { if !b.monitorLogs { return "" } - return "path" + return testPath } // MetricsPath describes a location where application exposes metrics @@ -237,7 +242,7 @@ func (b *testMonitor) MetricsPath(program.Spec, string) string { if !b.monitorMetrics { return "" } - return "path" + return testPath } // MetricsPathPrefixed return metrics path prefixed with http+ prefix. diff --git a/internal/pkg/agent/storage/encrypted_disk_storage_windows_linux_test.go b/internal/pkg/agent/storage/encrypted_disk_storage_windows_linux_test.go new file mode 100644 index 00000000000..e6e6e5d6fd8 --- /dev/null +++ b/internal/pkg/agent/storage/encrypted_disk_storage_windows_linux_test.go @@ -0,0 +1,112 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || windows +// +build linux windows + +package storage + +import ( + "bytes" + "errors" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/elastic/elastic-agent/internal/pkg/agent/application/secret" + "github.com/elastic/elastic-agent/internal/pkg/agent/vault" + "github.com/google/go-cmp/cmp" +) + +const ( + testConfigFile = "someconfig.enc" + vaultDir = "vault" +) + +func TestEncryptedDiskStorageWindowsLinuxLoad(t *testing.T) { + // Disable root permissions check + vault.DisableRootCheck() + + dir := t.TempDir() + + fp := filepath.Join(dir, testConfigFile) + s := NewEncryptedDiskStore(fp, WithVaultPath(dir)) + + // Test that the file loads and doesn't create vault + r, err := s.Load() + if err != nil { + t.Fatal(err) + } + defer r.Close() + + b, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + + // Expect empty content from the reader + diff := cmp.Diff(0, len(b)) + if diff != "" { + t.Error(diff) + } + + // Expect no vault directory was created + vdir := filepath.Join(dir, vaultDir) + if _, err := os.Stat(vdir); !os.IsNotExist(err) { + t.Fatal(err) + } + + // Save some data + // expect fs.PathError, no agent secret key was created yet + data := []byte("foobar config\ndata") + err = s.Save(bytes.NewBuffer(data)) + if err != nil { + var perr *fs.PathError + if !errors.As(err, &perr) { + t.Fatal(err) + } + } + + // Create agent secret + err = secret.CreateAgentSecret(secret.WithVaultPath(dir)) + if err != nil { + t.Fatal(err) + } + + // Save agent secret, expected to be saved + err = s.Save(bytes.NewBuffer(data)) + if err != nil { + t.Fatal(err) + } + + // Expect the stored file to exist + exists, err := s.Exists() + if err != nil { + t.Fatal(err) + } + diff = cmp.Diff(true, exists) + if diff != "" { + t.Error(diff) + } + + // Load content + nr, err := s.Load() + if err != nil { + t.Fatal(err) + } + defer nr.Close() + + b, err = ioutil.ReadAll(nr) + if err != nil { + t.Fatal(err) + } + + // Expect the content to match + diff = cmp.Diff(b, data) + if diff != "" { + t.Error(diff) + } +} diff --git a/internal/pkg/agent/storage/encrypted_disk_store.go b/internal/pkg/agent/storage/encrypted_disk_store.go new file mode 100644 index 00000000000..affd030d036 --- /dev/null +++ b/internal/pkg/agent/storage/encrypted_disk_store.go @@ -0,0 +1,181 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package storage + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "runtime" + + "github.com/hectane/go-acl" + + "github.com/elastic/elastic-agent-libs/file" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/paths" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/secret" + "github.com/elastic/elastic-agent/internal/pkg/agent/errors" + "github.com/elastic/elastic-agent/internal/pkg/crypto" +) + +const darwin = "darwin" + +var encryptionDisabled bool + +// DisableEncryptionDarwin disables storage encryption. +// Is needed for existing unit tests on Mac OS, because the system keychain requires sudo +func DisableEncryptionDarwin() { + if runtime.GOOS == darwin { + encryptionDisabled = true + } +} + +type OptionFunc func(s *EncryptedDiskStore) + +// NewEncryptedDiskStore creates an encrypted disk store. +// Drop-in replacement for NewDiskStorage +func NewEncryptedDiskStore(target string, opts ...OptionFunc) Storage { + if encryptionDisabled { + return NewDiskStore(target) + } + s := &EncryptedDiskStore{ + target: target, + vaultPath: paths.AgentVaultPath(), + } + for _, opt := range opts { + opt(s) + } + return s +} + +func WithVaultPath(vaultPath string) OptionFunc { + return func(s *EncryptedDiskStore) { + if runtime.GOOS == darwin { + return + } + s.vaultPath = vaultPath + } +} + +func (d *EncryptedDiskStore) Exists() (bool, error) { + _, err := os.Stat(d.target) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil +} + +func (d *EncryptedDiskStore) ensureKey() error { + if d.key == nil { + key, err := secret.GetAgentSecret(secret.WithVaultPath(d.vaultPath)) + if err != nil { + return err + } + d.key = key.Value + } + return nil +} + +func (d *EncryptedDiskStore) Save(in io.Reader) error { + // Ensure has agent key + err := d.ensureKey() + if err != nil { + return err + } + + tmpFile := d.target + ".tmp" + + fd, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perms) + if err != nil { + return errors.New(err, + fmt.Sprintf("could not save to %s", tmpFile), + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, tmpFile)) + } + + // Always clean up the temporary file and ignore errors. + defer os.Remove(tmpFile) + + // Wrap into crypto writer, reusing already existing crypto writer, open to other suggestions + w, err := crypto.NewWriterWithDefaults(fd, d.key) + if err != nil { + fd.Close() + return err + } + + if _, err := io.Copy(w, in); err != nil { + if err := fd.Close(); err != nil { + return errors.New(err, "could not close temporary file", + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, tmpFile)) + } + + return errors.New(err, "could not save content on disk", + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, tmpFile)) + } + + if err := fd.Sync(); err != nil { + return errors.New(err, + fmt.Sprintf("could not sync temporary file %s", d.target), + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, tmpFile)) + } + + if err := fd.Close(); err != nil { + return errors.New(err, "could not close temporary file", + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, tmpFile)) + } + + if err := file.SafeFileRotate(d.target, tmpFile); err != nil { + return errors.New(err, + fmt.Sprintf("could not replace target file %s", d.target), + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, d.target)) + } + + if err := acl.Chmod(d.target, perms); err != nil { + return errors.New(err, + fmt.Sprintf("could not set permissions target file %s", d.target), + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, d.target)) + } + + return nil +} + +func (d *EncryptedDiskStore) Load() (rc io.ReadCloser, err error) { + fd, err := os.OpenFile(d.target, os.O_RDONLY, perms) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // If file doesn't exists, return empty reader closer + return io.NopCloser(bytes.NewReader([]byte{})), nil + } + return nil, errors.New(err, + fmt.Sprintf("could not open %s", d.target), + errors.TypeFilesystem, + errors.M(errors.MetaKeyPath, d.target)) + } + + // Close fd if there is an error upon return + defer func() { + if err != nil && fd != nil { + _ = fd.Close() + } + }() + + // Ensure has agent key + err = d.ensureKey() + if err != nil { + return nil, err + } + + return crypto.NewReaderWithDefaults(fd, d.key) +} diff --git a/internal/pkg/agent/storage/storage.go b/internal/pkg/agent/storage/storage.go index 1945f10c3ef..dabb38f25d1 100644 --- a/internal/pkg/agent/storage/storage.go +++ b/internal/pkg/agent/storage/storage.go @@ -17,7 +17,23 @@ type Store interface { Save(io.Reader) error } +type Storage interface { + Store + + // Load return an io.ReadCloser for the target store. + Load() (io.ReadCloser, error) + + // Exists checks if the store exists. + Exists() (bool, error) +} + // DiskStore takes a persistedConfig and save it to a temporary files and replace the target file. type DiskStore struct { target string } + +type EncryptedDiskStore struct { + target string + vaultPath string + key []byte +} diff --git a/internal/pkg/agent/storage/store/state_store.go b/internal/pkg/agent/storage/store/state_store.go index c74d8aca486..fb15bfc9400 100644 --- a/internal/pkg/agent/storage/store/state_store.go +++ b/internal/pkg/agent/storage/store/state_store.go @@ -81,7 +81,7 @@ func NewStateStoreWithMigration(log *logger.Logger, actionStorePath, stateStoreP return nil, err } - return NewStateStore(log, storage.NewDiskStore(stateStorePath)) + return NewStateStore(log, storage.NewEncryptedDiskStore(stateStorePath)) } // NewStateStoreActionAcker creates a new state store backed action acker. @@ -95,6 +95,7 @@ func NewStateStore(log *logger.Logger, store storeLoad) (*StateStore, error) { // persisted and we return an empty store. reader, err := store.Load() if err != nil { + //nolint:nilerr // wad return &StateStore{log: log, store: store}, nil } defer reader.Close() @@ -144,7 +145,7 @@ func NewStateStore(log *logger.Logger, store storeLoad) (*StateStore, error) { func migrateStateStore(log *logger.Logger, actionStorePath, stateStorePath string) (err error) { log = log.Named("state_migration") actionDiskStore := storage.NewDiskStore(actionStorePath) - stateDiskStore := storage.NewDiskStore(stateStorePath) + stateDiskStore := storage.NewEncryptedDiskStore(stateStorePath) stateStoreExits, err := stateDiskStore.Exists() if err != nil { diff --git a/internal/pkg/agent/vault/aesgcm.go b/internal/pkg/agent/vault/aesgcm.go new file mode 100644 index 00000000000..41ea7131b34 --- /dev/null +++ b/internal/pkg/agent/vault/aesgcm.go @@ -0,0 +1,122 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package vault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "syscall" +) + +type AESKeyType int + +const ( + AES128 AESKeyType = 16 + AES192 AESKeyType = 24 + AES256 AESKeyType = 32 +) + +func (kt AESKeyType) String() string { + switch kt { + case AES128: + return "AES128" + case AES192: + return "AES192" + case AES256: + return "AES256" + } + return "" +} + +// NewKey generates new AES key as bytes +func NewKey(kt AESKeyType) ([]byte, error) { + key := make([]byte, kt) + if _, err := rand.Read(key); err != nil { + return nil, err + } + return key, nil +} + +// NewKeyHexString generates new AES key as hex encoded string +func NewKeyHexString(kt AESKeyType) (string, error) { + key, err := NewKey(kt) + if err != nil { + return "", err + } + return hex.EncodeToString(key), nil +} + +// Encrypt encrypts the data with AES-GCM +func Encrypt(key, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + + // The first parameter is nonce in order to get the ciphertext as concatenation of nonce and encrypted data + ciphertext := aesGCM.Seal(nonce, nonce, data, nil) + + return ciphertext, nil +} + +// EncryptHex encrypts with hex string key, producing hex encoded result +func EncryptHex(key string, data []byte) (string, error) { + bkey, err := hex.DecodeString(key) + if err != nil { + return "", err + } + enc, err := Encrypt(bkey, data) + if err != nil { + return "", err + } + return hex.EncodeToString(enc), nil +} + +// Decrypts decrypts the data with AES-GCM +func Decrypt(key, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return nil, syscall.EINVAL + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + + return aesGCM.Open(nil, nonce, ciphertext, nil) +} + +// DecryptHex decrypts with hex string key and data +func DecryptHex(key string, data string) ([]byte, error) { + bkey, err := hex.DecodeString(key) + if err != nil { + return nil, err + } + + bdata, err := hex.DecodeString(data) + if err != nil { + return nil, err + } + return Decrypt(bkey, bdata) +} diff --git a/internal/pkg/agent/vault/aesgcm_test.go b/internal/pkg/agent/vault/aesgcm_test.go new file mode 100644 index 00000000000..0c17ad4374f --- /dev/null +++ b/internal/pkg/agent/vault/aesgcm_test.go @@ -0,0 +1,198 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package vault + +import ( + "crypto/aes" + "crypto/rand" + "encoding/hex" + "errors" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var testKeyTypes []AESKeyType = []AESKeyType{AES128, AES192, AES256} + +func TestNewKey(t *testing.T) { + for _, kt := range testKeyTypes { + b, err := NewKey(kt) + if err != nil { + t.Error(err) + } + + diff := cmp.Diff(int(kt), len(b)) + if diff != "" { + t.Error(diff) + } + } +} + +func TestNewKeyHexString(t *testing.T) { + for _, kt := range testKeyTypes { + s, err := NewKeyHexString(kt) + if err != nil { + t.Error(err) + } + + diff := cmp.Diff(int(kt)*2, len(s)) + if diff != "" { + t.Error(diff) + } + } + +} + +func TestEncryptDecrypt(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + { + name: "nil", + }, + { + name: "empty", + data: []byte{}, + }, + { + name: "foobar", + data: []byte("foobar"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, kt := range testKeyTypes { + t.Run(kt.String(), func(t *testing.T) { + key, err := NewKey(kt) + if err != nil { + t.Error(err) + } + enc, err := Encrypt(key, tc.data) + if err != nil { + t.Error(err) + } + dec, err := Decrypt(key, enc) + if err != nil { + t.Error(err) + } + + if len(tc.data) == 0 { + diff := cmp.Diff(len(tc.data), len(dec)) + if diff != "" { + t.Error(diff) + } + } else { + diff := cmp.Diff(tc.data, dec) + if diff != "" { + t.Error(diff) + } + } + }) + } + }) + } + +} + +func TestEncryptDecryptDifferentLengths(t *testing.T) { + const maxDataSize = 55 // test for sufficient length for the key and a bit more + for _, kt := range testKeyTypes { + t.Run(kt.String(), func(t *testing.T) { + key, err := NewKey(kt) + if err != nil { + t.Error(err) + } + for i := 0; i < maxDataSize; i++ { + data := make([]byte, i) + _, err := rand.Read(data) + if err != nil { + t.Fatal(err) + } + name := strconv.Itoa(i) + t.Run(name, func(t *testing.T) { + enc, err := Encrypt(key, data) + if err != nil { + t.Error(err) + } + dec, err := Decrypt(key, enc) + if err != nil { + t.Error(err) + } + + if len(data) == 0 { + diff := cmp.Diff(len(data), len(dec)) + if diff != "" { + t.Error(diff) + } + } else { + diff := cmp.Diff(data, dec) + if diff != "" { + t.Error(diff) + } + } + }) + } + }) + } +} + +func TestEncryptDecryptHex(t *testing.T) { + aes256Key, err := NewKeyHexString(AES256) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + key string + data []byte + err error + }{ + { + name: "emptykey", + key: "", + err: aes.KeySizeError(0), + }, + { + name: "nonhexkey", + key: "123", + err: hex.ErrLength, + }, + { + name: "foobar", + key: aes256Key, + data: []byte("foobar"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + enc, err := EncryptHex(tc.key, tc.data) + if !errors.Is(tc.err, err) { + t.Fatalf(cmp.Diff(tc.err, err)) + } + + dec, err := DecryptHex(tc.key, enc) + if !errors.Is(tc.err, err) { + t.Fatalf(cmp.Diff(tc.err, err)) + } + + if len(tc.data) == 0 { + diff := cmp.Diff(len(tc.data), len(dec)) + if diff != "" { + t.Error(diff) + } + } else { + diff := cmp.Diff(tc.data, dec) + if diff != "" { + t.Error(diff) + } + } + }) + } + +} diff --git a/internal/pkg/agent/vault/seed.go b/internal/pkg/agent/vault/seed.go new file mode 100644 index 00000000000..99257540025 --- /dev/null +++ b/internal/pkg/agent/vault/seed.go @@ -0,0 +1,93 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || windows +// +build linux windows + +package vault + +import ( + "errors" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "runtime" + + "github.com/elastic/elastic-agent-libs/file" +) + +const seedFile = ".seed" + +var ErrNonRootFileOwner = errors.New("non-root file owner") + +var skipRootCheck bool + +// DisableRootCheck is a hook to disable root owner check for .seed file +// This is needed for linux unit tests +func DisableRootCheck() { + skipRootCheck = true +} + +func isFileOwnerRoot(path string) (isOwnerRoot bool, err error) { + if skipRootCheck || runtime.GOOS != "linux" { + return true, nil + } + info, err := os.Stat(path) + if err != nil { + return false, err + } + + stat, err := file.Wrap(info) + if err != nil { + return false, err + } + + uid, _ := stat.UID() + gid, _ := stat.GID() + if uid == 0 && gid == 0 { + return true, nil + } + + return false, nil +} + +func getSeed(path string) ([]byte, error) { + fp := filepath.Join(path, seedFile) + + isOwnerRoot, err := isFileOwnerRoot(fp) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + isOwnerRoot = true + } + + if !isOwnerRoot { + return nil, ErrNonRootFileOwner + } + + b, err := ioutil.ReadFile(fp) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + + if len(b) != 0 { + return b, nil + } + + seed, err := NewKey(AES256) + if err != nil { + return nil, err + } + + err = ioutil.WriteFile(fp, seed, 0600) + if err != nil { + return nil, err + } + + return seed, nil +} diff --git a/internal/pkg/agent/vault/seed_darwin.go b/internal/pkg/agent/vault/seed_darwin.go new file mode 100644 index 00000000000..8c24a40ccd0 --- /dev/null +++ b/internal/pkg/agent/vault/seed_darwin.go @@ -0,0 +1,13 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build darwin +// +build darwin + +package vault + +// DisableRootCheck noop on darwin to allow to compile the common code +// Noop on darwin +func DisableRootCheck() { +} diff --git a/internal/pkg/agent/vault/seed_test.go b/internal/pkg/agent/vault/seed_test.go new file mode 100644 index 00000000000..1e0672f8985 --- /dev/null +++ b/internal/pkg/agent/vault/seed_test.go @@ -0,0 +1,36 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || windows +// +build linux windows + +package vault + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" +) + +func TestGetSeed(t *testing.T) { + DisableRootCheck() + + dir := t.TempDir() + + fp := filepath.Join(dir, seedFile) + + assert.NoFileExists(t, fp) + + b, err := getSeed(dir) + assert.NoError(t, err) + + assert.FileExists(t, fp) + + diff := cmp.Diff(int(AES256), len(b)) + if diff != "" { + t.Error(diff) + } +} diff --git a/internal/pkg/agent/vault/vault_darwin.c b/internal/pkg/agent/vault/vault_darwin.c new file mode 100644 index 00000000000..c2bb85bb354 --- /dev/null +++ b/internal/pkg/agent/vault/vault_darwin.c @@ -0,0 +1,218 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +// CreateAccessWithUid is based on a modification of MyMakeUidAccess +// at https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1310.80.1/mDNSMacOSX/PreferencePane/BonjourPrefTool/BonjourPrefTool.m +// which is licensed under Apache 2.0 + +#include +#include + +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + +OSStatus CreateAccessWithUid(uid_t uid, SecAccessRef * ret_access) { + // make the "uid/gid" ACL subject + // this is a CSSM_LIST_ELEMENT chain + CSSM_ACL_PROCESS_SUBJECT_SELECTOR selector = { + CSSM_ACL_PROCESS_SELECTOR_CURRENT_VERSION, // selector version + CSSM_ACL_MATCH_UID, // set mask: match uids (only) + uid, // uid to match + 0 // gid (not matched here) + }; + CSSM_LIST_ELEMENT subject2 = { NULL, 0, 0, {{0,0,0}} }; + subject2.Element.Word.Data = (UInt8 *)&selector; + subject2.Element.Word.Length = sizeof(selector); + CSSM_LIST_ELEMENT subject1 = { &subject2, CSSM_ACL_SUBJECT_TYPE_PROCESS, CSSM_LIST_ELEMENT_WORDID, {{0,0,0}} }; + + + // rights granted (replace with individual list if desired) + CSSM_ACL_AUTHORIZATION_TAG rights[] = { + CSSM_ACL_AUTHORIZATION_ANY // everything + }; + // owner component (right to change ACL) + CSSM_ACL_OWNER_PROTOTYPE owner = { + // TypedSubject + { CSSM_LIST_TYPE_UNKNOWN, &subject1, &subject2 }, + // Delegate + false + }; + // ACL entries (any number, just one here) + CSSM_ACL_ENTRY_INFO acls = + { + // CSSM_ACL_ENTRY_PROTOTYPE + { + { CSSM_LIST_TYPE_UNKNOWN, &subject1, &subject2 }, // TypedSubject + false, // Delegate + { sizeof(rights) / sizeof(rights[0]), rights }, // Authorization rights for this entry + { { 0, 0 }, { 0, 0 } }, // CSSM_ACL_VALIDITY_PERIOD + "" // CSSM_STRING EntryTag + }, + // CSSM_ACL_HANDLE + 0 + }; + + return SecAccessCreateFromOwnerAndACL(&owner, 1, &acls, ret_access); +} + +#pragma clang diagnostic pop + +OSStatus OpenKeychain(SecKeychainRef keychain) { + OSStatus status = SecKeychainSetPreferenceDomain(kSecPreferencesDomainSystem); + if (status == noErr) { + status = SecKeychainCopyDomainDefault(kSecPreferencesDomainSystem, &keychain); + } + return status; +} + +OSStatus UpdateKeychainItem(SecKeychainRef keychain, const char *name, const char *key, const void *data, size_t len) { + void* pwd = NULL; + UInt32 pwd_len = 0; + SecKeychainItemRef item = NULL; + + OSStatus status = SecKeychainFindGenericPassword(keychain, + (UInt32)strlen(key), key, + (UInt32)strlen(name), name, + &pwd_len, &pwd, + &item); + + if (status == noErr) { // item is found, update the value + if ((len != pwd_len) || (bcmp(data, pwd, pwd_len) != 0)) { + status = SecKeychainItemModifyAttributesAndData(item, NULL, len, data); + } + } + + if (pwd != NULL) { + SecKeychainItemFreeContent(NULL, pwd); + pwd = NULL; + } + return status; +} + +OSStatus SetKeychainItem(SecKeychainRef keychain, const char *name, const char *key, const void *data, size_t len) { + SecKeychainItemRef item = NULL; + + OSStatus status = UpdateKeychainItem(keychain, name, key, data, len); + + if (status == errSecItemNotFound) { + SecAccessRef access = NULL; + + status = CreateAccessWithUid(0, &access); // 0 for root uid + if (status == noErr) { + size_t sz = strlen(name); + SecKeychainAttribute attrs[] = { + { kSecLabelItemAttr, (UInt32)sz, (char*)name }, + { kSecAccountItemAttr, (UInt32)sz, (char*)name }, + { kSecServiceItemAttr, (UInt32)strlen(key), (char*)key } + }; + SecKeychainAttributeList attributes = { sizeof(attrs) / sizeof(attrs[0]), + attrs }; + + status = SecKeychainItemCreateFromContent( + kSecGenericPasswordItemClass, + &attributes, + (UInt32)len, + data, + keychain, + access, + &item); + if (status == errSecDuplicateItem) { + status = UpdateKeychainItem(keychain, name, key, data, len); + } + } + + if (access != NULL) { + CFRelease(access); + } + } + + if (item != NULL) { + CFRelease(item); + item = NULL; + } + + return status; +} + +OSStatus GetKeychainItem(SecKeychainRef keychain, const char *name, const char *key, void **data, size_t *len) { + void* pwd = NULL; + UInt32 pwd_len = 0; + SecKeychainItemRef item = NULL; + + OSStatus status = SecKeychainFindGenericPassword(keychain, + (UInt32)strlen(key), key, + (UInt32)strlen(name), name, + &pwd_len, &pwd, + &item); + + if (status == noErr) { + *data = malloc(pwd_len); + memcpy(*data, pwd, pwd_len); + *len = pwd_len; + } + + if (pwd != NULL) { + SecKeychainItemFreeContent(NULL, pwd); + pwd = NULL; + } + + if (item != NULL) { + CFRelease(item); + item = NULL; + } + + return status; +} + +OSStatus ExistsKeychainItem(SecKeychainRef keychain, const char *name, const char *key) { + SecKeychainItemRef item = NULL; + + OSStatus status = SecKeychainFindGenericPassword(keychain, + (UInt32)strlen(key), key, + (UInt32)strlen(name), name, + NULL, NULL, + &item); + + if (item != NULL) { + CFRelease(item); + item = NULL; + } + + return status; +} + +OSStatus RemoveKeychainItem(SecKeychainRef keychain, const char *name, const char *key) { + SecKeychainItemRef item = NULL; + + OSStatus status = SecKeychainFindGenericPassword(keychain, + (UInt32)strlen(key), key, + (UInt32)strlen(name), name, + NULL, NULL, + &item); + + if (status == noErr) { + status = SecKeychainItemDelete(item); + } + + if (item != NULL) { + CFRelease(item); + item = NULL; + } + + return status; +} + +char* GetOSStatusMessage(OSStatus status) { + CFStringRef s = SecCopyErrorMessageString(status, NULL); + char *p; + int n; + n = CFStringGetLength(s)*8; + p = malloc(n); + CFStringGetCString(s, p, n, kCFStringEncodingUTF8); + CFRelease(s); + return p; +} diff --git a/internal/pkg/agent/vault/vault_darwin.go b/internal/pkg/agent/vault/vault_darwin.go new file mode 100644 index 00000000000..097f090a7d3 --- /dev/null +++ b/internal/pkg/agent/vault/vault_darwin.go @@ -0,0 +1,146 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build darwin +// +build darwin + +package vault + +/* +#include + +#include + +#cgo LDFLAGS: -framework Foundation -framework Security + +extern OSStatus OpenKeychain(SecKeychainRef keychain); +extern OSStatus SetKeychainItem(SecKeychainRef keychain, const char *name, const char *key, const void *data, size_t len); +extern OSStatus GetKeychainItem(SecKeychainRef keychain, const char *name, const char *key, void **data, size_t *len); +extern OSStatus ExistsKeychainItem(SecKeychainRef keychain, const char *name, const char *key); +extern OSStatus RemoveKeychainItem(SecKeychainRef keychain, const char *name, const char *key); +extern char* GetOSStatusMessage(OSStatus status); + +*/ +import "C" +import ( + "fmt" + "unsafe" +) + +type Vault struct { + name string + keychain C.SecKeychainRef +} + +// New initializes the vault store +// Call Close when done to release the resouces +func New(name string) (*Vault, error) { + var keychain C.SecKeychainRef + err := statusToError(C.OpenKeychain(keychain)) + if err != nil { + return nil, err + } + return &Vault{ + name: name, + keychain: keychain, + }, nil +} + +// Close closes the vault store +func (v *Vault) Close() error { + if v.keychain != 0 { + C.CFRelease(C.CFTypeRef(v.keychain)) + v.keychain = 0 + } + return nil +} + +// Set sets the key in the vault store +func (v *Vault) Set(key string, data []byte) error { + cname := C.CString(v.name) + defer C.free(unsafe.Pointer(cname)) + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + + cdata := C.CBytes(data) + defer C.free(cdata) + + return statusToError(C.SetKeychainItem(v.keychain, cname, ckey, cdata, C.size_t(len(data)))) +} + +// Get retrieves the key from the vault store +func (v *Vault) Get(key string) ([]byte, error) { + var ( + data unsafe.Pointer + len C.size_t + ) + + cname := C.CString(v.name) + defer C.free(unsafe.Pointer(cname)) + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + + err := statusToError(C.GetKeychainItem(v.keychain, cname, ckey, &data, &len)) + if err != nil { + return nil, err + } + b := C.GoBytes(data, C.int(len)) + C.free(data) + return b, nil +} + +// Exists checks if the key exists +func (v *Vault) Exists(key string) (bool, error) { + + cname := C.CString(v.name) + defer C.free(unsafe.Pointer(cname)) + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + + status := C.ExistsKeychainItem(v.keychain, cname, ckey) + if status == C.noErr { + return true, nil + } + + if status == C.errSecItemNotFound { + return false, nil + } + return false, statusToError(status) +} + +func (v *Vault) Remove(key string) error { + cname := C.CString(v.name) + defer C.free(unsafe.Pointer(cname)) + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + + return statusToError(C.RemoveKeychainItem(v.keychain, cname, ckey)) +} + +// statusToError converts OSStatus into Go error +func statusToError(status C.OSStatus) error { + if status != C.noErr { + cmsg := C.GetOSStatusMessage(status) + msg := C.GoString(cmsg) + C.free(unsafe.Pointer(cmsg)) + return &OSStatusError{ + status: int(status), + message: msg, + } + } + return nil +} + +type OSStatusError struct { + status int + message string +} + +func (o *OSStatusError) Error() string { + return fmt.Sprintf("%d: %s", o.status, o.message) +} diff --git a/internal/pkg/agent/vault/vault_linux.go b/internal/pkg/agent/vault/vault_linux.go new file mode 100644 index 00000000000..c47db6d8b87 --- /dev/null +++ b/internal/pkg/agent/vault/vault_linux.go @@ -0,0 +1,143 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux +// +build linux + +package vault + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "syscall" + + "golang.org/x/crypto/pbkdf2" +) + +const saltSize = 8 + +type Vault struct { + path string + key []byte +} + +// Open initializes the vault store +func New(path string) (*Vault, error) { + dir := filepath.Dir(path) + + // If there is no specific path then get the executable directory + if dir == "." { + exefp, err := os.Executable() + if err != nil { + return nil, err + } + dir = filepath.Dir(exefp) + path = filepath.Join(dir, path) + } + + err := os.MkdirAll(path, 0750) + if err != nil { + return nil, err + } + + key, err := getSeed(path) + if err != nil { + return nil, err + } + + return &Vault{ + path: path, + key: key, + }, nil +} + +// Close closes the valut store +// Noop on linux +func (v *Vault) Close() error { + return nil +} + +// Set stores the key in the vault store +func (v *Vault) Set(key string, data []byte) error { + enc, err := v.encrypt(data) + if err != nil { + return err + } + + return ioutil.WriteFile(v.filepathFromKey(key), enc, 0600) +} + +// Get retrieves the key from the vault store +func (v *Vault) Get(key string) ([]byte, error) { + enc, err := ioutil.ReadFile(v.filepathFromKey(key)) + if err != nil { + return nil, err + } + + return v.decrypt(enc) +} + +// Exists checks if the key exists +func (v *Vault) Exists(key string) (ok bool, err error) { + if _, err = os.Stat(v.filepathFromKey(key)); err == nil { + ok = true + } else if errors.Is(err, fs.ErrNotExist) { + err = nil + } + return ok, err +} + +// Remove removes the key +func (v *Vault) Remove(key string) error { + return os.RemoveAll(v.filepathFromKey(key)) +} + +func (v *Vault) encrypt(data []byte) ([]byte, error) { + key, salt, err := deriveKey(v.key, nil) + if err != nil { + return nil, err + } + enc, err := Encrypt(key, data) + if err != nil { + return nil, err + } + return append(salt, enc...), nil +} + +func (v *Vault) decrypt(data []byte) ([]byte, error) { + if len(data) < saltSize { + return nil, syscall.EINVAL + } + salt, data := data[:saltSize], data[saltSize:] + key, _, err := deriveKey(v.key, salt) + if err != nil { + return nil, err + } + return Decrypt(key, data) +} + +func deriveKey(pw []byte, salt []byte) ([]byte, []byte, error) { + if salt == nil { + salt = make([]byte, saltSize) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + } + return pbkdf2.Key(pw, salt, 12022, 32, sha256.New), salt, nil +} + +func (v *Vault) filepathFromKey(key string) string { + return filepath.Join(v.path, fileNameFromKey(key)) +} + +func fileNameFromKey(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/pkg/agent/vault/vault_test.go b/internal/pkg/agent/vault/vault_test.go new file mode 100644 index 00000000000..2586ecce36e --- /dev/null +++ b/internal/pkg/agent/vault/vault_test.go @@ -0,0 +1,120 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || windows +// +build linux windows + +package vault + +import ( + "path/filepath" + + "testing" + + "github.com/google/go-cmp/cmp" +) + +func getTestVaultPath(t *testing.T) string { + dir := t.TempDir() + return filepath.Join(dir, "vault") +} + +func TestVault(t *testing.T) { + + // Disable root check, because the tests are not running as sudo + DisableRootCheck() + + vaultPath := getTestVaultPath(t) + + v, err := New(vaultPath) + if err != nil { + t.Fatal(err) + } + + defer v.Close() + + const ( + key1 = "key1" + key2 = "key2" + key3 = "key3" + + val1 = "value1" + val2 = "value22" + val3 = "value3" + ) + + keys := []string{key1, key2, key3} + vals := []string{val1, val2, val3} + + // Test that keys do not exists + for _, key := range keys { + exists, err := v.Exists(key) + if err != nil { + t.Fatal(err) + } + diff := cmp.Diff(exists, false) + if diff != "" { + t.Fatal(diff) + } + } + + // Create keys, except the last one + for i := 0; i < len(keys)-1; i++ { + err := v.Set(keys[i], []byte(vals[i])) + if err != nil { + t.Fatal(err) + } + } + + // Verify the keys that were created now exist + for i := 0; i < len(keys)-1; i++ { + exists, err := v.Exists(keys[i]) + if err != nil { + t.Fatal(err) + } + diff := cmp.Diff(exists, true) + if diff != "" { + t.Fatal(diff) + } + } + + // Verify the keys values + for i := 0; i < len(keys)-1; i++ { + b, err := v.Get(keys[i]) + if err != nil { + t.Fatal(err) + } + diff := cmp.Diff(b, []byte(vals[i])) + if diff != "" { + t.Fatal(diff) + } + } + + // Verify that the last key that was not creates still doesn't exists + exists, err := v.Exists(keys[len(keys)-1]) + if err != nil { + t.Fatal(err) + } + diff := cmp.Diff(exists, false) + if diff != "" { + t.Fatal(diff) + } + + // Delete the first key + err = v.Remove(keys[0]) + if err != nil { + t.Fatal(err) + } + + // Verify that just deleted key doesn't exist anymore + exists, err = v.Exists(keys[0]) + if err != nil { + t.Fatal(err) + } + + diff = cmp.Diff(exists, false) + if diff != "" { + t.Fatal(diff) + } +} diff --git a/internal/pkg/agent/vault/vault_windows.go b/internal/pkg/agent/vault/vault_windows.go new file mode 100644 index 00000000000..c56bd7eb098 --- /dev/null +++ b/internal/pkg/agent/vault/vault_windows.go @@ -0,0 +1,129 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build windows +// +build windows + +package vault + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + + "github.com/billgraziano/dpapi" + "github.com/hectane/go-acl" + "golang.org/x/sys/windows" +) + +type Vault struct { + path string + entropy []byte +} + +// Open initializes the vault store +func New(path string) (*Vault, error) { + dir := filepath.Dir(path) + + // If there is no specific path then get the executable directory + if dir == "." { + exefp, err := os.Executable() + if err != nil { + return nil, err + } + dir = filepath.Dir(exefp) + path = filepath.Join(dir, path) + } + + err := os.MkdirAll(path, 0750) + if err != nil { + return nil, err + } + err = systemAdministratorsOnly(path, false) + if err != nil { + return nil, err + } + + entropy, err := getSeed(path) + if err != nil { + return nil, err + } + + return &Vault{ + path: path, + entropy: entropy, + }, nil +} + +// Close closes the valut store +// Noop on windows +func (v *Vault) Close() error { + return nil +} + +// Set stores the key in the vault store +func (v *Vault) Set(key string, data []byte) error { + enc, err := dpapi.EncryptBytesMachineLocalEntropy(data, v.entropy) + if err != nil { + return err + } + + return ioutil.WriteFile(v.filepathFromKey(key), enc, 0600) +} + +// Get retrieves the key from the vault store +func (v *Vault) Get(key string) ([]byte, error) { + enc, err := ioutil.ReadFile(v.filepathFromKey(key)) + if err != nil { + return nil, err + } + + return dpapi.DecryptBytesEntropy(enc, v.entropy) +} + +// Exists checks if the key exists +func (v *Vault) Exists(key string) (ok bool, err error) { + if _, err = os.Stat(v.filepathFromKey(key)); err == nil { + ok = true + } else if errors.Is(err, fs.ErrNotExist) { + err = nil + } + return ok, err +} + +// Remove removes the key +func (v *Vault) Remove(key string) error { + return os.RemoveAll(v.filepathFromKey(key)) +} + +func (v *Vault) filepathFromKey(key string) string { + return filepath.Join(v.path, fileNameFromKey(key)) +} + +func fileNameFromKey(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +} + +func systemAdministratorsOnly(path string, inherit bool) error { + // https://support.microsoft.com/en-us/help/243330/well-known-security-identifiers-in-windows-operating-systems + systemSID, err := windows.StringToSid("S-1-5-18") + if err != nil { + return err + } + administratorsSID, err := windows.StringToSid("S-1-5-32-544") + if err != nil { + return err + } + + // https://docs.microsoft.com/en-us/windows/win32/secauthz/access-mask + return acl.Apply( + path, true, inherit, + acl.GrantSid(0xF10F0000, systemSID), // full control of all acl's + acl.GrantSid(0xF10F0000, administratorsSID)) +} diff --git a/internal/pkg/composable/providers/agent/agent_test.go b/internal/pkg/composable/providers/agent/agent_test.go index 66d00311226..f3c6904b05c 100644 --- a/internal/pkg/composable/providers/agent/agent_test.go +++ b/internal/pkg/composable/providers/agent/agent_test.go @@ -13,9 +13,12 @@ import ( "github.com/elastic/elastic-agent/internal/pkg/composable" ctesting "github.com/elastic/elastic-agent/internal/pkg/composable/testing" + "github.com/elastic/elastic-agent/internal/pkg/testutils" ) func TestContextProvider(t *testing.T) { + testutils.InitStorage(t) + builder, _ := composable.Providers.GetContextProvider("agent") provider, err := builder(nil, nil) require.NoError(t, err) diff --git a/internal/pkg/config/operations/inspector.go b/internal/pkg/config/operations/inspector.go index e7a7f9e63ce..05ab040d92b 100644 --- a/internal/pkg/config/operations/inspector.go +++ b/internal/pkg/config/operations/inspector.go @@ -64,7 +64,7 @@ func loadConfig(configPath string) (*config.Config, error) { path := paths.AgentConfigFile() - store := storage.NewDiskStore(path) + store := storage.NewEncryptedDiskStore(path) reader, err := store.Load() if err != nil { return nil, errors.New(err, "could not initialize config store", diff --git a/internal/pkg/testutils/testutils.go b/internal/pkg/testutils/testutils.go new file mode 100644 index 00000000000..222fa49c902 --- /dev/null +++ b/internal/pkg/testutils/testutils.go @@ -0,0 +1,25 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package testutils + +import ( + "runtime" + "testing" + + "github.com/elastic/elastic-agent/internal/pkg/agent/application/secret" + "github.com/elastic/elastic-agent/internal/pkg/agent/storage" + "github.com/elastic/elastic-agent/internal/pkg/agent/vault" +) + +func InitStorage(t *testing.T) { + vault.DisableRootCheck() + storage.DisableEncryptionDarwin() + if runtime.GOOS != "darwin" { + err := secret.CreateAgentSecret() + if err != nil { + t.Fatal(err) + } + } +}