Repository: ansible-collections/community.general

Issue:
Generate word-based passwords
_From @PeterNerlich on Jun 11, 2021 13:38_

### Summary

<details><summary>For the <i>Why</i> of word based passwords, allow me to refer to <a href="https://xkcd.com/936/" target="_blank">xkcd 936</a>:</summary>
<img src="https://imgs.xkcd.com/comics/password_strength.png">
</details>

I think this might be a useful extension of the [password lookup plugin](https://github.com/ansible/ansible/blob/devel/lib/ansible/plugins/lookup/password.py) *(implementation probably also in [utils/encrypt.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/utils/encrypt.py))*. While it is not important for credentials only used for autonomous communication between two systems/programs (without humans needing to know it), I got to the point where I also want to use it to set a password that is to be relayed to human actors for their use *(in my case [mailboxes on uberspace](https://manual.uberspace.de/mail-mailboxes/#changing-passwords), which does not allow changing passwords without also having access to the whole account)*. I do not know whether this is common or bad practice and am curious for your input.
As I propose adding this as additional options, no existing workflows would be broken.

An implementation of this requires a few considerations first, which I myself have no definite opinion on yet:
While the implementation for random characters is straightforward, generating word-based passwords requires the system to know a list of possible words to choose from (the dictionary). Introducing this will quickly raise the question for many language specific dictionaries, I expect. While specifying custom dictionaries should definitely be one option for this functionality, it should not be expected just because someone doesn't want his passwords in English. Furthermore, it is not trivial anymore to ensure a certain password strength as the words chosen from the dictionary differ in length.
I did a quick search for python packages that could be integrated or studied for reference and found [passwdgen](https://github.com/thanethomson/passwdgen). I do not have the knowledge to ensure this library is as safe as the current character-based implementation and doesn't open up huge security issues. 

### Issue Type

Feature Idea

### Component Name

password

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- name: Generate easy to remember password using builtin German dictionary
  set_fact:
    easy_pw: "{{ lookup('password', '/dev/null words=de length=4 seperator=- capitalization=keep') }}"
    # something like "unglaublich-Feder-Ohnmacht-laufen"
    # note that "length" specifies the number of words now. But we could also name it "word-count"

- name: Generate questionable password from a custom dictionary
  set_fact:
    easy_pw: "{{ lookup('password', '/dev/null words=dictionaries/buzzwords.txt length=4 seperator=. capitalization=lower') }}"
    # something like "html.ai.5g.cloud"
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct

_Copied from original issue: ansible/ansible#74974_


Discussion:
_From @PeterNerlich on Jun 14, 2021 14:55_

Another python package that might be better than passwdgen because it comes with a few wordlists already is [xkcdpass](https://github.com/redacted/XKCD-password-generator): In addition to English it has Spanish, Finnish, Italian, German, Norwegian, French and Portuguese. Also it was directly inspired by the comic I used for explanation, too.
_From @PeterNerlich on Jun 15, 2021 21:19_

Ah okay, thanks for the feedback. Yeah, I originally decided against just making a standalone plugin as it made more sense to me to include it as a new option in this password module.

I'm definitely having a look at writing custom plugins, I'll see whether I'll stick with it long enough and get to a point where I feel confident enough to publish it.
It there any PR in progress regarding this?

From @ukncsc as potential doc reference: https://www.ncsc.gov.uk/blog-post/the-logic-behind-three-random-words
From @usnistgov as potential doc reference: https://github.com/usnistgov/800-63-3/blob/nist-pages/sp800-63b/appA_memorized.md
Not from me. Maybe I'll get around to look at this again the following months, we'll see. Anyone who can beat me to it is very welcome!

Resolving patch:
diff --git a/plugins/lookup/random_words.py b/plugins/lookup/random_words.py
new file mode 100644
--- /dev/null
+++ b/plugins/lookup/random_words.py
@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+"""The community.general.random_words Ansible lookup plugin."""
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+    name: random_words
+    author:
+      - Thomas Sjögren (@konstruktoid)
+    short_description: Return a number of random words
+    version_added: "4.0.0"
+    requirements:
+      - xkcdpass U(https://github.com/redacted/XKCD-password-generator)
+    description:
+      - Returns a number of random words. The output can for example be used for
+        passwords.
+      - See U(https://xkcd.com/936/) for background.
+    options:
+      numwords:
+        description:
+          - The number of words.
+        default: 6
+        type: int
+      min_length:
+        description:
+          - Minimum length of words to make password.
+        default: 5
+        type: int
+      max_length:
+        description:
+          - Maximum length of words to make password.
+        default: 9
+        type: int
+      delimiter:
+        description:
+          - The delimiter character between words.
+        default: " "
+        type: str
+      case:
+        description:
+          - The method for setting the case of each word in the passphrase.
+        choices: ["alternating", "upper", "lower", "random", "capitalize"]
+        default: "lower"
+        type: str
+"""
+
+EXAMPLES = r"""
+- name: Generate password with default settings
+  ansible.builtin.debug:
+    var: lookup('community.general.random_words')
+  # Example result: 'traitor gigabyte cesarean unless aspect clear'
+
+- name: Generate password with six, five character, words
+  ansible.builtin.debug:
+    var: lookup('community.general.random_words', min_length=5, max_length=5)
+  # Example result: 'brink banjo getup staff trump comfy'
+
+- name: Generate password with three capitalized words and the '-' delimiter
+  ansible.builtin.debug:
+    var: lookup('community.general.random_words', numwords=3, delimiter='-', case='capitalize')
+  # Example result: 'Overlabor-Faucet-Coastline'
+
+- name: Generate password with three words without any delimiter
+  ansible.builtin.debug:
+    var: lookup('community.general.random_words', numwords=3, delimiter='')
+  # Example result: 'deskworkmonopolystriking'
+  # https://www.ncsc.gov.uk/blog-post/the-logic-behind-three-random-words
+"""
+
+RETURN = r"""
+  _raw:
+    description: A single-element list containing random words.
+    type: list
+    elements: str
+"""
+
+from ansible.errors import AnsibleLookupError
+from ansible.plugins.lookup import LookupBase
+
+try:
+    from xkcdpass import xkcd_password as xp
+
+    HAS_XKCDPASS = True
+except ImportError:
+    HAS_XKCDPASS = False
+
+
+class LookupModule(LookupBase):
+    """The random_words Ansible lookup class."""
+
+    def run(self, terms, variables=None, **kwargs):
+
+        if not HAS_XKCDPASS:
+            raise AnsibleLookupError(
+                "Python xkcdpass library is required. "
+                'Please install using "pip install xkcdpass"'
+            )
+
+        self.set_options(var_options=variables, direct=kwargs)
+        method = self.get_option("case")
+        delimiter = self.get_option("delimiter")
+        max_length = self.get_option("max_length")
+        min_length = self.get_option("min_length")
+        numwords = self.get_option("numwords")
+
+        words = xp.locate_wordfile()
+        wordlist = xp.generate_wordlist(
+            max_length=max_length, min_length=min_length, wordfile=words
+        )
+
+        values = xp.generate_xkcdpassword(
+            wordlist, case=method, delimiter=delimiter, numwords=numwords
+        )
+
+        return [values]


Test patch:
diff --git a/tests/integration/targets/lookup_random_words/aliases b/tests/integration/targets/lookup_random_words/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lookup_random_words/aliases
@@ -0,0 +1,3 @@
+shippable/posix/group2
+skip/aix
+skip/python2.6  # lookups are controller only, and we no longer support Python 2.6 on the controller
diff --git a/tests/integration/targets/lookup_random_words/dependencies.yml b/tests/integration/targets/lookup_random_words/dependencies.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lookup_random_words/dependencies.yml
@@ -0,0 +1,6 @@
+---
+- hosts: localhost
+  tasks:
+    - name: Install xkcdpass Python package
+      pip:
+        name: xkcdpass
diff --git a/tests/integration/targets/lookup_random_words/runme.sh b/tests/integration/targets/lookup_random_words/runme.sh
new file mode 100755
--- /dev/null
+++ b/tests/integration/targets/lookup_random_words/runme.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+set -eux
+
+ANSIBLE_ROLES_PATH=../ \
+    ansible-playbook dependencies.yml -v "$@"
+
+ANSIBLE_ROLES_PATH=../ \
+    ansible-playbook test.yml -v "$@"
diff --git a/tests/integration/targets/lookup_random_words/test.yml b/tests/integration/targets/lookup_random_words/test.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lookup_random_words/test.yml
@@ -0,0 +1,28 @@
+---
+- hosts: localhost
+  gather_facts: false
+  tasks:
+  - name: Call random_words plugin
+    set_fact:
+      result1: "{{ query('community.general.random_words') }}"
+      result2: "{{ query('community.general.random_words', min_length=5, max_length=5) }}"
+      result3: "{{ query('community.general.random_words', delimiter='!') }}"
+      result4: "{{ query('community.general.random_words', numwords=3, delimiter='-', case='capitalize') }}"
+      result5: "{{ query('community.general.random_words', numwords=3, delimiter='') }}"
+
+  - name: Check results
+    assert:
+      that:
+        - result1 | length == 1
+        - result1[0] | length >= 35
+        - result2 | length == 1
+        - result2[0] | length == 35
+        - result3 | length == 1
+        - result3[0].count("!") == 5
+        - result4 | length == 1
+        - result4[0] | length >= 17
+        - result4[0] | length <= 29
+        - result4[0] | regex_findall("[A-Z]") | length == 3
+        - result4[0].count("-") == 2
+        - result5 | length == 1
+        - result5[0] | length >= 18