Repository: Yubico/yubikey-manager (language: python)

Issue:
OpenPGP: Add commands for changing and using Reset Code.
This adds CLI commands for changing the Reset Code, as well as for resetting the PIN (using either Reset Code or Admin PIN).


Discussion / hints:

Fix patch:
diff --git a/ykman/cli/openpgp.py b/ykman/cli/openpgp.py
index 2fdb8ff9..2e371307 100644
--- a/ykman/cli/openpgp.py
+++ b/ykman/cli/openpgp.py
@@ -28,7 +28,13 @@
 import logging
 import click
 from ..util import parse_certificates, parse_private_key
-from ..openpgp import OpenPgpController, KEY_SLOT, TOUCH_MODE, get_openpgp_info
+from ..openpgp import (
+    OpenPgpController,
+    KEY_SLOT,
+    TOUCH_MODE,
+    PIN_POLICY,
+    get_openpgp_info,
+)
 from .util import (
     CliFail,
     click_force_option,
@@ -186,6 +192,158 @@ def set_pin_retries(
             echo_default_pins()
 
 
+@access.command("change-pin")
+@click.option("-P", "--pin", help="Current PIN code.")
+@click.option("-n", "--new-pin", help="A new PIN.")
+@click.pass_context
+def change_pin(ctx, pin, new_pin):
+    """
+    Change PIN.
+
+    The PIN has a minimum length of 6, and supports any type of
+    alphanumeric characters.
+    """
+
+    controller = ctx.obj["controller"]
+
+    if pin is None:
+        pin = click_prompt("Enter PIN", hide_input=True)
+
+    if new_pin is None:
+        new_pin = click_prompt(
+            "New PIN",
+            hide_input=True,
+            confirmation_prompt=True,
+        )
+
+    controller.change_pin(pin, new_pin)
+
+
+@access.command("change-reset-code")
+@click.option("-a", "--admin-pin", help="Admin PIN.")
+@click.option("-r", "--reset-code", help="A new Reset Code.")
+@click.pass_context
+def change_reset_code(ctx, admin_pin, reset_code):
+    """
+    Change Reset Code.
+
+    The Reset Code has a minimum length of 6, and supports any type of
+    alphanumeric characters.
+    """
+
+    controller = ctx.obj["controller"]
+
+    if admin_pin is None:
+        admin_pin = click_prompt("Enter Admin PIN", hide_input=True)
+
+    if reset_code is None:
+        reset_code = click_prompt(
+            "New Reset Code",
+            hide_input=True,
+            confirmation_prompt=True,
+        )
+
+    controller.verify_admin(admin_pin)
+    controller.change_reset_code(reset_code)
+
+
+@access.command("change-admin-pin")
+@click.option("-a", "--admin-pin", help="Current Admin PIN.")
+@click.option("-n", "--new-admin-pin", help="New Admin PIN.")
+@click.pass_context
+def change_admin(ctx, admin_pin, new_admin_pin):
+    """
+    Change Admin PIN.
+
+    The Admin PIN has a minimum length of 8, and supports any type of
+    alphanumeric characters.
+    """
+
+    controller = ctx.obj["controller"]
+
+    if admin_pin is None:
+        admin_pin = click_prompt("Enter Admin PIN", hide_input=True)
+
+    if new_admin_pin is None:
+        new_admin_pin = click_prompt(
+            "New Admin PIN",
+            hide_input=True,
+            confirmation_prompt=True,
+        )
+
+    controller.change_admin(admin_pin, new_admin_pin)
+
+
+@access.command("unblock-pin")
+@click.option(
+    "-a", "--admin-pin", help='Admin PIN. Use "-" as a value to prompt for input.'
+)
+@click.option("-r", "--reset-code", help="Reset Code.")
+@click.option("-n", "--new-pin", help="A new PIN.")
+@click.pass_context
+def unblock_pin(ctx, admin_pin, reset_code, new_pin):
+    """
+    Unblock PIN.
+
+    If the PIN is lost or blocked you can reset it to a new value using either the
+    Reset Code OR the Admin PIN.
+
+    The new PIN has a minimum length of 6, and supports any type of
+    alphanumeric characters.
+    """
+
+    controller = ctx.obj["controller"]
+
+    if reset_code is not None and admin_pin is not None:
+        raise CliFail(
+            "Invalid options: Only one of --reset-code and --admin-pin may be used."
+        )
+
+    if admin_pin == "-":
+        admin_pin = click_prompt("Enter Admin PIN", hide_input=True)
+
+    if reset_code is None and admin_pin is None:
+        reset_code = click_prompt("Enter Reset Code", hide_input=True)
+
+    if new_pin is None:
+        new_pin = click_prompt(
+            "New PIN",
+            hide_input=True,
+            confirmation_prompt=True,
+        )
+
+    if admin_pin:
+        controller.verify_admin(admin_pin)
+    controller.reset_pin(new_pin, reset_code)
+
+
+@access.command("set-signature-policy")
+@click.argument("policy", metavar="POLICY", type=EnumChoice(PIN_POLICY))
+@click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP.")
+@click.pass_context
+def set_signature_policy(ctx, policy, admin_pin):
+    """
+    Set Signature PIN policy.
+
+    \b
+    POLICY  Signature PIN policy to set (always, once).
+
+    The Signature PIN policy is used to control whether the PIN is
+    always required when using the Signature key, or if it is required
+    only once per session.
+    """
+    controller = ctx.obj["controller"]
+
+    if admin_pin is None:
+        admin_pin = click_prompt("Enter Admin PIN", hide_input=True)
+
+    try:
+        controller.verify_admin(admin_pin)
+        controller.set_signature_pin_policy(policy)
+    except Exception:
+        raise CliFail("Failed to set new Signature PIN policy")
+
+
 @openpgp.group("keys")
 def keys():
     """Manage private keys."""
diff --git a/ykman/openpgp.py b/ykman/openpgp.py
index d556ca55..87624ae4 100755
--- a/ykman/openpgp.py
+++ b/ykman/openpgp.py
@@ -107,20 +107,34 @@ def __str__(self):
             return "Cached (fixed)"
 
 
+@unique
+class PIN_POLICY(IntEnum):  # noqa: N801
+    ONCE = 0x00
+    ALWAYS = 0x01
+
+    def __str__(self):
+        if self == PIN_POLICY.ONCE:
+            return "Once"
+        elif self == PIN_POLICY.ALWAYS:
+            return "Always"
+
+
 @unique
 class INS(IntEnum):  # noqa: N801
-    GET_DATA = 0xCA
-    GET_VERSION = 0xF1
-    SET_PIN_RETRIES = 0xF2
     VERIFY = 0x20
-    TERMINATE = 0xE6
+    CHANGE_PIN = 0x24
+    RESET_RETRY_COUNTER = 0x2C
     ACTIVATE = 0x44
     GENERATE_ASYM = 0x47
+    SELECT_DATA = 0xA5
+    SEND_REMAINING = 0xC0
+    GET_DATA = 0xCA
     PUT_DATA = 0xDA
     PUT_DATA_ODD = 0xDB
+    TERMINATE = 0xE6
+    GET_VERSION = 0xF1
+    SET_PIN_RETRIES = 0xF2
     GET_ATTESTATION = 0xFB
-    SEND_REMAINING = 0xC0
-    SELECT_DATA = 0xA5
 
 
 class PinRetries(NamedTuple):
@@ -130,6 +144,7 @@ class PinRetries(NamedTuple):
 
 
 PW1 = 0x81
+PW2 = 0x82  # Resetting Code
 PW3 = 0x83
 INVALID_PIN = b"\0" * 8
 TOUCH_METHOD_BUTTON = 0x20
@@ -139,9 +154,10 @@ class PinRetries(NamedTuple):
 class DO(IntEnum):
     AID = 0x4F
     PW_STATUS = 0xC4
-    CARDHOLDER_CERTIFICATE = 0x7F21
-    ATT_CERTIFICATE = 0xFC
+    RESETTING_CODE = 0xD3
     KDF = 0xF9
+    ATT_CERTIFICATE = 0xFC
+    CARDHOLDER_CERTIFICATE = 0x7F21
 
 
 @unique
@@ -298,6 +314,8 @@ def process(self, pw, pin):
         kdf = _KDFS[self.kdf_algorithm]
         if pw == PW1:
             salt = self.pw1_salt_bytes
+        elif pw == PW2:
+            salt = self.pw2_salt_bytes or self.pw1_salt_bytes
         elif pw == PW3:
             salt = self.pw3_salt_bytes or self.pw1_salt_bytes
         else:
@@ -374,6 +392,17 @@ def get_remaining_pin_tries(self):
         data = self._get_data(DO.PW_STATUS)
         return PinRetries(*data[4:7])
 
+    def get_signature_pin_policy(self):
+        data = self._get_data(DO.PW_STATUS)
+
+        return PIN_POLICY(data[0])
+
+    def set_signature_pin_policy(self, pin_policy):
+        """Requires Admin PIN verification."""
+        data = struct.pack(">B", pin_policy)
+
+        self._put_data(DO.PW_STATUS, data)
+
     def _block_pins(self):
         retries = self.get_remaining_pin_tries()
 
@@ -416,6 +445,47 @@ def verify_pin(self, pin):
     def verify_admin(self, admin_pin):
         self._verify(PW3, admin_pin)
 
+    def _change(self, pw, pin, new_pin):
+        try:
+            pin = self._get_kdf().process(pw, pin.encode())
+            new_pin = self._get_kdf().process(pw, new_pin.encode())
+            self._app.send_apdu(0, INS.CHANGE_PIN, 0, pw, pin + new_pin)
+        except ApduError as e:
+            if e.sw == SW.CONDITIONS_NOT_SATISFIED:
+                raise ValueError("Conditions of use not satisfied.")
+            else:
+                pw_remaining = self.get_remaining_pin_tries()[pw - PW1]
+                raise ValueError(f"Invalid PIN, {pw_remaining} tries remaining.")
+
+    def change_pin(self, pin, new_pin):
+        self._change(PW1, pin, new_pin)
+
+    def change_admin(self, admin_pin, new_admin_pin):
+        self._change(PW3, admin_pin, new_admin_pin)
+
+    def change_reset_code(self, reset_code):
+        data = self._get_kdf().process(PW2, reset_code.encode())
+        self._put_data(DO.RESETTING_CODE, data)
+
+    def reset_pin(self, new_pin, reset_code=None):
+        p1 = 2
+        data = self._get_kdf().process(PW1, new_pin.encode())
+        if reset_code:
+            rc = self._get_kdf().process(PW2, reset_code.encode())
+            p1 = 0
+            data = rc + data
+
+        try:
+            self._app.send_apdu(0, INS.RESET_RETRY_COUNTER, p1, PW1, data)
+        except ApduError as e:
+            if e.sw == SW.CONDITIONS_NOT_SATISFIED:
+                raise ValueError("Conditions of use not satisfied.")
+            else:
+                reset_remaining = self.get_remaining_pin_tries().reset
+                raise ValueError(
+                    f"Invalid Reset Code, {reset_remaining} tries remaining."
+                )
+
     @property
     def supported_touch_policies(self):
         if self.version < (4, 2, 0):
@@ -605,6 +675,7 @@ def get_openpgp_info(controller: OpenPgpController):
         "PIN tries remaining": retries.pin,
         "Reset code tries remaining": retries.reset,
         "Admin PIN tries remaining": retries.admin,
+        "Signature PIN": controller.get_signature_pin_policy(),
     }
 
     # Touch only available on YK4 and later


Test patch:
diff --git a/tests/device/cli/test_openpgp.py b/tests/device/cli/test_openpgp.py
index 60549e78..1339192e 100644
--- a/tests/device/cli/test_openpgp.py
+++ b/tests/device/cli/test_openpgp.py
@@ -2,6 +2,15 @@
 from .. import condition
 import pytest
 
+DEFAULT_PIN = "123456"
+NON_DEFAULT_PIN = "654321"
+DEFAULT_ADMIN_PIN = "12345678"
+NON_DEFAULT_ADMIN_PIN = "87654321"
+
+
+def old_new_new(old, new):
+    return f"{old}\n{new}\n{new}\n"
+
 
 @pytest.fixture(autouse=True)
 @condition.capability(CAPABILITY.OPENPGP)
@@ -17,3 +26,108 @@ def test_openpgp_info(self, ykman_cli):
     def test_openpgp_reset(self, ykman_cli):
         output = ykman_cli("openpgp", "reset", "-f").output
         assert "Success! All data has been cleared and default PINs are set." in output
+
+
+class TestPin:
+    def test_change_pin(self, ykman_cli):
+        ykman_cli(
+            "openpgp", "access", "change-pin", "-P", DEFAULT_PIN, "-n", NON_DEFAULT_PIN
+        )
+        ykman_cli(
+            "openpgp", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN
+        )
+
+    def test_change_pin_prompt(self, ykman_cli):
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-pin",
+            input=old_new_new(DEFAULT_PIN, NON_DEFAULT_PIN),
+        )
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-pin",
+            input=old_new_new(NON_DEFAULT_PIN, DEFAULT_PIN),
+        )
+
+
+class TestAdminPin:
+    def test_change_admin_pin(self, ykman_cli):
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-admin-pin",
+            "-a",
+            DEFAULT_ADMIN_PIN,
+            "-n",
+            NON_DEFAULT_ADMIN_PIN,
+        )
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-admin-pin",
+            "-a",
+            NON_DEFAULT_ADMIN_PIN,
+            "-n",
+            DEFAULT_ADMIN_PIN,
+        )
+
+    def test_change_pin_prompt(self, ykman_cli):
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-admin-pin",
+            input=old_new_new(DEFAULT_ADMIN_PIN, NON_DEFAULT_ADMIN_PIN),
+        )
+        ykman_cli(
+            "openpgp",
+            "access",
+            "change-admin-pin",
+            input=old_new_new(NON_DEFAULT_ADMIN_PIN, DEFAULT_ADMIN_PIN),
+        )
+
+
+class TestForceSignature:
+    def test_set_force_sig(self, ykman_cli):
+        ykman_cli(
+            "openpgp",
+            "access",
+            "set-signature-policy",
+            "ALWAYS",
+            "-a",
+            DEFAULT_ADMIN_PIN,
+        )
+
+        output = ykman_cli("openpgp", "info").output
+        assert "Always" in output
+
+        ykman_cli(
+            "openpgp", "access", "set-signature-policy", "ONCE", "-a", DEFAULT_ADMIN_PIN
+        )
+
+        output = ykman_cli("openpgp", "info").output
+        assert "Once" in output
+
+    def test_set_force_sig_prompt(self, ykman_cli):
+        ykman_cli(
+            "openpgp",
+            "access",
+            "set-signature-policy",
+            "ALWAYS",
+            input=DEFAULT_ADMIN_PIN,
+        )
+
+        output = ykman_cli("openpgp", "info").output
+        assert "Always" in output
+
+        ykman_cli(
+            "openpgp",
+            "access",
+            "set-signature-policy",
+            "ONCE",
+            input=DEFAULT_ADMIN_PIN,
+        )
+
+        output = ykman_cli("openpgp", "info").output
+        assert "Once" in output