diff --git a/.sanity-ansible-ignore-2.23.txt b/.sanity-ansible-ignore-2.23.txt new file mode 100644 index 00000000..fae055c2 --- /dev/null +++ b/.sanity-ansible-ignore-2.23.txt @@ -0,0 +1 @@ +plugins/modules/sr_fingerprint.py validate-modules:missing-gplv3-license diff --git a/README.md b/README.md index a029fe65..9477dc0a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Type: `int` Use this variable to protect boot parameters with a password. **WARNING**: Changing the bootloader password is not idempotent. +Use `bootloader_password_hash` for idempotent password configuration. +These two inputs cannot be used together. The bootloader username is always `root`. @@ -124,6 +126,33 @@ Default: `null` Type: `string` +### bootloader_password_hash + +Use this variable to set a precomputed GRUB PBKDF2 SHA512 password hash +for the bootloader user `root`. Generate the hash with +`grub2-mkpasswd-pbkdf2` and store it in Ansible Vault. +Supply only the resulting `grub.pbkdf2.sha512...` hash, without the command's +explanatory text or a trailing newline. The hash must have a positive iteration +count, a nonempty hexadecimal salt consisting of whole bytes, and a 64-byte +hexadecimal digest. + +Setting the same hash repeatedly is idempotent. If unset or `null`, no hash +is written. An empty string is invalid; use `bootloader_remove_password: true` +with this variable unset or `null` to remove a password. + +Do not combine this variable with a non-null `bootloader_password` or with +`bootloader_remove_password: true`. + +For example, where `vault_bootloader_password_hash` contains the generated hash: + +```yaml +bootloader_password_hash: "{{ vault_bootloader_password_hash }}" +``` + +Default: `null` + +Type: `string` + ### bootloader_remove_password Set this variable to `true` to remove the bootloader password. diff --git a/defaults/main.yml b/defaults/main.yml index 12b20de1..448c9464 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -3,6 +3,7 @@ bootloader_settings: [] bootloader_timeout: null bootloader_password: null +bootloader_password_hash: null bootloader_remove_password: false bootloader_reboot_ok: false diff --git a/meta/argument_specs.yml b/meta/argument_specs.yml index 62278f0f..7b45eb47 100644 --- a/meta/argument_specs.yml +++ b/meta/argument_specs.yml @@ -104,6 +104,16 @@ argument_specs: username is always `root`. This value should come from an Ansible vault. + bootloader_password_hash: + type: raw + default: null + description: > + Precomputed GRUB PBKDF2 SHA512 password hash for the root + boot loader user. When null or unset, no hash is written. + Setting the same hash repeatedly is idempotent. Store the hash + in Ansible Vault. Cannot be combined with bootloader_password + or bootloader_remove_password=true. + bootloader_remove_password: type: bool default: false diff --git a/pytest_extra_requirements.txt b/pytest_extra_requirements.txt index a0fb201b..0e023b90 100644 --- a/pytest_extra_requirements.txt +++ b/pytest_extra_requirements.txt @@ -2,3 +2,4 @@ # ansible and dependencies for all supported platforms ansible-core ; python_version > "2.6" +mock ; python_version < "3.0" diff --git a/tasks/assert_role_vars.yml b/tasks/assert_role_vars.yml index a982d1e6..becc5c05 100644 --- a/tasks/assert_role_vars.yml +++ b/tasks/assert_role_vars.yml @@ -20,6 +20,32 @@ bootloader_password must be null or a string, got {{ bootloader_password | type_debug }} +- name: Assert bootloader_password_hash is null or a GRUB PBKDF2 SHA512 hash + ansible.builtin.assert: + that: + - >- + (bootloader_password_hash is none) + or (bootloader_password_hash is string + and bootloader_password_hash is + match('^grub[.]pbkdf2[.]sha512[.][1-9][0-9]*[.]' + ~ '([0-9A-Fa-f]{2})+[.][0-9A-Fa-f]{128}\Z')) + fail_msg: >- + bootloader_password_hash must be null or a GRUB PBKDF2 SHA512 hash + with a positive iteration count, hexadecimal salt, and 64-byte digest + no_log: "{{ bootloader_secure_logging }}" + +- name: Assert bootloader_password_hash does not conflict with password settings + ansible.builtin.assert: + that: + - >- + bootloader_password_hash is none + or (bootloader_password is none + and not bootloader_remove_password | bool) + fail_msg: >- + bootloader_password_hash cannot be combined with bootloader_password + or bootloader_remove_password=true + no_log: "{{ bootloader_secure_logging }}" + - name: Assert kernel in bootloader_settings is defined and valid type ansible.builtin.assert: that: diff --git a/tasks/main.yml b/tasks/main.yml index d157621e..f14f5243 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -92,15 +92,16 @@ - name: Use a general grub conf path if UEFI path has a stub config when: __bootloader_grub_conf_stat.stat.exists | bool block: - - name: Verify if there is a stab config in {{ __bootloader_grub_conf }} + - name: Verify if there is a stub config in {{ __bootloader_grub_conf }} shell: grep "configfile" {{ __bootloader_grub_conf }} || true changed_when: false register: __bootloader_grep_configfile ignore_errors: true + check_mode: false - name: Use a general grub and user conf path if UEFI path has a stub config when: - - __bootloader_grep_configfile.stdout | length > 0 + - __bootloader_grep_configfile.stdout | d("") | length > 0 - __bootloader_grub_conf == __bootloader_uefi_conf_dir ~ 'grub.cfg' set_fact: __bootloader_grub_conf: /boot/grub2/grub.cfg @@ -162,6 +163,14 @@ changed_when: true no_log: "{{ bootloader_secure_logging }}" +- name: Install precomputed boot loader password hash + ansible.builtin.copy: + content: "GRUB2_PASSWORD={{ bootloader_password_hash }}" + dest: "{{ __bootloader_user_conf }}" + mode: "{{ __bootloader_conf_mode }}" + when: bootloader_password_hash is not none + no_log: "{{ bootloader_secure_logging }}" + - name: Remove boot loader password configuration file: path: "{{ __bootloader_user_conf }}" diff --git a/tests/tasks/check_invalid_password_hash.yml b/tests/tasks/check_invalid_password_hash.yml new file mode 100644 index 00000000..b6ad85fa --- /dev/null +++ b/tests/tasks/check_invalid_password_hash.yml @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MIT +--- +- name: Reset password hash validation result + ansible.builtin.set_fact: + __bootloader_test_hash_rejected: false + +- name: Try invalid password hash settings + block: + - name: Validate invalid password hash settings + ansible.builtin.include_tasks: run_role_with_clear_facts.yml + vars: + __sr_tasks_from: assert_role_vars.yml + bootloader_password_hash: "{{ __bootloader_test_invalid_hash.hash }}" + bootloader_password: >- + {{ __bootloader_test_invalid_hash.password | default(none) }} + bootloader_remove_password: >- + {{ __bootloader_test_invalid_hash.remove | default(false) }} + # Test-only values; expose the assertion message to verify the failure. + bootloader_secure_logging: false + rescue: + - name: Verify failure comes from password hash validation + ansible.builtin.assert: + that: + - "'bootloader_password_hash' in ansible_failed_result.msg" + + - name: Record expected validation failure + ansible.builtin.set_fact: + __bootloader_test_hash_rejected: true + +- name: Assert invalid password hash settings were rejected + ansible.builtin.assert: + that: __bootloader_test_hash_rejected diff --git a/tests/tests_invalid_input.yml b/tests/tests_invalid_input.yml index 29c50c1b..560f92cd 100644 --- a/tests/tests_invalid_input.yml +++ b/tests/tests_invalid_input.yml @@ -385,6 +385,28 @@ assert_role_vars should reject bootloader_password when given a boolean value + - name: Reject invalid or conflicting password hashes + ansible.builtin.include_tasks: tasks/check_invalid_password_hash.yml + loop: + - hash: "" + - hash: 123 + - hash: true + - hash: [] + - hash: {} + - hash: not-a-hash + - hash: "{{ 'grub.pbkdf2.sha512.0.AA.' ~ ('AB' * 64) }}" + - hash: "{{ 'grub.pbkdf2.sha512.10000.A.' ~ ('AB' * 64) }}" + - hash: "{{ 'grub.pbkdf2.sha512.10000.GG.' ~ ('AB' * 64) }}" + - hash: "{{ 'grub.pbkdf2.sha512.10000.AA.' ~ ('AB' * 63) }}" + - hash: "{{ 'grub.pbkdf2.sha512.10000.AA.' ~ ('AB' * 64) }}\n" + - hash: "{{ 'grub.pbkdf2.sha512.10000.AA.' ~ ('AB' * 64) }}" + password: test-pass + - hash: "{{ 'grub.pbkdf2.sha512.10000.AA.' ~ ('AB' * 64) }}" + remove: true + loop_control: + loop_var: __bootloader_test_invalid_hash + label: Password hash validation case + # --- Test: kernel as integer --- - name: Assert rejects kernel as integer block: @@ -441,6 +463,7 @@ always: - name: Clear test facts ansible.builtin.set_fact: + __bootloader_test_hash_rejected: __invalid_input_settings_type_failed: __invalid_input_missing_kernel_failed: __invalid_input_default_subopt_type_failed: diff --git a/tests/tests_password_hash.yml b/tests/tests_password_hash.yml new file mode 100644 index 00000000..8a9cbcfc --- /dev/null +++ b/tests/tests_password_hash.yml @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: MIT +# This code was generated with ChatGPT using model Astra Light 6. +--- +- name: Test precomputed bootloader password hashes + hosts: all + tasks: + - name: Skip on s390x architecture + ansible.builtin.include_tasks: tasks/skip_on_s390x.yml + + - name: Test hash configuration and clean up afterwards + block: + - name: Configure a precomputed password hash + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_password_hash: "{{ __bootloader_test_hash }}" + __sr_public: true + + - name: Read the configured password + ansible.builtin.slurp: + src: "{{ __bootloader_user_conf }}" + register: __bootloader_test_content + + - name: Verify the exact hash was installed + ansible.builtin.assert: + that: + - >- + __bootloader_test_content.content | b64decode == + 'GRUB2_PASSWORD=' ~ __bootloader_test_hash + + - name: Record password file state + ansible.builtin.stat: + path: "{{ __bootloader_user_conf }}" + register: __bootloader_test_before + + - name: Configure the same hash again + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_password_hash: "{{ __bootloader_test_hash }}" + + - name: Predict changing the hash in check mode + ansible.builtin.include_tasks: + file: tasks/run_role_with_clear_facts.yml + apply: + check_mode: true + diff: true + vars: + bootloader_password_hash: "{{ __bootloader_test_new_hash }}" + + - name: Verify password file is unchanged + ansible.builtin.stat: + path: "{{ __bootloader_user_conf }}" + register: __bootloader_test_after + + - name: Assert idempotency and check mode preserved the file + ansible.builtin.assert: + that: + - >- + __bootloader_test_before.stat.checksum == + __bootloader_test_after.stat.checksum + - >- + __bootloader_test_before.stat.mtime == + __bootloader_test_after.stat.mtime + - __bootloader_test_after.stat.mode == __bootloader_conf_mode + + - name: Change the password hash + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_password_hash: "{{ __bootloader_test_new_hash }}" + + - name: Leave the password unmanaged + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_password_hash: null + + - name: Read the changed password + ansible.builtin.slurp: + src: "{{ __bootloader_user_conf }}" + register: __bootloader_test_content + + - name: Verify the changed hash was preserved + ansible.builtin.assert: + that: + - >- + __bootloader_test_content.content | b64decode == + 'GRUB2_PASSWORD=' ~ __bootloader_test_new_hash + + - name: Remove the password + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_remove_password: true + + - name: Check password file was removed + ansible.builtin.stat: + path: "{{ __bootloader_user_conf }}" + register: __bootloader_test_removed + + - name: Assert password file was removed + ansible.builtin.assert: + that: not __bootloader_test_removed.stat.exists + always: + - name: Remove test password + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + vars: + bootloader_password_hash: null + bootloader_remove_password: true + tags: tests::cleanup diff --git a/tests/vars/vault-variables.yml b/tests/vars/vault-variables.yml new file mode 100644 index 00000000..ad53f1bb --- /dev/null +++ b/tests/vars/vault-variables.yml @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MIT +--- +# Test-only hashes; the test vault password is in tests/vault_pwd. +__bootloader_test_hash: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 36393032626465646465663830343861656263366136386433633836643362653034343133323535 + 3330663362346336346461386138346564353235306635660a363363613535643535306132613964 + 31653465613164663234343536623737326561653732623237313334356162646133333263643561 + 3630346265373535320a666562343564626463623262313732323337333431626465326138336462 + 31393231313438376132393264366634643564636632643561333230373365306465623264633938 + 32376538316663613765623762373062616134616631383866383730353634396366663038386235 + 37326530373762626162376234353635333235313639663234353164616134623562646535323035 + 35393662653966383265633363363461393030653839613763386333663865633634376562363332 + 38373463336162393066306465326262383830663933393637356265383239356433613861326637 + 39353633346566303564333139636663386663336432623063353034633738306236363630653335 + 64643435316437303938353438383566373032646264373132616530653133373438333565653530 + 61633031306565373065326161393135393633666462366637346232376564313733316137373435 + 37616233633836346662336537356436393132616432623131366230363334626665386132663739 + 35326564386238633737383832383865393737386662616565373036323865613234633234373330 + 62353836383538633762646539353563333636646237633761343139323130366233663633663461 + 36313839663264353062653534653636306362313031316434383866373063336539386532336638 + 61623935653964353833666635313765346439386233396463366330643239663735666563313365 + 6639316136343337346135326331653939323462386230313238 +__bootloader_test_new_hash: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 38343664623539623364396233306631313533396162333234616561396165636332663064346337 + 3239333537636431393431653436336537636363646433620a356166336431303835343861356139 + 65313264636238633566323339653538323862313364643835336664356263616262326138373831 + 3232623063633833320a626436386131363934383639373362356435343633656536346230643233 + 62313633306238343030666535323765656237333663383030663339333066386261363763393966 + 36663666313633363365353965343065653939323539336564623234333338333931323332323862 + 63363238393662373833323039323836303433663032333765393361646432383734366334613164 + 65303937393363343031653437353263333861383134323537346563346237633166303530386336 + 66316463313838376534303035616436313064353263393234383731656263323637396234643563 + 36653039393233396631666535373837373564326236353436303831383738353237353635313430 + 61643738386231666361616337306630373434393836373736383965316235383861323464303961 + 39623837633130666465636634633033323766373933663731653433353830663133616435663362 + 32316534356331333761663730316233653632353738353238366538373861393737326536633731 + 63613866646465633536346133326335613365636265393336383037326638363931303264383934 + 34353932613732373539613630636164636438343835326433366466303937626363613562633464 + 37373965623737336239353262393932326434393433376638653936623936623031623437306564 + 61336531353535666563623037306239323832613464663236363339303732336238653464646463 + 3337356239396133643961306266666130613837656335353366 diff --git a/tests/vault_pwd b/tests/vault_pwd new file mode 100644 index 00000000..f59e0e25 --- /dev/null +++ b/tests/vault_pwd @@ -0,0 +1 @@ +b910c3847ab3a6d7aa62e6995167c323a68c5001b01722c6caa472e6964106ae