yubikey_manager-5.6.1/0000775000175000017500000000000014775540152014256 5ustar winniewinnieyubikey_manager-5.6.1/ykman/0000775000175000017500000000000014775540152015375 5ustar winniewinnieyubikey_manager-5.6.1/ykman/scancodes/0000775000175000017500000000000014775540152017337 5ustar winniewinnieyubikey_manager-5.6.1/ykman/scancodes/norman.py0000644000175000017500000000636014777516541021215 0ustar winniewinnie# vim: set fileencoding=utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for US English Norman keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x08, "e": 0x07, "f": 0x15, "g": 0x0A, "h": 0x33, "i": 0x0E, "j": 0x1C, "k": 0x17, "l": 0x12, "m": 0x10, "n": 0x0D, "o": 0x0F, "p": 0x11, "q": 0x14, "r": 0x0C, "s": 0x16, "t": 0x09, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x0B, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x08 | SHIFT, "E": 0x07 | SHIFT, "F": 0x15 | SHIFT, "G": 0x0A | SHIFT, "H": 0x33 | SHIFT, "I": 0x0E | SHIFT, "J": 0x1C | SHIFT, "K": 0x17 | SHIFT, "L": 0x12 | SHIFT, "M": 0x10 | SHIFT, "N": 0x0D | SHIFT, "O": 0x0F | SHIFT, "P": 0x11 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x0C | SHIFT, "S": 0x16 | SHIFT, "T": 0x09 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x0B | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x34 | SHIFT, "#": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x13, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, "@": 0x1F | SHIFT, "[": 0x2F, "\\": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "|": 0x32 | SHIFT, "~": 0x35 | SHIFT, " ": 0x2C, } yubikey_manager-5.6.1/ykman/scancodes/de.py0000644000175000017500000000641714777516541020316 0ustar winniewinnie# vim: set fileencoding=utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for DE German keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1D, "z": 0x1C, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1D | SHIFT, "Z": 0x1C | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x1F | SHIFT, "#": 0x32, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x23 | SHIFT, "'": 0x32 | SHIFT, "(": 0x25 | SHIFT, ")": 0x26 | SHIFT, "*": 0x30 | SHIFT, "+": 0x30, ",": 0x36, "-": 0x38, ".": 0x37, "/": 0x24 | SHIFT, ":": 0x37 | SHIFT, ";": 0x36 | SHIFT, "<": 0x64, "=": 0x27 | SHIFT, ">": 0x64 | SHIFT, "?": 0x2D | SHIFT, "^": 0x35, "_": 0x38 | SHIFT, " ": 0x2C, "`": 0x2D | SHIFT, "§": 0x20 | SHIFT, "´": 0x2E, "Ä": 0x34 | SHIFT, "Ö": 0x33 | SHIFT, "Ü": 0x2F | SHIFT, "ß": 0x2D, "ä": 0x34, "ö": 0x33, "ü": 0x2F, } yubikey_manager-5.6.1/ykman/scancodes/us.py0000644000175000017500000000635114777516541020352 0ustar winniewinnie# vim: set fileencoding=utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for US English keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x34 | SHIFT, "#": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x33, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, "@": 0x1F | SHIFT, "[": 0x2F, "\\": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "|": 0x32 | SHIFT, "~": 0x35 | SHIFT, " ": 0x2C, } yubikey_manager-5.6.1/ykman/scancodes/bepo.py0000644000175000017500000000651514777516541020652 0ustar winniewinnie# vim: set fileencoding:utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for BÉPO (fr dvorak) keyboard layout""" SHIFT = 0x80 scancodes = { "\t": 0x2B | SHIFT, "\n": 0x28 | SHIFT, " ": 0x2C, "!": 0x1C | SHIFT, '"': 0x1E, "#": 0x35 | SHIFT, "$": 0x35, "%": 0x2E, "'": 0x11, "(": 0x21, ")": 0x22, "*": 0x27, "+": 0x24, ",": 0x0A, "-": 0x25, ".": 0x19, "/": 0x26, "0": 0x27 | SHIFT, "1": 0x1E | SHIFT, "2": 0x1F | SHIFT, "3": 0x20 | SHIFT, "4": 0x21 | SHIFT, "5": 0x22 | SHIFT, "6": 0x23 | SHIFT, "7": 0x24 | SHIFT, "8": 0x25 | SHIFT, "9": 0x26 | SHIFT, ":": 0x19 | SHIFT, ";": 0x0A | SHIFT, "=": 0x2D, "?": 0x11 | SHIFT, "@": 0x23, "A": 0x04 | SHIFT, "B": 0x14 | SHIFT, "C": 0x0B | SHIFT, "D": 0x0C | SHIFT, "E": 0x09 | SHIFT, "F": 0x38 | SHIFT, "G": 0x36 | SHIFT, "H": 0x37 | SHIFT, "I": 0x07 | SHIFT, "J": 0x13 | SHIFT, "K": 0x05 | SHIFT, "L": 0x12 | SHIFT, "M": 0x34 | SHIFT, "N": 0x33 | SHIFT, "O": 0x15 | SHIFT, "P": 0x08 | SHIFT, "Q": 0x10 | SHIFT, "R": 0x0F | SHIFT, "S": 0x0E | SHIFT, "T": 0x0D | SHIFT, "U": 0x16 | SHIFT, "V": 0x18 | SHIFT, "W": 0x30 | SHIFT, "X": 0x06 | SHIFT, "Y": 0x1B | SHIFT, "Z": 0x2F | SHIFT, "`": 0x2E | SHIFT, "a": 0x04, "b": 0x14, "c": 0x0B, "d": 0x0C, "e": 0x09, "f": 0x38, "g": 0x36, "h": 0x37, "i": 0x07, "j": 0x13, "k": 0x05, "l": 0x12, "m": 0x34, "n": 0x33, "o": 0x15, "p": 0x08, "q": 0x10, "r": 0x0F, "s": 0x0E, "t": 0x0D, "u": 0x16, "v": 0x18, "w": 0x30, "x": 0x06, "y": 0x1B, "z": 0x2F, "\xa0": 0x2C | SHIFT, "«": 0x1F, "°": 0x2D | SHIFT, "»": 0x20, "À": 0x1D | SHIFT, "Ç": 0x31 | SHIFT, "È": 0x17 | SHIFT, "É": 0x1A | SHIFT, "Ê": 0x64 | SHIFT, "à": 0x1D, "ç": 0x31, "è": 0x17, "é": 0x1A, "ê": 0x64, } yubikey_manager-5.6.1/ykman/scancodes/uk.py0000644000175000017500000000635214777516541020343 0ustar winniewinnie# vim: set fileencoding=utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for UK English keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, "@": 0x34 | SHIFT, "£": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x33, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, '"': 0x1F | SHIFT, "[": 0x2F, "#": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "~": 0x32 | SHIFT, "¬": 0x35 | SHIFT, " ": 0x2C, } yubikey_manager-5.6.1/ykman/scancodes/modhex.py0000644000175000017500000000417014777516541021204 0ustar winniewinnie# vim: set fileencoding=utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for keyboard layout based on Modhex. Note that this layouts allows both upper and lowercase characters.""" SHIFT = 0x80 scancodes = { "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "n": 0x11, "r": 0x15, "t": 0x17, "u": 0x18, "v": 0x19, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "N": 0x11 | SHIFT, "R": 0x15 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, } yubikey_manager-5.6.1/ykman/scancodes/fr.py0000644000175000017500000000632714777516541020335 0ustar winniewinnie# vim: set fileencoding:utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for FR French (AZERTY) keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x14, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x33, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x04, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1D, "x": 0x1B, "y": 0x1C, "z": 0x1A, "A": 0x14 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x33 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x04 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1D | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1A | SHIFT, "0": 0x27 | SHIFT, "1": 0x1E | SHIFT, "2": 0x1F | SHIFT, "3": 0x20 | SHIFT, "4": 0x21 | SHIFT, "5": 0x22 | SHIFT, "6": 0x23 | SHIFT, "7": 0x24 | SHIFT, "8": 0x25 | SHIFT, "9": 0x26 | SHIFT, "\t": 0x2B, "\n": 0x28, " ": 0x2C, "!": 0x38, '"': 0x20, "$": 0x30, "%": 0x34 | SHIFT, "&": 0x1E, "'": 0x21, "(": 0x22, ")": 0x2D, "*": 0x31, "+": 0x2E | SHIFT, ",": 0x10, "-": 0x23, ".": 0x36 | SHIFT, "/": 0x37 | SHIFT, ":": 0x37, ";": 0x36, "<": 0x64, "=": 0x2E, "_": 0x25, "\x7f": 0x2A, "£": 0x30 | SHIFT, "§": 0x38 | SHIFT, "°": 0x2D | SHIFT, "²": 0x35, "µ": 0x31 | SHIFT, "à": 0x27, "ç": 0x26, "è": 0x24, "é": 0x1F, "ù": 0x34, } yubikey_manager-5.6.1/ykman/scancodes/__init__.py0000644000175000017500000000356314777516541021464 0ustar winniewinnie# Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from enum import Enum from . import us, uk, de, fr, it, modhex, norman, bepo class KEYBOARD_LAYOUT(Enum): MODHEX = modhex.scancodes US = us.scancodes UK = uk.scancodes DE = de.scancodes FR = fr.scancodes IT = it.scancodes BEPO = bepo.scancodes NORMAN = norman.scancodes def encode(data, keyboard_layout=KEYBOARD_LAYOUT.MODHEX): try: return bytes(bytearray(keyboard_layout.value[c] for c in data)) except KeyError as e: raise ValueError(f"Unsupported character: {e.args[0]}") yubikey_manager-5.6.1/ykman/scancodes/it.py0000644000175000017500000000642714777516541020343 0ustar winniewinnie# vim: set fileencoding:utf-8 : # Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. """Scancode map for IT Italian (AZERTY) keyboard layout""" SHIFT = 0x80 scancodes = { "\t": 0x2B, "\n": 0x28, " ": 0x2C, "!": 0x1E | SHIFT, '"': 0x1F | SHIFT, "#": 0x32, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x23 | SHIFT, "'": 0x2D, "(": 0x25 | SHIFT, ")": 0x26 | SHIFT, "*": 0x55, "+": 0x30, ",": 0x36, "-": 0x38, ".": 0x63, "/": 0x24 | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, ":": 0xB7, ";": 0xB6, "<": 0x64, "=": 0x27 | SHIFT, ">": 0x64 | SHIFT, "?": 0x2D | SHIFT, "@": 0x24, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "\\": 0x35, "^": 0xAE, "_": 0xB8, "`": 0x2D | SHIFT, "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "|": 0xB5, "£": 0xA0, "§": 0xB2, "°": 0xB4, "ç": 0xB3, "è": 0x2F, "é": 0x2F | SHIFT, "à": 0x34, "ì": 0x2E, "ò": 0x33, "ù": 0x31, } yubikey_manager-5.6.1/ykman/scripting.py0000644000175000017500000002115314777516541017760 0ustar winniewinnie# Copyright (c) 2021 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .base import YkmanDevice from .device import list_all_devices, scan_devices from .pcsc import list_devices as list_ccid from yubikit.core import TRANSPORT from yubikit.core.otp import OtpConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.core.fido import FidoConnection from yubikit.management import DeviceInfo from yubikit.support import get_name, read_info from smartcard.Exceptions import NoCardException, CardConnectionException from time import sleep from typing import Generator, Optional, Set """ Various helpers intended to simplify scripting. Add an import to your script: from ykman import scripting as s Example usage: yubikey = s.single() print("Here is a YubiKey:", yubikey) print("Insert multiple YubiKeys") for yubikey in s.multi(): print("You inserted {yubikey}") print("You pressed Ctrl+C, end of script") """ class ScriptingDevice: """Scripting-friendly proxy for YkmanDevice. This wrapper adds some helpful utility methods useful for scripting. """ def __init__(self, wrapped, info): self._wrapped = wrapped self._info = info self._name = get_name(info, self.pid.yubikey_type if self.pid else None) def __getattr__(self, attr): return getattr(self._wrapped, attr) def __str__(self): serial = self._info.serial return f"{self._name} ({serial})" if serial else self._name @property def info(self) -> DeviceInfo: return self._info @property def name(self) -> str: return self._name def otp(self) -> OtpConnection: """Establish a OTP connection.""" return self.open_connection(OtpConnection) def smart_card(self) -> SmartCardConnection: """Establish a Smart Card connection.""" return self.open_connection(SmartCardConnection) def fido(self) -> FidoConnection: """Establish a FIDO connection.""" return self.open_connection(FidoConnection) YkmanDevice.register(ScriptingDevice) def single(*, prompt=True) -> ScriptingDevice: """Connect to a YubiKey. :param prompt: When set, you will be prompted to insert a YubiKey. """ pids, state = scan_devices() n_devs = sum(pids.values()) if prompt and n_devs == 0: print("Insert YubiKey...") while n_devs == 0: sleep(1.0) pids, new_state = scan_devices() n_devs = sum(pids.values()) devs = list_all_devices() if len(devs) == 1: return ScriptingDevice(*devs[0]) raise ValueError("Failed to get single YubiKey") def multi( *, ignore_duplicates: bool = True, allow_initial: bool = False, prompt: bool = True ) -> Generator[ScriptingDevice, None, None]: """Connect to multiple YubiKeys. :param ignore_duplicates: When set, duplicates are ignored. :param allow_initial: When set, YubiKeys can be connected at the start of the function call. :param prompt: When set, you will be prompted to insert a YubiKey. """ state = None handled_serials: Set[Optional[int]] = set() pids, _ = scan_devices() n_devs = sum(pids.values()) if n_devs == 0: if prompt: print("Insert YubiKeys, one at a time...") elif not allow_initial: raise ValueError("YubiKeys must not be present initially.") while True: # Run this until we stop the script with Ctrl+C pids, new_state = scan_devices() if new_state != state: state = new_state # State has changed serials = set() if len(pids) == 0 and None in handled_serials: handled_serials.remove(None) # Allow one key without serial at a time for device, info in list_all_devices(): serials.add(info.serial) if info.serial not in handled_serials: handled_serials.add(info.serial) yield ScriptingDevice(device, info) if not ignore_duplicates: # Reset handled serials to currently connected handled_serials = serials else: try: sleep(1.0) # No change, sleep for 1 second. except KeyboardInterrupt: return # Stop waiting def _get_reader(reader) -> YkmanDevice: readers = [d for d in list_ccid(reader) if d.transport == TRANSPORT.NFC] if not readers: raise ValueError(f"No NFC reader found matching filter: '{reader}'") elif len(readers) > 1: names = [r.fingerprint for r in readers] raise ValueError(f"Multiple NFC readers matching filter: '{reader}' {names}") return readers[0] def single_nfc(reader="", *, prompt=True) -> ScriptingDevice: """Connect to a YubiKey over NFC. :param reader: The name of the NFC reader. :param prompt: When set, you will prompted to place a YubiKey on NFC reader. """ device = _get_reader(reader) while True: try: with device.open_connection(SmartCardConnection) as connection: info = read_info(connection) return ScriptingDevice(device, info) except NoCardException: if prompt: print("Place YubiKey on NFC reader...") prompt = False sleep(1.0) def multi_nfc( reader="", *, ignore_duplicates=True, allow_initial=False, prompt=True ) -> Generator[ScriptingDevice, None, None]: """Connect to multiple YubiKeys over NFC. :param reader: The name of the NFC reader. :param ignore_duplicates: When set, duplicates are ignored. :param allow_initial: When set, YubiKeys can be connected at the start of the function call. :param prompt: When set, you will be prompted to place YubiKeys on the NFC reader. """ device = _get_reader(reader) prompted = False try: with device.open_connection(SmartCardConnection) as connection: if not allow_initial: raise ValueError("YubiKey must not be present initially.") except NoCardException: if prompt: print("Place YubiKey on NFC reader...") prompted = True sleep(1.0) handled_serials: Set[Optional[int]] = set() current: Optional[int] = -1 while True: # Run this until we stop the script with Ctrl+C try: with device.open_connection(SmartCardConnection) as connection: info = read_info(connection) if info.serial in handled_serials or current == info.serial: if prompt and not prompted: print("Remove YubiKey from NFC reader.") prompted = True else: current = info.serial if ignore_duplicates: handled_serials.add(current) yield ScriptingDevice(device, info) prompted = False except NoCardException: if None in handled_serials: handled_serials.remove(None) # Allow one key without serial at a time current = -1 if prompt and not prompted: print("Place YubiKey on NFC reader...") prompted = True except CardConnectionException: pass try: sleep(1.0) # No change, sleep for 1 second. except KeyboardInterrupt: return # Stop waiting yubikey_manager-5.6.1/ykman/otp.py0000644000175000017500000001014314777516541016555 0ustar winniewinnie# Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .scancodes import KEYBOARD_LAYOUT from yubikit.core.otp import modhex_encode from yubikit.yubiotp import YubiOtpSession from yubikit.oath import parse_b32_key from datetime import datetime from typing import Iterable, Optional import struct import random import logging logger = logging.getLogger(__name__) def is_in_fips_mode(session: YubiOtpSession) -> bool: """Check if the OTP application of a FIPS YubiKey is in FIPS approved mode. :param session: The YubiOTP session. """ return session.backend.send_and_receive(0x14, b"", 1) == b"\1" # type: ignore DEFAULT_PW_CHAR_BLOCKLIST = ["\t", "\n", " "] def generate_static_pw( length: int, keyboard_layout: KEYBOARD_LAYOUT = KEYBOARD_LAYOUT.MODHEX, blocklist: Iterable[str] = DEFAULT_PW_CHAR_BLOCKLIST, ) -> str: """Generate a random password. :param length: The length of the password. :param keyboard_layout: The keyboard layout. :param blocklist: The list of characters to block. """ chars = [k for k in keyboard_layout.value.keys() if k not in blocklist] sr = random.SystemRandom() return "".join([sr.choice(chars) for _ in range(length)]) def parse_oath_key(val: str) -> bytes: """Parse a secret key encoded as either Hex or Base32. :param val: The secret key. """ try: return bytes.fromhex(val) except ValueError: return parse_b32_key(val) def format_oath_code(response: bytes, digits: int = 6) -> str: """Format an OATH code from a hash response. :param response: The response. :param digits: The number of digits in the OATH code. """ offs = response[-1] & 0xF code = struct.unpack_from(">I", response[offs:])[0] & 0x7FFFFFFF return ("%%0%dd" % digits) % (code % 10**digits) def time_challenge(timestamp: int, period: int = 30) -> bytes: """Format a HMAC-SHA1 challenge based on an OATH timestamp and period. :param timestamp: The timestamp. :param period: The period. """ return struct.pack(">q", int(timestamp // period)) def format_csv( serial: int, public_id: bytes, private_id: bytes, key: bytes, access_code: Optional[bytes] = None, timestamp: Optional[datetime] = None, ) -> str: """Produce a CSV line in the "Yubico" format. :param serial: The serial number. :param public_id: The public ID. :param private_id: The private ID. :param key: The secret key. :param access_code: The access code. """ ts = timestamp or datetime.now() return ",".join( [ str(serial), modhex_encode(public_id), private_id.hex(), key.hex(), access_code.hex() if access_code else "", ts.isoformat(timespec="seconds"), "", # Add trailing comma ] ) yubikey_manager-5.6.1/ykman/_cli/0000775000175000017500000000000014775540152016303 5ustar winniewinnieyubikey_manager-5.6.1/ykman/_cli/config.py0000644000175000017500000005117514777516541020140 0ustar winniewinnie# Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT, YUBIKEY from yubikit.core.otp import OtpConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.core.fido import FidoConnection from yubikit.management import ( ManagementSession, DeviceConfig, CAPABILITY, USB_INTERFACE, DEVICE_FLAG, Mode, ) from .util import ( click_group, click_postpone_execution, click_force_option, click_prompt, EnumChoice, CliFail, ) import os import re import click import logging logger = logging.getLogger(__name__) CLEAR_LOCK_CODE = b"\0" * 16 def prompt_lock_code(): return click_prompt("Enter your lock code", hide_input=True) @click_group(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context @click_postpone_execution def config(ctx): """ Configure the YubiKey, enable or disable applications. The applications may be enabled and disabled independently over different transports (USB and NFC). The configuration may also be protected by a lock code. Examples: \b Disable PIV over NFC: $ ykman config nfc --disable PIV \b Enable all applications over USB: $ ykman config usb --enable-all \b Generate and set a random application lock code: $ ykman config set-lock-code --generate """ dev = ctx.obj["device"] for conn_type in (SmartCardConnection, OtpConnection, FidoConnection): if dev.supports_connection(conn_type): try: conn = dev.open_connection(conn_type) ctx.call_on_close(conn.close) ctx.obj["session"] = ManagementSession(conn) return except Exception: logger.warning( f"Failed connecting to the YubiKey over {conn_type}", exc_info=True ) raise CliFail("Couldn't connect to the YubiKey.") def _require_config(ctx): info = ctx.obj["info"] if (1, 0, 0) < info.version < (5, 0, 0): raise CliFail( "Configuring applications is not supported on this YubiKey. " "Use the `mode` command to configure USB interfaces." ) @config.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all YubiKey data. This command is used with the YubiKey Bio Multi-protocol Edition. This action will wipe all data and restore factory settings for all applications on the YubiKey. """ dev = ctx.obj["device"] if not dev.supports_connection(SmartCardConnection): raise CliFail("Full device reset requires the CCID interface to be enabled.") info = ctx.obj["info"] # reset_blocked is a sure indicator of the command if not info.reset_blocked: # No reset blocked, we can still check for Bio MPE transport = ctx.obj["device"].transport has_piv = CAPABILITY.PIV in info.supported_capabilities.get(transport) if not (info._is_bio and has_piv): raise CliFail( "Full device reset is not supported on this YubiKey, " "refer to reset commands for specific applications instead." ) force or click.confirm( "WARNING! This will delete all stored data and restore factory " "settings. Proceed?", abort=True, err=True, ) click.echo("Resetting YubiKey data...") ctx.obj["session"].device_reset() click.echo("Reset complete. All data has been cleared from the YubiKey.") @config.command("set-lock-code") @click.pass_context @click_force_option @click.option("-l", "--lock-code", metavar="HEX", help="current lock code") @click.option( "-n", "--new-lock-code", metavar="HEX", help="new lock code (can't be used with --generate)", ) @click.option("-c", "--clear", is_flag=True, help="clear the lock code") @click.option( "-g", "--generate", is_flag=True, help="generate a random lock code (can't be used with --new-lock-code)", ) def set_lock_code(ctx, lock_code, new_lock_code, clear, generate, force): """ Set or change the configuration lock code. A lock code may be used to protect the application configuration. The lock code must be a 32 characters (16 bytes) hex value. """ _require_config(ctx) info = ctx.obj["info"] app = ctx.obj["session"] if sum(1 for arg in [new_lock_code, generate, clear] if arg) > 1: raise CliFail( "Invalid options: Only one of --new-lock-code, --generate, " "and --clear may be used." ) # Get the new lock code to set if clear: set_code = CLEAR_LOCK_CODE elif generate: set_code = os.urandom(16) click.echo(f"Using a randomly generated lock code: {set_code.hex()}") force or click.confirm( "Lock configuration with this lock code?", abort=True, err=True ) else: if not new_lock_code: new_lock_code = click_prompt( "Enter your new lock code", hide_input=True, confirmation_prompt=True ) set_code = _parse_lock_code(new_lock_code) # Get the current lock code to use if info.is_locked: if not lock_code: lock_code = click_prompt("Enter your current lock code", hide_input=True) use_code = _parse_lock_code(lock_code) else: if lock_code: raise CliFail( "No lock code is currently set. Use --new-lock-code to set one." ) use_code = None # Set new lock code try: app.write_device_config( None, False, use_code, set_code, ) click.echo("Lock code updated.") except Exception: if info.is_locked: raise CliFail("Failed to change the lock code. Wrong current code?") raise CliFail("Failed to set the lock code.") def _get_lock_code(is_locked, lock_code, force): if force and is_locked and not lock_code: raise CliFail("Configuration is locked - supply the --lock-code option.") if lock_code and not is_locked: raise CliFail("Configuration is not locked - remove the --lock-code option.") if is_locked and not lock_code: lock_code = prompt_lock_code() if lock_code: lock_code = _parse_lock_code(lock_code) return lock_code def _configure_applications( ctx, config, changes, transport, enable, disable, lock_code, force, ): info = ctx.obj["info"] # If any app reset is blocked, we will not be able to toggle applications if info.reset_blocked: raise CliFail( "This YubiKey must be in a newly reset state before applications can be " "toggled." ) supported = info.supported_capabilities.get(transport) enabled = info.config.enabled_capabilities.get(transport) if not supported: raise CliFail(f"{transport} not supported on this YubiKey.") if enable & disable: raise CliFail("Invalid options.") unsupported = ~supported & (enable | disable) if unsupported: raise CliFail( f"{unsupported.display_name} not supported over {transport} on this " "YubiKey." ) # N.B. NOT (~) of IntFlag doesn't work as expected new_enabled = (enabled | enable) & ~int(disable) if transport == TRANSPORT.USB: if sum(CAPABILITY) & new_enabled == 0: raise CliFail(f"Can not disable all applications over {transport}.") reboot = enabled.usb_interfaces != new_enabled.usb_interfaces else: reboot = False if reboot: changes.append("The YubiKey will reboot") if lock_code and not info.is_locked: raise CliFail("Configuration is not locked - remove the --lock-code option.") click.echo(f"{transport} configuration changes:") for change in changes: click.echo(f" {change}") force or click.confirm("Proceed?", abort=True, err=True) config.enabled_capabilities = {transport: new_enabled} lock_code = _get_lock_code(info.is_locked, lock_code, force) app = ctx.obj["session"] try: app.write_device_config( config, reboot, lock_code, ) click.echo(f"{transport} application configuration updated.") except Exception: raise CliFail(f"Failed to configure {transport} applications.") @config.command() @click.pass_context @click_force_option @click.option( "-e", "--enable", multiple=True, type=EnumChoice(CAPABILITY), help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), help="disable applications", ) @click.option( "-l", "--list", "list_enabled", is_flag=True, help="list enabled applications" ) @click.option("-a", "--enable-all", is_flag=True, help="enable all applications") @click.option( "-L", "--lock-code", metavar="HEX", help="current application configuration lock code", ) @click.option( "--touch-eject", is_flag=True, help="when set, the button toggles the state" " of the smartcard between ejected and inserted (CCID only)", ) @click.option("--no-touch-eject", is_flag=True, help="disable touch eject (CCID only)") @click.option( "--autoeject-timeout", required=False, type=int, default=None, metavar="SECONDS", help="when set, the smartcard will automatically eject" " after the given time (implies --touch-eject)", ) @click.option( "--chalresp-timeout", required=False, type=int, default=None, metavar="SECONDS", help="sets the timeout when waiting for touch for challenge-response in the OTP " "application", ) def usb( ctx, enable, disable, list_enabled, enable_all, touch_eject, no_touch_eject, autoeject_timeout, chalresp_timeout, lock_code, force, ): """ Enable or disable applications over USB. """ _require_config(ctx) if not any( [ list_enabled, enable_all, enable, disable, touch_eject, no_touch_eject, autoeject_timeout, chalresp_timeout, ] ): raise CliFail("No configuration options chosen.") if touch_eject and no_touch_eject: raise CliFail("Invalid options.") if list_enabled: _list_apps(ctx, TRANSPORT.USB) config = DeviceConfig({}, autoeject_timeout, chalresp_timeout) changes = [] info = ctx.obj["info"] if enable_all: enable = info.supported_capabilities.get(TRANSPORT.USB) changes.append("Enable all applications") else: enable = CAPABILITY(sum(enable)) if enable: changes.append(f"Enable {enable.display_name}") disable = CAPABILITY(sum(disable)) if disable: changes.append(f"Disable {disable.display_name}") if touch_eject: config.device_flags = info.config.device_flags | DEVICE_FLAG.EJECT changes.append("Enable touch-eject") if no_touch_eject: config.device_flags = info.config.device_flags & ~DEVICE_FLAG.EJECT changes.append("Disable touch-eject") if autoeject_timeout: changes.append(f"Set auto-eject timeout to {autoeject_timeout}") if chalresp_timeout: changes.append(f"Set challenge-response timeout to {chalresp_timeout}") _configure_applications( ctx, config, changes, TRANSPORT.USB, enable, disable, lock_code, force, ) @config.command() @click.pass_context @click_force_option @click.option( "-e", "--enable", multiple=True, type=EnumChoice(CAPABILITY), help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), help="disable applications", ) @click.option("-a", "--enable-all", is_flag=True, help="enable all applications") @click.option("-D", "--disable-all", is_flag=True, help="disable all applications") @click.option( "-l", "--list", "list_enabled", is_flag=True, help="list enabled applications" ) @click.option( "-L", "--lock-code", metavar="HEX", help="current application configuration lock code", ) @click.option( "-R", "--restrict", is_flag=True, help="Disable NFC for transport, re-enabled by USB power", ) def nfc( ctx, enable, disable, enable_all, disable_all, list_enabled, lock_code, restrict, force, ): """ Enable or disable applications over NFC. """ info = ctx.obj["info"] if TRANSPORT.NFC not in info.supported_capabilities: raise CliFail("This YubiKey does not support NFC.") _require_config(ctx) if not any([list_enabled, enable_all, enable, disable_all, disable, restrict]): raise CliFail("No configuration options chosen.") if list_enabled: _list_apps(ctx, TRANSPORT.NFC) config = DeviceConfig({}, None, None, None) if restrict: if info.version < (5, 7): raise CliFail("NFC restriction requires YubiKey 5.7 or later.") config.nfc_restricted = True lock_code = _get_lock_code(info.is_locked, lock_code, force) ctx.obj["session"].write_device_config(config, False, lock_code) click.echo( "YubiKey NFC disabled. It will be re-enabled automatically the next time " "it is connected to USB power." ) ctx.exit() changes = [] nfc_supported = info.supported_capabilities.get(TRANSPORT.NFC) if enable_all: enable = nfc_supported changes.append("Enable all applications") else: enable = CAPABILITY(sum(enable)) if enable: changes.append(f"Enable {enable.display_name}") if disable_all: disable = nfc_supported changes.append("Disable all applications") else: disable = CAPABILITY(sum(disable)) if disable: changes.append(f"Disable {disable.display_name}") _configure_applications( ctx, config, changes, TRANSPORT.NFC, enable, disable, lock_code, force, ) def _list_apps(ctx, transport): enabled = ctx.obj["info"].config.enabled_capabilities.get(transport) if enabled is None: raise CliFail(f"{transport} not supported on this YubiKey.") for app in CAPABILITY: if app & enabled: click.echo(app.display_name) ctx.exit() def _ensure_not_invalid_options(ctx, enable, disable): if enable & disable: raise CliFail("Invalid options.") def _parse_lock_code(lock_code): try: lock_code = bytes.fromhex(lock_code) if lock_code and len(lock_code) != 16: raise CliFail( "Lock code must be exactly 16 bytes (32 hexadecimal digits) long." ) return lock_code except Exception: raise CliFail("Lock code has the wrong format.") # MODE def _parse_interface_string(interface): for iface in USB_INTERFACE: if (iface.name or "").startswith(interface): return iface raise ValueError() def _parse_mode_string(ctx, param, mode): try: mode_int = int(mode) return Mode.from_code(mode_int) except IndexError: raise CliFail(f"Invalid mode: {mode_int}") except ValueError: pass # Not a numeric mode, parse string try: if mode[0] in ["+", "-"]: info = ctx.obj["info"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] interfaces = usb_enabled.usb_interfaces for mod in re.findall(r"[+-][A-Z]+", mode.upper()): interface = _parse_interface_string(mod[1:]) if mod.startswith("+"): interfaces |= interface else: interfaces ^= interface else: interfaces = USB_INTERFACE(0) for t in re.split(r"[+]+", mode.upper()): if t: interfaces |= _parse_interface_string(t) except ValueError: raise CliFail(f"Invalid mode string: {mode}") return Mode(interfaces) @config.command() @click.argument("mode", callback=_parse_mode_string) @click.option( "--touch-eject", is_flag=True, help="when set, the button " "toggles the state of the smartcard between ejected and inserted " "(CCID only)", ) @click.option( "--autoeject-timeout", required=False, type=int, default=0, metavar="SECONDS", help="when set, the smartcard will automatically eject after the given time " "(implies --touch-eject, CCID only)", ) @click.option( "--chalresp-timeout", required=False, type=int, default=0, metavar="SECONDS", help="sets the timeout when waiting for touch for challenge response", ) @click_force_option @click.pass_context def mode(ctx, mode, touch_eject, autoeject_timeout, chalresp_timeout, force): """ Manage connection modes (USB Interfaces). This command is generally used with YubiKeys prior to the 5 series. Use "ykman config usb" for more granular control on YubiKey 5 and later. Get the current connection mode of the YubiKey, or set it to MODE. MODE can be a string, such as "OTP+FIDO+CCID", or a shortened form: "o+f+c". It can also be a mode number. Examples: \b Set the OTP and FIDO mode: $ ykman config mode OTP+FIDO \b Set the CCID only mode and use touch to eject the smart card: $ ykman config mode CCID --touch-eject """ info = ctx.obj["info"] mgmt = ctx.obj["session"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] my_mode = Mode(usb_enabled.usb_interfaces) usb_supported = info.supported_capabilities[TRANSPORT.USB] interfaces_supported = usb_supported.usb_interfaces pid = ctx.obj["pid"] if pid: key_type = pid.yubikey_type else: key_type = None if autoeject_timeout: # autoeject implies touch eject touch_eject = True autoeject = autoeject_timeout if touch_eject else None if mode.interfaces != USB_INTERFACE.CCID: if touch_eject: raise CliFail("--touch-eject can only be used when setting CCID-only mode") if not force: if mode == my_mode: raise CliFail(f"Mode is already {mode}, nothing to do...", 0) elif key_type in (YUBIKEY.YKS, YUBIKEY.YKP): raise CliFail( "Mode switching is not supported on this YubiKey!\n" "Use --force to attempt to set it anyway." ) elif mode.interfaces not in interfaces_supported: raise CliFail( f"Mode {mode} is not supported on this YubiKey!\n" + "Use --force to attempt to set it anyway." ) elif info.is_sky and USB_INTERFACE.FIDO not in mode.interfaces: raise CliFail("Security Key requires FIDO to be enabled.") force or click.confirm(f"Set mode of YubiKey to {mode}?", abort=True, err=True) try: mgmt.set_mode(mode, chalresp_timeout, autoeject) logger.info("USB mode updated") click.echo( "Mode set! You must remove and re-insert your YubiKey " "for this change to take effect." ) except Exception: raise CliFail( "Failed to switch mode on the YubiKey. Make sure your " "YubiKey does not have an access code set." ) yubikey_manager-5.6.1/ykman/_cli/otp.py0000644000175000017500000006546514777516541017504 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from base64 import b32encode from yubikit.yubiotp import ( SLOT, NDEF_TYPE, YubiOtpSession, YubiOtpSlotConfiguration, HmacSha1SlotConfiguration, StaticPasswordSlotConfiguration, HotpSlotConfiguration, UpdateConfiguration, ) from yubikit.core import TRANSPORT, CommandError from yubikit.core.otp import ( MODHEX_ALPHABET, modhex_encode, modhex_decode, OtpConnection, ) from yubikit.core.smartcard import SmartCardConnection from .util import ( CliFail, click_group, click_force_option, click_callback, click_parse_b32_key, click_postpone_execution, click_prompt, prompt_for_touch, EnumChoice, is_yk4_fips, log_or_echo, ) from ..scancodes import encode, KEYBOARD_LAYOUT from ..otp import ( is_in_fips_mode, generate_static_pw, parse_oath_key, parse_b32_key, time_challenge, format_oath_code, format_csv, ) from threading import Event from time import time import logging import os import struct import click logger = logging.getLogger(__name__) def parse_hex(length): @click_callback() def inner(ctx, param, val): val = bytes.fromhex(val) if len(val) != length: raise ValueError(f"Must be exactly {length} bytes.") return val return inner def parse_access_code_hex(access_code_hex): try: access_code = bytes.fromhex(access_code_hex) except TypeError as e: raise ValueError(e) if len(access_code) != 6: raise ValueError("Must be exactly 6 bytes.") return access_code click_slot_argument = click.argument( "slot", type=click.Choice(["1", "2"]), callback=lambda c, p, v: SLOT(int(v)) ) _WRITE_FAIL_MSG = ( "Failed to write to the YubiKey. Make sure the device does not " 'have restricted access (see "ykman otp --help" for more info).' ) def _confirm_slot_overwrite(slot_state, slot): if slot_state.is_configured(slot): click.confirm( f"Slot {slot} is already configured. Overwrite configuration?", abort=True, err=True, ) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[OtpConnection, SmartCardConnection]) @click.pass_context @click_postpone_execution @click.option( "--access-code", required=False, metavar="HEX", help='6 byte access code (use "-" as a value to prompt for input)', ) def otp(ctx, access_code): """ Manage the YubiOTP application. The YubiKey provides two keyboard-based slots which can each be configured with a credential. Several credential types are supported. A slot configuration may be write-protected with an access code. This prevents the configuration to be overwritten without the access code provided. Mode switching the YubiKey is not possible when a slot is configured with an access code. To provide an access code to commands which require it, use the --access-code option. Note that this option must be given directly after the "otp" command, before any sub-command. Examples: \b Swap the configurations between the two slots: $ ykman otp swap \b Program a random challenge-response credential to slot 2: $ ykman otp chalresp --generate 2 \b Program a Yubico OTP credential to slot 1, using the serial as public id: $ ykman otp yubiotp 1 --serial-public-id \b Program a random 38 characters long static password to slot 2: $ ykman otp static --generate 2 --length 38 \b Remove a currently set access code from slot 2): $ ykman otp --access-code 0123456789ab settings 2 --delete-access-code """ """ # TODO: Require OTP for chalresp, or FW < 5.?. Require CCID for HashOTP dev = ctx.obj["device"] if dev.supports_connection(OtpConnection): conn = dev.open_connection(OtpConnection) else: conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) ctx.obj["session"] = YubiOtpSession(conn) """ if access_code is not None: if access_code == "-": access_code = click_prompt("Enter the access code", hide_input=True) try: access_code = parse_access_code_hex(access_code) except Exception as e: ctx.fail(f"Failed to parse access code: {e}") ctx.obj["access_code"] = access_code def _get_session(ctx, types=[OtpConnection, SmartCardConnection]): dev = ctx.obj["device"] resolve_scp = ctx.obj.get("scp") if resolve_scp: if SmartCardConnection in types: types = [SmartCardConnection] else: raise CliFail("SCP can only be used with SmartCardConnection") for conn_type in types: if dev.supports_connection(conn_type): conn = dev.open_connection(conn_type) ctx.call_on_close(conn.close) if resolve_scp: scp_params = resolve_scp(conn) else: scp_params = None return YubiOtpSession(conn, scp_params) raise CliFail( "The connection type required for this command is not supported/enabled on the " "YubiKey." ) @otp.command() @click.pass_context def info(ctx): """ Display general status of the YubiKey OTP slots. """ session = _get_session(ctx) state = session.get_config_state() slot1 = state.is_configured(1) slot2 = state.is_configured(2) click.echo(f"Slot 1: {slot1 and 'programmed' or 'empty'}") click.echo(f"Slot 2: {slot2 and 'programmed' or 'empty'}") if is_yk4_fips(ctx.obj["info"]): click.echo(f"FIPS Approved Mode: {'Yes' if is_in_fips_mode(session) else 'No'}") @otp.command() @click_force_option @click.pass_context def swap(ctx, force): """ Swaps the two slot configurations. """ session = _get_session(ctx) force or click.confirm( "Swap the two slots of the YubiKey?", abort=True, err=True, ) try: session.swap_slots() click.echo("Slots swapped.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.pass_context @click.option("-p", "--prefix", help="added before the NDEF payload, typically a URI") @click.option( "-t", "--ndef-type", type=EnumChoice(NDEF_TYPE), default="URI", show_default=True, help="NDEF payload type", ) def ndef(ctx, slot, prefix, ndef_type): """ Configure a slot to be used over NDEF (NFC). \b If "--prefix" is not specified, a default value will be used, based on the type: - For URI the default value is: "https://my.yubico.com/yk/#" - For TEXT the default is an empty string """ info = ctx.obj["info"] session = _get_session(ctx) state = session.get_config_state() if not info.has_transport(TRANSPORT.NFC): raise CliFail("This YubiKey does not support NFC.") if not state.is_configured(slot): raise CliFail(f"Slot {slot} is empty.") try: session.set_ndef_configuration(slot, prefix, ctx.obj["access_code"], ndef_type) click.echo("NDEF configuration updated.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click_force_option @click.pass_context def delete(ctx, slot, force): """ Deletes the configuration stored in a slot. """ session = _get_session(ctx) state = session.get_config_state() if not force and not state.is_configured(slot): raise CliFail("Not possible to delete an empty slot.") force or click.confirm( f"Do you really want to delete the configuration of slot {slot}?", abort=True, err=True, ) try: session.delete_slot(slot, ctx.obj["access_code"]) click.echo(f"Configuration slot {slot} deleted.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.option( "-P", "--public-id", required=False, help="public identifier prefix", metavar="MODHEX", ) @click.option( "-p", "--private-id", required=False, metavar="HEX", callback=parse_hex(6), help="6 byte private identifier", ) @click.option( "-k", "--key", required=False, metavar="HEX", callback=parse_hex(16), help="16 byte secret key", ) @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after emitting the OTP", ) @click.option( "-S", "--serial-public-id", is_flag=True, required=False, help="use YubiKey serial number as public ID (can't be used with --public-id)", ) @click.option( "-g", "--generate-private-id", is_flag=True, required=False, help="generate a random private ID (can't be used with --private-id)", ) @click.option( "-G", "--generate-key", is_flag=True, required=False, help="generate a random secret key (can't be used with --key)", ) @click.option( "-u", "--upload", is_flag=True, required=False, hidden=True, ) @click.option( "-O", "--config-output", type=click.File("a"), required=False, help="file to output the configuration to (existing file will be appended to)", ) @click_force_option @click.pass_context def yubiotp( ctx, slot, public_id, private_id, key, no_enter, force, serial_public_id, generate_private_id, generate_key, upload, config_output, ): """ Program a Yubico OTP credential. """ session = _get_session(ctx) serial = None if upload: raise CliFail( "Automated YubiCloud upload support has been ended. " "You can manually upload a credential by saving it as a CSV file " "(use -O/--config-output) and then submitting it to " "https://upload.yubico.com." ) if public_id and serial_public_id: ctx.fail("Invalid options: --public-id conflicts with --serial-public-id.") if private_id and generate_private_id: ctx.fail("Invalid options: --private-id conflicts with --generate-public-id.") if key and generate_key: ctx.fail("Invalid options: --key conflicts with --generate-key.") if not public_id: if serial_public_id: try: serial = session.get_serial() except CommandError: raise CliFail("Serial number not set, public ID must be provided.") public_id = modhex_encode(b"\xff\x00" + struct.pack(b">I", serial)) click.echo(f"Using YubiKey serial as public ID: {public_id}") elif force: ctx.fail( "Public ID not given. Remove the --force flag, or " "add the --serial-public-id flag or --public-id option." ) else: public_id = click_prompt("Enter public ID") if len(public_id) % 2: ctx.fail("Invalid public ID, length must be a multiple of 2.") try: public_id = modhex_decode(public_id) except ValueError: ctx.fail(f"Invalid public ID, must be modhex ({MODHEX_ALPHABET}).") if not private_id: if generate_private_id: private_id = os.urandom(6) click.echo(f"Using a randomly generated private ID: {private_id.hex()}") elif force: ctx.fail( "Private ID not given. Remove the --force flag, or " "add the --generate-private-id flag or --private-id option." ) else: private_id = click_prompt("Enter private ID") private_id = bytes.fromhex(private_id) if not key: if generate_key: key = os.urandom(16) click.echo(f"Using a randomly generated secret key: {key.hex()}") elif force: ctx.fail( "Secret key not given. Remove the --force flag, or " "add the --generate-key flag or --key option." ) else: key = click_prompt("Enter secret key") key = bytes.fromhex(key) force or click.confirm( f"Program a YubiOTP credential in slot {slot}?", abort=True, err=True ) access_code = ctx.obj["access_code"] try: session.put_configuration( slot, YubiOtpSlotConfiguration(public_id, private_id, key).append_cr( not no_enter ), access_code, access_code, ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) if config_output: serial = serial or session.get_serial() csv = format_csv(serial, public_id, private_id, key, access_code) config_output.write(csv + "\n") log_or_echo( f"Configuration parameters written to {_fname(config_output)}", logger, config_output, ) @otp.command() @click_slot_argument @click.argument("password", required=False) @click.option("-g", "--generate", is_flag=True, help="generate a random password") @click.option( "-l", "--length", metavar="LENGTH", type=click.IntRange(1, 38), default=38, show_default=True, help="length of generated password", ) @click.option( "-k", "--keyboard-layout", type=EnumChoice(KEYBOARD_LAYOUT), default="MODHEX", show_default=True, help="keyboard layout to use for the static password", ) @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after outputting the password", ) @click_force_option @click.pass_context def static(ctx, slot, password, generate, length, keyboard_layout, no_enter, force): """ Configure a static password. To avoid problems with different keyboard layouts, the following characters (upper and lower case) are allowed by default: cbdefghijklnrtuv Use the --keyboard-layout option to allow more characters based on preferred keyboard layout. """ session = _get_session(ctx) if password and len(password) > 38: ctx.fail("Password too long (maximum length is 38 characters).") if generate and not length: ctx.fail("Provide a length for the generated password.") if not password and not generate: password = click_prompt("Enter a static password") elif not password and generate: password = generate_static_pw(length, keyboard_layout) scan_codes = encode(password, keyboard_layout) if not force: _confirm_slot_overwrite(session.get_config_state(), slot) try: session.put_configuration( slot, StaticPasswordSlotConfiguration(scan_codes).append_cr(not no_enter), ctx.obj["access_code"], ctx.obj["access_code"], ) click.echo(f"Static password stored in slot {slot}.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.argument("key", required=False) @click.option( "-t", "--touch", is_flag=True, help="require touch on the YubiKey to generate a response", ) @click.option( "-T", "--totp", is_flag=True, required=False, help="use a base32 encoded key (optionally padded) for TOTP credentials", ) @click.option( "-g", "--generate", is_flag=True, required=False, help="generate a random secret key (can't be used with KEY argument)", ) @click_force_option @click.pass_context def chalresp(ctx, slot, key, totp, touch, force, generate): """ Program a challenge-response credential. If KEY is not given, an interactive prompt will ask for it. \b KEY a key given in hex (or base32, if --totp is specified) """ session = _get_session(ctx) if key: if generate: ctx.fail("Invalid options: --generate conflicts with KEY argument.") elif totp: key = parse_b32_key(key) else: key = parse_oath_key(key) else: if force and not generate: ctx.fail( "No secret key given. Remove the --force flag, " "set the KEY argument or set the --generate flag." ) elif generate: key = os.urandom(20) if totp: b32key = b32encode(key).decode() click.echo(f"Using a randomly generated key (base32): {b32key}") else: click.echo(f"Using a randomly generated key (hex): {key.hex()}") elif totp: while True: key = click_prompt("Enter a secret key (base32)") try: key = parse_b32_key(key) break except Exception as e: click.echo(e) else: key = click_prompt("Enter a secret key") key = parse_oath_key(key) cred_type = "TOTP" if totp else "challenge-response" force or click.confirm( f"Program a {cred_type} credential in slot {slot}?", abort=True, err=True, ) try: session.put_configuration( slot, HmacSha1SlotConfiguration(key).require_touch(touch), ctx.obj["access_code"], ctx.obj["access_code"], ) click.echo(f"{cred_type} credential stored in slot {slot}.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.argument("challenge", required=False) @click.option( "-T", "--totp", is_flag=True, help="generate a TOTP code, use the current time if challenge is omitted", ) @click.option( "-d", "--digits", type=click.Choice(["6", "8"]), default="6", help="number of digits in generated TOTP code (default: 6), " "ignored unless --totp is set", ) @click.pass_context def calculate(ctx, slot, challenge, totp, digits): """ Perform a challenge-response operation. Send a challenge (in hex) to a YubiKey slot with a challenge-response credential, and read the response. Supports output as a OATH-TOTP code. """ dev = ctx.obj["device"] if dev.transport == TRANSPORT.NFC: session = _get_session(ctx, [SmartCardConnection]) else: # Calculate over USB is only available over OtpConnection session = _get_session(ctx, [OtpConnection]) if not challenge and not totp: challenge = click_prompt("Enter a challenge (hex)") # Check that slot is not empty if not session.get_config_state().is_configured(slot): raise CliFail("Cannot perform challenge-response on an empty slot.") if totp: # Challenge omitted or timestamp if challenge is None: challenge = time_challenge(int(time())) else: try: challenge = time_challenge(int(challenge)) except Exception: logger.exception("Error parsing challenge") ctx.fail("Timestamp challenge for TOTP must be an integer.") else: # Challenge is hex challenge = bytes.fromhex(challenge) try: event = Event() def on_keepalive(status): if not hasattr(on_keepalive, "prompted") and status == 2: prompt_for_touch() setattr(on_keepalive, "prompted", True) response = session.calculate_hmac_sha1(slot, challenge, event, on_keepalive) if totp: value = format_oath_code(response, int(digits)) else: value = response.hex() click.echo(value) except CommandError: raise CliFail(_WRITE_FAIL_MSG) def parse_modhex_or_bcd(value): try: return True, modhex_decode(value) except ValueError: try: int(value) return False, bytes.fromhex(value) except ValueError: raise ValueError("value must be modhex or decimal") @otp.command() @click_slot_argument @click.argument("key", callback=click_parse_b32_key, required=False) @click.option( "-d", "--digits", type=click.Choice(["6", "8"]), default="6", help="number of digits in generated code (default is 6)", ) @click.option("-c", "--counter", type=int, default=0, help="initial counter value") @click.option("-i", "--identifier", help="token identifier") @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after outputting the code", ) @click_force_option @click.pass_context def hotp(ctx, slot, key, digits, counter, identifier, no_enter, force): """ Program an HMAC-SHA1 OATH-HOTP credential. The YubiKey can be configured to output an OATH Token Identifier as a prefix to the OTP itself, which consists of OMP+TT+MUI. Using the "--identifier" option, you may specify the OMP+TT as 4 characters, the MUI as 8 characters, or the full OMP+TT+MUI as 12 characters. If omitted, a default value of "ubhe" will be used for OMP+TT, and the YubiKey serial number will be used as MUI. """ session = _get_session(ctx) mh1 = False mh2 = False if identifier: if identifier == "-": identifier = "ubhe" if len(identifier) == 4: identifier += f"{session.get_serial():08}" elif len(identifier) == 8: identifier = "ubhe" + identifier if len(identifier) != 12: raise ValueError("Incorrect length for token identifier.") omp_m, omp = parse_modhex_or_bcd(identifier[:2]) tt_m, tt = parse_modhex_or_bcd(identifier[2:4]) mui_m, mui = parse_modhex_or_bcd(identifier[4:]) if tt_m and not omp_m: raise ValueError("TT can only be modhex encoded if OMP is as well.") if mui_m and not (omp_m and tt_m): raise ValueError( "MUI can only be modhex encoded if OMP and TT are as well." ) token_id = omp + tt + mui if mui_m: mh1 = mh2 = True elif tt_m: mh2 = True elif omp_m: mh1 = True else: token_id = b"" if not key: while True: key = click_prompt("Enter a secret key (base32)") try: key = parse_b32_key(key) break except Exception as e: click.echo(e) force or click.confirm( f"Program a HOTP credential in slot {slot}?", abort=True, err=True ) try: session.put_configuration( slot, HotpSlotConfiguration(key) .imf(counter) .token_id(token_id, mh1, mh2) .digits8(int(digits) == 8) .append_cr(not no_enter), ctx.obj["access_code"], ctx.obj["access_code"], ) click.echo(f"HOTP credential stored in slot {slot}.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click_force_option @click.pass_context @click.option( "-A", "--new-access-code", metavar="HEX", required=False, help='a new 6 byte access code to set (use "-" as a value to prompt for input)', ) @click.option( "--delete-access-code", is_flag=True, help="remove access code from the slot" ) @click.option( "--enter/--no-enter", default=True, show_default=True, help="send an Enter keystroke after slot output", ) @click.option( "-p", "--pacing", type=click.Choice(["0", "20", "40", "60"]), default="0", show_default=True, help="throttle output speed by adding a delay (in ms) between characters emitted", ) @click.option( "--use-numeric-keypad", is_flag=True, show_default=True, help="use scancodes for numeric keypad when sending digits " "(helps for some keyboard layouts)", ) def settings( ctx, slot, new_access_code, delete_access_code, enter, pacing, use_numeric_keypad, force, ): """ Update the settings for a slot. Change the settings for a slot without changing the stored secret. All settings not specified will be written with default values. """ session = _get_session(ctx) if new_access_code and delete_access_code: ctx.fail("--new-access-code conflicts with --delete-access-code.") if delete_access_code and not ctx.obj["access_code"]: raise CliFail( "--delete-access-code used without providing an access code " '(see "ykman otp --help" for more info).' ) if not session.get_config_state().is_configured(slot): raise CliFail("Not possible to update settings on an empty slot.") if new_access_code is None: if not delete_access_code: new_access_code = ctx.obj["access_code"] else: if new_access_code == "-": new_access_code = click_prompt( "Enter new access code", hide_input=True, confirmation_prompt=True ) try: new_access_code = parse_access_code_hex(new_access_code) except Exception as e: ctx.fail("Failed to parse access code: " + str(e)) if ctx.obj["info"].pin_complexity and len(set(new_access_code)) < 2: raise CliFail("Access code does not meet complexity requirement.") force or click.confirm( f"Update the settings for slot {slot}? " "All existing settings will be overwritten.", abort=True, err=True, ) pacing_bits = int(pacing or "0") // 20 pacing_10ms = bool(pacing_bits & 1) pacing_20ms = bool(pacing_bits & 2) try: session.update_configuration( slot, UpdateConfiguration() .append_cr(enter) .use_numeric(use_numeric_keypad) .pacing(pacing_10ms, pacing_20ms), new_access_code, ctx.obj["access_code"], ) click.echo(f"Settings for slot {slot} updated.") except CommandError: raise CliFail(_WRITE_FAIL_MSG) yubikey_manager-5.6.1/ykman/_cli/info.py0000644000175000017500000001644414777516541017626 0ustar winniewinnie# Copyright (c) 2016 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.management import CAPABILITY, USB_INTERFACE from yubikit.yubiotp import YubiOtpSession from yubikit.oath import OathSession from yubikit.support import get_name from .util import CliFail, is_yk4_fips, click_command, pretty_print from ..otp import is_in_fips_mode as otp_in_fips_mode from ..oath import is_in_fips_mode as oath_in_fips_mode from ..fido import is_in_fips_mode as ctap_in_fips_mode from typing import List import click import logging logger = logging.getLogger(__name__) def print_app_status_table(supported_apps, enabled_apps): usb_supported = supported_apps.get(TRANSPORT.USB, 0) usb_enabled = enabled_apps.get(TRANSPORT.USB, 0) nfc_supported = supported_apps.get(TRANSPORT.NFC, 0) nfc_enabled = enabled_apps.get(TRANSPORT.NFC, 0) rows = [] for app in CAPABILITY: if app & usb_supported: if app & usb_enabled: usb_status = "Enabled" else: usb_status = "Disabled" else: usb_status = "Not available" if nfc_supported: if app & nfc_supported: if app & nfc_enabled: nfc_status = "Enabled" else: nfc_status = "Disabled" else: nfc_status = "Not available" rows.append([app.display_name, usb_status, nfc_status]) else: rows.append([app.display_name, usb_status]) column_l: List[int] = [] for row in rows: for idx, c in enumerate(row): if len(column_l) > idx: if len(c) > column_l[idx]: column_l[idx] = len(c) else: column_l.append(len(c)) f_apps = "Applications".ljust(column_l[0]) if nfc_supported: f_USB = "USB".ljust(column_l[1]) f_NFC = "NFC".ljust(column_l[2]) f_table = "" for row in rows: for idx, c in enumerate(row): f_table += f"{c.ljust(column_l[idx])}\t" f_table = f_table.strip() + "\n" if nfc_supported: click.echo(f"{f_apps}\t{f_USB}\t{f_NFC}") else: click.echo(f"{f_apps}") click.echo(f_table, nl=False) def get_overall_fips_status(device, info): statuses = {} usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] statuses["OTP"] = False if usb_enabled & CAPABILITY.OTP: with device.open_connection(OtpConnection) as conn: otp_app = YubiOtpSession(conn) statuses["OTP"] = otp_in_fips_mode(otp_app) statuses["OATH"] = False if usb_enabled & CAPABILITY.OATH: with device.open_connection(SmartCardConnection) as conn: oath_app = OathSession(conn) statuses["OATH"] = oath_in_fips_mode(oath_app) statuses["FIDO U2F"] = False if usb_enabled & CAPABILITY.U2F: with device.open_connection(FidoConnection) as conn: statuses["FIDO U2F"] = ctap_in_fips_mode(conn) return statuses def _check_fips_status(device, info): fips_status = get_overall_fips_status(device, info) click.echo(f"FIPS Approved Mode: {'Yes' if all(fips_status.values()) else 'No'}") status_keys = list(fips_status.keys()) status_keys.sort() for status_key in status_keys: click.echo(f" {status_key}: {'Yes' if fips_status[status_key] else 'No'}") @click.option( "-c", "--check-fips", help="check if YubiKey is in FIPS Approved mode (4 Series only)", is_flag=True, ) @click_command(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context def info(ctx, check_fips): """ Show general information. Displays information about the attached YubiKey such as serial number, firmware version, capabilities, etc. """ info = ctx.obj["info"] pid = ctx.obj["pid"] if pid is None: interfaces = None key_type = None else: interfaces = pid.usb_interfaces key_type = pid.yubikey_type device_name = get_name(info, key_type) click.echo(f"Device type: {device_name}") if info.serial: click.echo(f"Serial number: {info.serial}") if info.version: f_version = ".".join(str(x) for x in info.version) click.echo(f"Firmware version: {f_version}") else: click.echo( "Firmware version: Uncertain, re-run with only one YubiKey connected" ) if info.form_factor: click.echo(f"Form factor: {info.form_factor!s}") if interfaces: f_interfaces = ", ".join( t.name or str(t) for t in USB_INTERFACE if t in USB_INTERFACE(interfaces) ) click.echo(f"Enabled USB interfaces: {f_interfaces}") if TRANSPORT.NFC in info.supported_capabilities: if info.config.nfc_restricted: f_nfc = "restricted" elif info.config.enabled_capabilities.get(TRANSPORT.NFC): f_nfc = "enabled" else: f_nfc = "disabled" click.echo(f"NFC transport is {f_nfc}") if info.pin_complexity: click.echo("PIN complexity is enforced") if info.is_locked: click.echo("Configured capabilities are protected by a lock code") click.echo() print_app_status_table( info.supported_capabilities, info.config.enabled_capabilities ) if info.fips_capable: click.echo() click.echo("FIPS approved applications") data = { c.display_name: c in info.fips_approved for c in CAPABILITY if c in info.fips_capable } click.echo("\n".join(pretty_print(data))) if check_fips: click.echo() if is_yk4_fips(info): device = ctx.obj["device"] _check_fips_status(device, info) else: raise CliFail( "Unable to check FIPS Approved mode - Not a YubiKey FIPS (4 Series)" ) yubikey_manager-5.6.1/ykman/_cli/fido.py0000755000175000017500000007523414777516541017621 0ustar winniewinnie# Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from fido2.ctap import CtapError from fido2.ctap1 import ApduError from fido2.ctap2 import ( Ctap2, ClientPin, CredentialManagement, FPBioEnrollment, CaptureError, Config, ) from fido2.pcsc import CtapPcscDevice from yubikit.management import CAPABILITY from yubikit.core import TRANSPORT from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SW from time import sleep from .util import ( click_postpone_execution, click_prompt, click_force_option, click_group, prompt_timeout, is_yk4_fips, pretty_print, ) from .util import CliFail from ..fido import is_in_fips_mode, fips_reset, fips_change_pin, fips_verify_pin from ..hid import list_ctap_devices from ..pcsc import list_devices as list_ccid from smartcard.Exceptions import NoCardException, CardConnectionException from typing import Optional, Sequence, List, Dict import io import csv as _csv import click import logging logger = logging.getLogger(__name__) @click_group(connections=[FidoConnection]) @click.pass_context @click_postpone_execution def fido(ctx): """ Manage the FIDO applications. Examples: \b Reset the FIDO (FIDO2 and U2F) applications: $ ykman fido reset \b Change the FIDO2 PIN from 123456 to 654321: $ ykman fido access change-pin --pin 123456 --new-pin 654321 """ dev = ctx.obj["device"] conn = dev.open_connection(FidoConnection) ctx.call_on_close(conn.close) ctx.obj["conn"] = conn try: ctx.obj["ctap2"] = Ctap2(conn) except (ValueError, CtapError): logger.info("FIDO device does not support CTAP2", exc_info=True) @fido.command() @click.pass_context def info(ctx): """ Display general status of the FIDO2 application. """ info = ctx.obj["info"] ctap2 = ctx.obj.get("ctap2") data: Dict = {} lines: List = [data] if CAPABILITY.FIDO2 in info.fips_capable: data["FIPS approved"] = CAPABILITY.FIDO2 in info.fips_approved elif is_yk4_fips(info): data["FIPS approved"] = is_in_fips_mode(ctx.obj["conn"]) if ctap2: if ctap2.info.aaguid: data["AAGUID"] = str(ctap2.info.aaguid) client_pin = ClientPin(ctap2) # N.B. All YubiKeys with CTAP2 support PIN. if ctap2.info.options["clientPin"]: if ctap2.info.force_pin_change: lines.append( "NOTE: The FIDO PIN is disabled and must be changed before it can " "be used!" ) pin_retries, power_cycle = client_pin.get_pin_retries() if pin_retries: data["PIN"] = f"{pin_retries} attempt(s) remaining" if power_cycle: lines.append( "PIN is temporarily blocked. " "Remove and re-insert the YubiKey to unblock." ) else: data["PIN"] = "Blocked" else: data["PIN"] = "Not set" data["Minimum PIN length"] = ctap2.info.min_pin_length bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: uv_retries = client_pin.get_uv_retries() if uv_retries: data["Fingerprints"] = f"Registered, {uv_retries} attempt(s) remaining" else: data["Fingerprints"] = "Registered, blocked until PIN is verified" elif bio_enroll is False: data["Fingerprints"] = "Not registered" always_uv = ctap2.info.options.get("alwaysUv") if always_uv is not None: data["Always Require UV"] = "On" if always_uv else "Off" remaining_creds = ctap2.info.remaining_disc_creds if remaining_creds is not None: data["Credential storage remaining"] = remaining_creds ep = ctap2.info.options.get("ep") if ep is not None: data["Enterprise Attestation"] = "Enabled" if ep else "Disabled" else: data["PIN"] = "Not supported" click.echo("\n".join(pretty_print(lines))) @fido.command("reset") @click_force_option @click.pass_context def reset(ctx, force): """ Reset all FIDO applications. This action will wipe all FIDO credentials, including FIDO U2F credentials, on the YubiKey and remove the PIN code. The reset must be triggered immediately after the YubiKey is inserted, and requires a touch on the YubiKey. """ info = ctx.obj["info"] if CAPABILITY.FIDO2 in info.reset_blocked: raise CliFail( "Cannot perform FIDO reset when PIV is configured, " "use 'ykman config reset' for full factory reset." ) conn = ctx.obj["conn"] if isinstance(conn, CtapPcscDevice): # NFC readers = list_ccid(conn._name) if not readers or readers[0].reader.name != conn._name: raise CliFail("Unable to isolate NFC reader.") dev = readers[0] is_fips = False def prompt_re_insert(): click.echo( "Remove and re-place your YubiKey on the NFC reader to perform the " "reset..." ) removed = False while True: sleep(0.5) try: with dev.open_connection(FidoConnection): if removed: sleep(1.0) # Wait for the device to settle break except CardConnectionException: pass # Expected, ignore except NoCardException: removed = True return dev.open_connection(FidoConnection) else: # USB n_keys = len(list_ctap_devices()) if n_keys > 1: raise CliFail("Only one YubiKey can be connected to perform a reset.") is_fips = is_yk4_fips(ctx.obj["info"]) ctap2 = ctx.obj.get("ctap2") if not is_fips and not ctap2: raise CliFail("This YubiKey does not support FIDO reset.") def prompt_re_insert(): click.echo("Remove and re-insert your YubiKey to perform the reset...") removed = False while True: sleep(0.5) keys = list_ctap_devices() if not keys: removed = True if removed and len(keys) == 1: return keys[0].open_connection(FidoConnection) if not force: click.confirm( "WARNING! This will delete all FIDO credentials, including FIDO U2F " "credentials, and restore factory settings. Proceed?", err=True, abort=True, ) if is_fips: destroy_input = click_prompt( "WARNING! This is a YubiKey FIPS (4 Series) device. This command will " "also overwrite the U2F attestation key; this action cannot be undone " "and this YubiKey will no longer be a FIPS compliant device.\n" 'To proceed, enter the text "OVERWRITE"', default="", show_default=False, ) if destroy_input != "OVERWRITE": raise CliFail("Reset aborted by user.") conn = prompt_re_insert() try: with prompt_timeout(): if is_fips: fips_reset(conn) else: Ctap2(conn).reset() click.echo("FIDO application data reset.") except CtapError as e: if e.code == CtapError.ERR.ACTION_TIMEOUT: raise CliFail( "Reset failed. You need to touch your YubiKey to confirm the reset." ) elif e.code in (CtapError.ERR.NOT_ALLOWED, CtapError.ERR.PIN_AUTH_BLOCKED): raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: raise CliFail(f"Reset failed: {e.code.name}.") except ApduError as e: # From fips_reset if e.code == SW.COMMAND_NOT_ALLOWED: raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: raise CliFail("Reset failed.") except Exception: raise CliFail("Reset failed.") def _fail_pin_error(ctx, e, other="%s"): if e.code == CtapError.ERR.PIN_INVALID: raise CliFail("Wrong PIN.") elif e.code == CtapError.ERR.PIN_AUTH_BLOCKED: raise CliFail( "PIN authentication is currently blocked. " "Remove and re-insert the YubiKey." ) elif e.code == CtapError.ERR.PIN_BLOCKED: raise CliFail("PIN is blocked.") else: raise CliFail(other % e.code) @fido.group("access") def access(): """ Manage the PIN for FIDO. """ @access.command("change-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") @click.option("-n", "--new-pin", help="a new PIN") @click.option( "-u", "--u2f", is_flag=True, help="set FIDO U2F PIN instead of FIDO2 PIN (YubiKey FIPS only)", ) def change_pin(ctx, pin, new_pin, u2f): """ Set or change the PIN code. The FIDO2 PIN must be at least 4 characters long, and supports any type of alphanumeric characters. Some YubiKeys can be configured to require a longer PIN. On YubiKey FIPS (4 Series), a PIN can be set for FIDO U2F. That PIN must be at least 6 characters long. """ info = ctx.obj["info"] is_fips = is_yk4_fips(info) if is_fips and not u2f: raise CliFail( "This is a YubiKey FIPS (4 Series). " "To set the U2F PIN, pass the --u2f option." ) if u2f and not is_fips: raise CliFail( "This is not a YubiKey FIPS (4 Series), and therefore does not support a " "U2F PIN. To set the FIDO2 PIN, remove the --u2f option." ) if is_fips: conn = ctx.obj["conn"] min_len = 6 else: ctap2 = ctx.obj.get("ctap2") if not ctap2: raise CliFail("PIN is not supported on this YubiKey.") client_pin = ClientPin(ctap2) min_len = ctap2.info.min_pin_length if ( info._is_bio and CAPABILITY.PIV in info.config.enabled_capabilities[TRANSPORT.USB] ): max_len = 8 else: max_len = 63 def _fail_if_not_valid_pin(pin=None, name="PIN"): if not pin or len(pin) < min_len: raise CliFail(f"{name} must be at least {min_len} characters long.") if len(pin) > max_len: raise CliFail(f"{name} must be at most {max_len} characters long.") def prompt_new_pin(): return click_prompt( "Enter your new PIN", hide_input=True, confirmation_prompt=True, ) def change_pin(pin, new_pin): try: if is_fips: try: # Failing this with empty current PIN does not cost a retry fips_change_pin(conn, pin or "", new_pin) except ApduError as e: if e.code == SW.WRONG_LENGTH: pin = _prompt_current_pin() _fail_if_not_valid_pin(pin) fips_change_pin(conn, pin, new_pin) else: raise else: client_pin.change_pin(pin, new_pin) except CtapError as e: if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: raise CliFail("New PIN doesn't meet complexity requirements.") else: _fail_pin_error(ctx, e, "Failed to change PIN: %s.") except ApduError as e: if e.code == SW.VERIFY_FAIL_NO_RETRY: raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: raise CliFail("PIN is blocked.") else: raise CliFail(f"Failed to change PIN: SW={e.code:04x}.") def set_pin(new_pin): try: client_pin.set_pin(new_pin) except CtapError as e: if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: raise CliFail("New PIN doesn't meet complexity requirements.") else: raise CliFail(f"Failed to set PIN: {e.code}.") if not is_fips: if ctap2.info.options.get("clientPin"): if not pin: pin = _prompt_current_pin() else: if pin: raise CliFail("There is no current PIN set. Use --new-pin to set one.") if not new_pin: new_pin = prompt_new_pin() _fail_if_not_valid_pin(new_pin, "New PIN") if is_fips: change_pin(pin, new_pin) else: if ctap2.info.options.get("clientPin"): change_pin(pin, new_pin) else: set_pin(new_pin) click.echo("FIDO PIN updated.") def _require_pin(ctx, pin, feature="This feature"): ctap2 = ctx.obj.get("ctap2") if not ctap2: raise CliFail(f"{feature} is not supported on this YubiKey.") if not ctap2.info.options.get("clientPin"): raise CliFail(f"{feature} requires having a PIN. Set a PIN first.") if ctap2.info.force_pin_change: raise CliFail("The FIDO PIN is blocked. Change the PIN first.") if pin is None: pin = _prompt_current_pin(prompt="Enter your PIN") return pin @access.command("verify-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") def verify(ctx, pin): """ Verify the FIDO PIN against a YubiKey. For YubiKeys supporting FIDO2 this will reset the "retries" counter of the PIN. For YubiKey FIPS (4 Series) this will unlock the session, allowing U2F registration. """ ctap2 = ctx.obj.get("ctap2") if ctap2: pin = _require_pin(ctx, pin) client_pin = ClientPin(ctap2) try: # Get a PIN token to verify the PIN. client_pin.get_pin_token( pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" ) except CtapError as e: raise CliFail(f"PIN verification failed: {e}.") elif is_yk4_fips(ctx.obj["info"]): try: fips_verify_pin(ctx.obj["conn"], pin) except ApduError as e: if e.code == SW.VERIFY_FAIL_NO_RETRY: raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: raise CliFail("PIN is blocked.") elif e.code == SW.COMMAND_NOT_ALLOWED: raise CliFail("PIN is not set.") else: raise CliFail(f"PIN verification failed: {e.code.name}.") else: raise CliFail("This YubiKey does not support a FIDO PIN.") click.echo("PIN verified.") def _init_config(ctx, pin): ctap2 = ctx.obj.get("ctap2") if not Config.is_supported(ctap2.info): raise CliFail("Authenticator Configuration is not supported on this YubiKey.") protocol = None token = None if ctap2.info.options.get("clientPin"): pin = _require_pin(ctx, pin, "Authenticator Configuration") client_pin = ClientPin(ctap2) try: protocol = client_pin.protocol token = client_pin.get_pin_token( pin, ClientPin.PERMISSION.AUTHENTICATOR_CFG ) except CtapError as e: _fail_pin_error(ctx, e, "PIN error: %s.") return Config(ctap2, protocol, token) @access.command("force-change") @click.pass_context @click.option("-P", "--pin", help="PIN code") def force_pin_change(ctx, pin): """ Force the PIN to be changed to a new value before use. """ options = ctx.obj["ctap2"].info.options if "ctap2" in ctx.obj else None if options is None or not options.get("setMinPINLength"): raise CliFail("Force change PIN is not supported on this YubiKey.") if not options.get("clientPin"): raise CliFail("No PIN is set.") config = _init_config(ctx, pin) config.set_min_pin_length(force_change_pin=True) click.echo("Force PIN change set.") @access.command("set-min-length") @click.pass_context @click.option("-P", "--pin", help="PIN code") @click.option("-R", "--rp-id", multiple=True, help="RP ID to allow") @click.argument("length", type=click.IntRange(4, 63)) def set_min_pin_length(ctx, pin, rp_id, length): """ Set the minimum length allowed for PIN. Optionally use the --rp-id option to specify which RPs are allowed to request this information. """ info = ctx.obj["ctap2"].info if "ctap2" in ctx.obj else None if info is None or not info.options.get("setMinPINLength"): raise CliFail("Set minimum PIN length is not supported on this YubiKey.") if info.options.get("alwaysUv") and not info.options.get("clientPin"): raise CliFail( "Setting min PIN length requires a PIN to be set when alwaysUv is enabled." ) min_len = info.min_pin_length if length < min_len: raise CliFail(f"Cannot set a minimum length that is shorter than {min_len}.") dev_info = ctx.obj["info"] if ( dev_info._is_bio and CAPABILITY.PIV in dev_info.config.enabled_capabilities[TRANSPORT.USB] and length > 8 ): raise CliFail("Cannot set a minimum length that is longer than 8.") config = _init_config(ctx, pin) if rp_id: ctap2 = ctx.obj.get("ctap2") cap = ctap2.info.max_rpids_for_min_pin if len(rp_id) > cap: raise CliFail( f"Authenticator supports up to {cap} RP IDs ({len(rp_id)} given)." ) config.set_min_pin_length(min_pin_length=length, rp_ids=rp_id) click.echo("Minimum PIN length set.") def _prompt_current_pin(prompt="Enter your current PIN"): return click_prompt(prompt, hide_input=True) def _gen_creds(credman): data = credman.get_metadata() if data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT) == 0: return # No credentials for rp in credman.enumerate_rps(): for cred in credman.enumerate_creds(rp[CredentialManagement.RESULT.RP_ID_HASH]): yield ( rp[CredentialManagement.RESULT.RP]["id"], cred[CredentialManagement.RESULT.CREDENTIAL_ID], cred[CredentialManagement.RESULT.USER]["id"], cred[CredentialManagement.RESULT.USER].get("name", ""), cred[CredentialManagement.RESULT.USER].get("displayName", ""), ) def _format_table(headings: Sequence[str], rows: List[Sequence[str]]) -> str: all_rows = [headings] + rows padded_rows = [["" for cell in row] for row in all_rows] max_cols = max(len(row) for row in all_rows) for c in range(max_cols): max_width = max(len(row[c]) for row in all_rows if len(row) > c) for r in range(len(all_rows)): if c < len(all_rows[r]): padded_rows[r][c] = all_rows[r][c] + ( " " * (max_width - len(all_rows[r][c])) ) return "\n".join(" ".join(row) for row in padded_rows) def _format_cred(rp_id, user_id, user_name): return f"{rp_id} {user_id.hex()} {user_name}" @fido.group("credentials") def creds(): """ Manage discoverable (resident) credentials. This command lets you manage credentials stored on your YubiKey. Credential management is only available when a FIDO PIN is set on the YubiKey. \b Examples: \b List credentials (providing PIN via argument): $ ykman fido credentials list --pin 123456 \b Delete a credential (ID shown in "list" output, PIN will be prompted for): $ ykman fido credentials delete da7fdc """ def _init_credman(ctx, pin): pin = _require_pin(ctx, pin, "Credential Management") ctap2 = ctx.obj.get("ctap2") client_pin = ClientPin(ctap2) try: token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.CREDENTIAL_MGMT) except CtapError as e: _fail_pin_error(ctx, e, "PIN error: %s.") return CredentialManagement(ctap2, client_pin.protocol, token) @creds.command("list") @click.pass_context @click.option("-P", "--pin", help="PIN code") @click.option( "-c", "--csv", is_flag=True, help="output full credential information as CSV", ) def creds_list(ctx, pin, csv): """ List credentials. Shows a list of credentials stored on the YubiKey. The --csv flag will output more complete information about each credential, formatted as a CSV (comma separated values). """ credman = _init_credman(ctx, pin) creds = list(_gen_creds(credman)) if csv: buf = io.StringIO() writer = _csv.writer(buf) writer.writerow( ["credential_id", "rp_id", "user_name", "user_display_name", "user_id"] ) writer.writerows( [cred_id["id"].hex(), rp_id, user_name, display_name, user_id.hex()] for rp_id, cred_id, user_id, user_name, display_name in creds ) click.echo(buf.getvalue()) else: ln = 4 while len(set(c[1]["id"][:ln] for c in creds)) < len(creds): ln += 1 click.echo( _format_table( ["Credential ID", "RP ID", "Username", "Display name"], [ (cred_id["id"][:ln].hex() + "...", rp_id, user_name, display_name) for rp_id, cred_id, _, user_name, display_name in creds ], ) ) @creds.command("delete") @click.pass_context @click.argument("credential_id") @click.option("-P", "--pin", help="PIN code") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") def creds_delete(ctx, credential_id, pin, force): """ Delete a credential. List stored credential IDs using the "list" subcommand. \b CREDENTIAL_ID a unique substring match of a Credential ID """ credman = _init_credman(ctx, pin) credential_id = credential_id.rstrip(".").lower() hits = [ (rp_id, cred_id, user_name, display_name) for (rp_id, cred_id, _, user_name, display_name) in _gen_creds(credman) if cred_id["id"].hex().startswith(credential_id) ] if len(hits) == 0: raise CliFail("No matches, nothing to be done.") elif len(hits) == 1: (rp_id, cred_id, user_name, display_name) = hits[0] if force or click.confirm( f"Delete {rp_id} {user_name} {display_name} ({cred_id['id'].hex()})?" ): try: credman.delete_cred(cred_id) click.echo("Credential deleted.") except CtapError: raise CliFail("Failed to delete credential.") else: raise CliFail("Multiple matches, make the credential ID more specific.") @fido.group("fingerprints") def bio(): """ Manage fingerprints. Requires a YubiKey with fingerprint sensor. Fingerprint management is only available when a FIDO PIN is set on the YubiKey. \b Examples: \b Register a new fingerprint (providing PIN via argument): $ ykman fido fingerprints add "Left thumb" --pin 123456 \b List already stored fingerprints (providing PIN via argument): $ ykman fido fingerprints list --pin 123456 \b Delete a stored fingerprint with ID "f691" (PIN will be prompted for): $ ykman fido fingerprints delete f691 """ def _init_bio(ctx, pin): ctap2 = ctx.obj.get("ctap2") if not ctap2 or "bioEnroll" not in ctap2.info.options: raise CliFail("Biometrics is not supported on this YubiKey.") pin = _require_pin(ctx, pin, "Biometrics") client_pin = ClientPin(ctap2) try: token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.BIO_ENROLL) except CtapError as e: _fail_pin_error(ctx, e, "PIN error: %s.") return FPBioEnrollment(ctap2, client_pin.protocol, token) def _format_fp(template_id, name): return f"{template_id.hex()}{f' ({name})' if name else ''}" @bio.command("list") @click.pass_context @click.option("-P", "--pin", help="PIN code") def bio_list(ctx, pin): """ List registered fingerprints. Lists fingerprints by ID and (if available) label. """ bio = _init_bio(ctx, pin) for t_id, name in bio.enumerate_enrollments().items(): click.echo(f"ID: {_format_fp(t_id, name)}") @bio.command("add") @click.pass_context @click.argument("name") @click.option("-P", "--pin", help="PIN code") def bio_enroll(ctx, name, pin): """ Add a new fingerprint. \b NAME a short readable name for the fingerprint (eg. "Left thumb") """ if len(name.encode()) > 15: ctx.fail("Fingerprint name must be a maximum of 15 characters") bio = _init_bio(ctx, pin) enroller = bio.enroll() template_id = None while template_id is None: click.echo("Place your finger against the sensor now...") try: template_id = enroller.capture() remaining = enroller.remaining if remaining: click.echo(f"{remaining} more scans needed.") except CaptureError as e: logger.debug(f"Capture error: {e.code}") click.echo("Capture failed. Re-center your finger, and try again.") except CtapError as e: if e.code == CtapError.ERR.FP_DATABASE_FULL: raise CliFail( "Fingerprint storage full. " "Remove some fingerprints before adding new ones." ) elif e.code == CtapError.ERR.USER_ACTION_TIMEOUT: raise CliFail("Failed to add fingerprint due to user inactivity.") raise CliFail(f"Failed to add fingerprint: {e.code.name}.") logger.info("Fingerprint template registered") click.echo("Capture complete.") bio.set_name(template_id, name) logger.info("Fingerprint template name set") @bio.command("rename") @click.pass_context @click.argument("template_id", metavar="ID") @click.argument("name") @click.option("-P", "--pin", help="PIN code") def bio_rename(ctx, template_id, name, pin): """ Set the label for a fingerprint. \b ID the ID of the fingerprint to rename (as shown in "list") NAME a short readable name for the fingerprint (eg. "Left thumb") """ if len(name.encode()) >= 16: ctx.fail("Fingerprint name must be a maximum of 15 bytes") bio = _init_bio(ctx, pin) enrollments = bio.enumerate_enrollments() key = bytes.fromhex(template_id) if key not in enrollments: raise CliFail(f"No fingerprint matching ID={template_id}.") bio.set_name(key, name) click.echo("Fingerprint template renamed.") @bio.command("delete") @click.pass_context @click.argument("template_id", metavar="ID") @click.option("-P", "--pin", help="PIN code") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") def bio_delete(ctx, template_id, pin, force): """ Delete a fingerprint. Delete a fingerprint from the YubiKey by its ID, which can be seen by running the "list" subcommand. """ bio = _init_bio(ctx, pin) enrollments = bio.enumerate_enrollments() try: key: Optional[bytes] = bytes.fromhex(template_id) except ValueError: key = None if key not in enrollments: # Match using template_id as NAME matches = [k for k in enrollments if enrollments[k] == template_id] if len(matches) == 0: raise CliFail(f"No fingerprint matching ID={template_id}.") elif len(matches) > 1: raise CliFail( f"Multiple matches for NAME={template_id}. " "Delete by template ID instead." ) key = matches[0] name = enrollments[key] if force or click.confirm(f"Delete fingerprint {_format_fp(key, name)}?"): try: bio.remove_enrollment(key) click.echo("Fingerprint template deleted.") except CtapError as e: raise CliFail(f"Failed to delete fingerprint: {e.code.name}.") @fido.group("config") def config(): """ Manage FIDO configuration. """ @config.command("toggle-always-uv") @click.pass_context @click.option("-P", "--pin", help="PIN code") def toggle_always_uv(ctx, pin): """ Toggles the state of Always Require User Verification. """ options = ctx.obj.get("ctap2").info.options if "ctap2" in ctx.obj else None if not options or "alwaysUv" not in options: raise CliFail("Always Require UV is not supported on this YubiKey.") info = ctx.obj["info"] if CAPABILITY.FIDO2 in info.fips_capable: raise CliFail("Always Require UV can not be disabled on this YubiKey.") always_uv = options["alwaysUv"] config = _init_config(ctx, pin) config.toggle_always_uv() click.echo(f"Always Require UV is {'off' if always_uv else 'on'}.") @config.command("enable-ep-attestation") @click.pass_context @click.option("-P", "--pin", help="PIN code") def enable_ep_attestation(ctx, pin): """ Enables Enterprise Attestation for Authenticators pre-configured to support it. """ options = ctx.obj.get("ctap2").info.options if "ctap2" in ctx.obj else None if not options or "ep" not in options: raise CliFail("Enterprise Attestation is not supported on this YubiKey.") if options.get("alwaysUv") and not options.get("clientPin"): raise CliFail( "Enabling Enterprise Attestation requires a PIN to be set when alwaysUv is " "enabled." ) config = _init_config(ctx, pin) config.enable_enterprise_attestation() click.echo("Enterprise Attestation enabled.") yubikey_manager-5.6.1/ykman/_cli/oath.py0000644000175000017500000005747114777516541017633 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.oath import ( OathSession, CredentialData, OATH_TYPE, HASH_ALGORITHM, parse_b32_key, _format_cred_id, ) from yubikit.management import CAPABILITY from .util import ( CliFail, click_force_option, click_postpone_execution, click_callback, click_parse_b32_key, click_prompt, click_group, prompt_for_touch, prompt_timeout, EnumChoice, is_yk4_fips, check_version, pretty_print, get_scp_params, ) from ..oath import is_steam, calculate_steam, is_hidden, delete_broken_credential from ..settings import AppData from typing import Dict, List, Any import click import logging logger = logging.getLogger(__name__) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def oath(ctx): """ Manage the OATH application. Examples: \b Generate codes for accounts starting with 'yubi': $ ykman oath accounts code yubi \b Add an account with the secret key f5up4ub3dw and the name yubico, which requires touch: $ ykman oath accounts add yubico f5up4ub3dw --touch \b Set a password for the OATH application: $ ykman oath access change """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.OATH, conn) ctx.obj["session"] = OathSession(conn, scp_params) ctx.obj["oath_keys"] = AppData("oath_keys") info = ctx.obj["info"] is_fips = CAPABILITY.OATH in info.fips_capable ctx.obj["fips_unready"] = is_fips and CAPABILITY.OATH not in info.fips_approved ctx.obj["no_scp"] = is_fips and dev.transport == TRANSPORT.NFC and not scp_params @oath.command() @click.pass_context def info(ctx): """ Display general status of the OATH application. """ session = ctx.obj["session"] info = ctx.obj["info"] data: Dict[str, Any] = {"OATH version": "%d.%d.%d" % session.version} lines: List[Any] = [data] if CAPABILITY.OATH in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data["FIPS approved"] = CAPABILITY.OATH in info.fips_approved elif is_yk4_fips(info): data["FIPS approved"] = session.locked data["Password protection"] = "enabled" if session.locked else "disabled" keys = ctx.obj["oath_keys"] if session.locked and session.device_id in keys: lines.append("The password for this YubiKey is remembered by ykman.") click.echo("\n".join(pretty_print(lines))) @oath.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all OATH data. This action will delete all accounts and restore factory settings for the OATH application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored OATH accounts and restore factory " "settings. Proceed?", abort=True, err=True, ) session = ctx.obj["session"] click.echo("Resetting OATH data...") old_id = session.device_id session.reset() keys = ctx.obj["oath_keys"] if old_id in keys: del keys[old_id] keys.write() logger.info("Deleted remembered access key") click.echo("Reset complete. All OATH accounts have been deleted from the YubiKey.") click_password_option = click.option( "-p", "--password", help="the password to unlock the YubiKey" ) click_remember_option = click.option( "-r", "--remember", is_flag=True, help="remember the password on this machine", ) def _validate(ctx, key, remember): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] session.validate(key) if remember: keys.put_secret(session.device_id, key.hex()) keys.write() logger.info("Access key remembered") click.echo("Password remembered.") def _init_session(ctx, password, remember, prompt="Enter the password"): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if session.locked: try: # Use password, if given as argument if password: logger.debug("Access key required, using provided password") key = session.derive_key(password) _validate(ctx, key, remember) return # Use stored key, if available if device_id in keys: logger.debug("Access key required, using remembered key") try: key = bytes.fromhex(keys.get_secret(device_id)) _validate(ctx, key, False) return except ApduError as e: # Delete wrong key and fall through to prompt if e.sw == SW.INCORRECT_PARAMETERS: logger.debug("Remembered key incorrect, deleting key") del keys[device_id] keys.write() except Exception as e: # Other error, fall though to prompt logger.warning("Error authenticating", exc_info=e) # Prompt for password password = click_prompt(prompt, hide_input=True) key = session.derive_key(password) _validate(ctx, key, remember) except ApduError: raise CliFail("Authentication to the YubiKey failed. Wrong password?") elif password: raise CliFail("Password provided, but no password is set.") def _fail_scp(ctx, e): if ctx.obj["no_scp"] and e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Unable to manage OATH over NFC without SCP") raise e @oath.group() def access(): """Manage password protection for OATH.""" @access.command() @click.pass_context @click_password_option @click.option( "-c", "--clear", is_flag=True, help="remove the current password", ) @click.option("-n", "--new-password", help="provide a new password as an argument") @click_remember_option def change(ctx, password, clear, new_password, remember): """ Change the password used to protect OATH accounts. Allows you to set or change a password that will be required to access the OATH accounts stored on the YubiKey. """ if clear: if new_password: raise CliFail("--clear cannot be combined with --new-password.") info = ctx.obj["info"] if CAPABILITY.OATH in info.fips_capable: raise CliFail("Removing the password is not allowed on YubiKey FIPS.") _init_session(ctx, password, False, prompt="Enter the current password") session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if clear: session.unset_key() if device_id in keys: del keys[device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password cleared from YubiKey.") else: if remember: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not new_password: new_password = click_prompt( "Enter the new password", hide_input=True, confirmation_prompt=True ) key = session.derive_key(new_password) if remember: keys.put_secret(device_id, key.hex()) keys.write() click.echo("Password remembered.") elif device_id in keys: del keys[device_id] keys.write() try: session.set_key(key) click.echo("Password updated.") except ApduError as e: _fail_scp(ctx, e) @access.command() @click.pass_context @click_password_option def remember(ctx, password): """ Store the YubiKeys password on this computer to avoid having to enter it on each use. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if not session.locked: if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("This YubiKey is not password protected.") else: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not password: password = click_prompt("Enter the password", hide_input=True) key = session.derive_key(password) try: _validate(ctx, key, True) click.echo("Password remembered.") except Exception: raise CliFail("Authentication to the YubiKey failed. Wrong password?") def _clear_all_passwords(ctx, param, value): if not value or ctx.resilient_parsing: return keys = AppData("oath_keys") if keys: keys.clear() keys.write() click.echo("All passwords have been forgotten.") ctx.exit() @access.command() @click.pass_context @click.option( "-a", "--all", is_flag=True, is_eager=True, expose_value=False, callback=_clear_all_passwords, help="remove all stored passwords", ) def forget(ctx): """ Remove a stored password from this computer. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password forgotten.") else: click.echo("No password stored for this YubiKey.") click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to generate code" ) click_show_hidden_option = click.option( "-H", "--show-hidden", is_flag=True, help="include hidden accounts" ) def _string_id(credential): return credential.id.decode("utf-8") def _error_multiple_hits(ctx, hits): click.echo("Error: Multiple matches, make the query more specific.", err=True) click.echo("", err=True) for cred in hits: click.echo(_string_id(cred), err=True) ctx.exit(1) def _search(creds, query, show_hidden): hits = [] for c in creds: cred_id = _string_id(c) if not show_hidden and is_hidden(c): continue if cred_id == query: return [c] if query.lower() in cred_id.lower(): hits.append(c) return hits @oath.group() def accounts(): """Manage and use OATH accounts.""" @accounts.command() @click.argument("name") @click.argument("secret", callback=click_parse_b32_key, required=False) @click.option( "-o", "--oath-type", type=EnumChoice(OATH_TYPE), default=OATH_TYPE.TOTP.name, help="time-based (TOTP) or counter-based (HOTP) account", show_default=True, ) @click.option( "-d", "--digits", type=click.Choice(["6", "7", "8"]), default="6", help="number of digits in generated code", show_default=True, ) @click.option( "-a", "--algorithm", type=EnumChoice(HASH_ALGORITHM), default=HASH_ALGORITHM.SHA1.name, show_default=True, help="algorithm to use for code generation", ) @click.option( "-c", "--counter", type=click.INT, default=0, help="initial counter value for HOTP accounts", ) @click.option("-i", "--issuer", help="issuer of the account (optional)") @click.option( "-P", "--period", help="number of seconds a TOTP code is valid", default=30, show_default=True, ) @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def add( ctx, secret, name, issuer, period, oath_type, digits, touch, algorithm, counter, force, password, remember, ): """ Add a new account. This will add a new OATH account to the YubiKey. \b NAME human readable name of the account, such as a username or e-mail address SECRET base32-encoded secret/key value provided by the server """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" ) digits = int(digits) if not secret: while True: secret = click_prompt("Enter a secret key (base32)") try: secret = parse_b32_key(secret) break except Exception as e: click.echo(e) _init_session(ctx, password, remember) _add_cred( ctx, CredentialData( name, oath_type, algorithm, secret, digits, period, counter, issuer ), touch, force, ) @click_callback() def click_parse_uri(ctx, param, val): try: return CredentialData.parse_uri(val) except (ValueError, KeyError): raise click.BadParameter("URI seems to have the wrong format.") @accounts.command() @click.argument("data", callback=click_parse_uri, required=False, metavar="URI") @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def uri(ctx, data, touch, force, password, remember): """ Add a new account from an otpauth:// URI. Use a URI to add a new account to the YubiKey. """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" ) if not data: while True: uri = click_prompt("Enter an OATH URI (otpauth://)") try: data = CredentialData.parse_uri(uri) break except Exception as e: click.echo(e) # Steam is a special case where we allow the otpauth # URI to contain a 'digits' value of '5'. if data.digits == 5 and is_steam(data): data.digits = 6 _init_session(ctx, password, remember) _add_cred(ctx, data, touch, force) def _add_cred(ctx, data, touch, force): session = ctx.obj["session"] version = session.version if not (0 < len(data.name) <= 64): raise CliFail("Name must be between 1 and 64 bytes.") if len(data.secret) < 2: raise CliFail("Secret must be at least 2 bytes.") if touch and not check_version(version, (4, 2, 6)): raise CliFail("Require touch is not supported on this YubiKey.") if data.counter and data.oath_type != OATH_TYPE.HOTP: raise CliFail("Counter only supported for HOTP accounts.") if data.hash_algorithm == HASH_ALGORITHM.SHA512 and ( not check_version(version, (4, 3, 1)) or is_yk4_fips(ctx.obj["info"]) ): raise CliFail("Algorithm SHA512 not supported on this YubiKey.") creds = session.list_credentials() cred_id = data.get_id() if not force and any(cred.id == cred_id for cred in creds): click.confirm( f"An account called {data.name} already exists on this YubiKey." " Do you want to overwrite it?", abort=True, err=True, ) firmware_overwrite_issue = (4, 0, 0) < version < (4, 3, 5) cred_is_subset = any( (cred.id.startswith(cred_id) and cred.id != cred_id) for cred in creds ) # YK4 has an issue with credential overwrite in firmware versions < 4.3.5 if firmware_overwrite_issue and cred_is_subset: raise CliFail("Choose a name that is not a subset of an existing account.") try: session.put_credential(data, touch) click.echo("OATH account added.") except ApduError as e: if e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for OATH accounts.") elif e.sw == SW.COMMAND_ABORTED: # Some NEOs do not use the NO_SPACE error. raise CliFail("The command failed. Is there enough space on the YubiKey?") _fail_scp(ctx, e) @accounts.command() @click_show_hidden_option @click.pass_context @click.option("-o", "--oath-type", is_flag=True, help="display the OATH type") @click.option("-P", "--period", is_flag=True, help="display the period") @click_password_option @click_remember_option def list(ctx, show_hidden, oath_type, period, password, remember): """ List all accounts. List all accounts stored on the YubiKey. """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = [ cred for cred in session.list_credentials() if show_hidden or not is_hidden(cred) ] creds.sort() for cred in creds: click.echo(_string_id(cred), nl=False) if oath_type: click.echo(f", {cred.oath_type.name}", nl=False) if period: click.echo(f", {cred.period}", nl=False) click.echo() @accounts.command() @click_show_hidden_option @click.pass_context @click.argument("query", required=False, default="") @click.option( "-s", "--single", is_flag=True, help="ensure only a single match, and output only the code", ) @click_password_option @click_remember_option def code(ctx, show_hidden, query, single, password, remember): """ Generate codes. Generate codes from OATH accounts stored on the YubiKey. Provide a query string to match one or more specific accounts. Accounts of type HOTP, or those that require touch, require a single match to be triggered. """ _init_session(ctx, password, remember) session = ctx.obj["session"] try: entries = session.calculate_all() except ApduError as e: if e.sw == SW.MEMORY_FAILURE: logger.warning("Corrupted data in OATH accounts, attempting to fix") if delete_broken_credential(session): entries = session.calculate_all() else: logger.error("Unable to fix memory failure") raise else: raise creds = _search(entries.keys(), query, show_hidden) if len(creds) == 1: cred = creds[0] code = entries[cred] if cred.touch_required: prompt_for_touch() try: if cred.oath_type == OATH_TYPE.HOTP: with prompt_timeout(): # HOTP might require touch, we don't know. # Assume yes after 500ms. code = session.calculate_code(cred) elif code is None: code = session.calculate_code(cred) except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("Touch account timed out!") entries[cred] = code elif single and len(creds) > 1: _error_multiple_hits(ctx, creds) elif single and len(creds) == 0: raise CliFail("No matching account found.") if single and creds: if is_steam(cred): click.echo(calculate_steam(session, cred)) else: click.echo(code.value) else: outputs = [] for cred in sorted(creds): code = entries[cred] if code: if is_steam(cred): code = calculate_steam(session, cred) else: code = code.value elif cred.touch_required: code = "[Requires Touch]" elif cred.oath_type == OATH_TYPE.HOTP: code = "[HOTP Account]" else: code = "" outputs.append((_string_id(cred), code)) longest_name = max(len(n) for (n, c) in outputs) if outputs else 0 longest_code = max(len(c) for (n, c) in outputs) if outputs else 0 format_str = "{:<%d} {:>%d}" % (longest_name, longest_code) for name, result in outputs: click.echo(format_str.format(name, result)) @accounts.command() @click.pass_context @click.argument("query") @click.argument("name") @click.option("-f", "--force", is_flag=True, help="confirm rename without prompting") @click_password_option @click_remember_option def rename(ctx, query, name, force, password, remember): """ Rename an account (requires YubiKey 5.3 or later). \b QUERY a query to match a single account (as shown in "list") NAME the name of the account (use ":" to specify issuer) """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if ":" in name: issuer, name = name.split(":", 1) else: issuer = None new_id = _format_cred_id(issuer, name, cred.oath_type, cred.period) if any(cred.id == new_id for cred in creds): raise CliFail( f"Another account with ID {new_id.decode()} " "already exists on this YubiKey." ) if force or ( click.confirm( f"Rename account: {_string_id(cred)} ?", default=False, err=True, ) ): session.rename_credential(cred.id, name, issuer) click.echo(f"Renamed {_string_id(cred)} to {new_id.decode()}.") else: click.echo("Rename aborted by user.") else: _error_multiple_hits(ctx, hits) @accounts.command() @click.pass_context @click.argument("query") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") @click_password_option @click_remember_option def delete(ctx, query, force, password, remember): """ Delete an account. Delete an account from the YubiKey. \b QUERY a query to match a single account (as shown in "list") """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if force or ( click.confirm( f"Delete account: {_string_id(cred)} ?", default=False, err=True, ) ): session.delete_credential(cred.id) click.echo(f"Deleted {_string_id(cred)}.") else: click.echo("Deletion aborted by user.") else: _error_multiple_hits(ctx, hits) yubikey_manager-5.6.1/ykman/_cli/__main__.py0000644000175000017500000005415514777516541020414 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import ApplicationNotAvailableError, Version, _override_version from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.core.smartcard.scp import ( Scp03KeyParams, StaticKeys, ScpKid, KeyRef, ) from yubikit.support import get_name, read_info from yubikit.logging import LOG_LEVEL from .. import __version__ from ..pcsc import list_devices as list_ccid, list_readers from ..device import scan_devices, list_all_devices as _list_all_devices from ..util import ( get_windows_version, parse_private_key, parse_certificates, InvalidPasswordError, is_nfc_restricted, ) from ..logging import init_logging from ..diagnostics import get_diagnostics, sys_info from ..settings import AppData from .util import ( YkmanContextObject, click_group, EnumChoice, HexIntParamType, CliFail, pretty_print, click_prompt, find_scp11_params, organize_scp11_certificates, ) from .info import info from .otp import otp from .openpgp import openpgp from .oath import oath from .piv import piv from .fido import fido from .config import config from .apdu import apdu from .script import run_script from .hsmauth import hsmauth from .securitydomain import securitydomain, click_parse_scp_ref, ScpKidParamType from cryptography.exceptions import InvalidSignature from dataclasses import replace import click import click.shell_completion import ctypes import time import sys import re import os import logging logger = logging.getLogger(__name__) # Development key builds are treated as having the following version _OVERRIDE_VERSION = Version.from_string(os.environ.get("_YK_OVERRIDE_VERSION", "5.7.4")) CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], max_content_width=999) WIN_CTAP_RESTRICTED = ( sys.platform == "win32" and not bool(ctypes.windll.shell32.IsUserAnAdmin()) and get_windows_version() >= (10, 0, 18362) ) def _scan_changes(state, attempts=10): for _ in range(attempts): time.sleep(0.25) devices, new_state = scan_devices() if new_state != state: return devices, new_state raise TimeoutError("Timed out waiting for state change") def print_version(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo(f"YubiKey Manager (ykman) version: {__version__}") ctx.exit() def print_diagnostics(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo("\n".join(pretty_print(get_diagnostics()))) ctx.exit() def require_reader(connection_types, reader): if SmartCardConnection in connection_types or FidoConnection in connection_types: readers = list_ccid(reader) if len(readers) == 1: dev = readers[0] nfc_restricted = False try: with dev.open_connection(SmartCardConnection) as conn: try: info = read_info(conn, dev.pid) return dev, info except ValueError: nfc_restricted = is_nfc_restricted(conn) raise # Re-raise to be handled in block below except Exception: if nfc_restricted: raise CliFail( "YubiKey is in NFC restricted mode " "(see: https://www.yubico.com/getting-started/)." ) raise CliFail("Failed to connect to YubiKey.") elif len(readers) > 1: raise CliFail("Multiple external readers match name.") else: raise CliFail("No YubiKey found on external reader.") else: raise CliFail("Not a CCID command.") def list_all_devices(*args, **kwargs): devices = _list_all_devices(*args, **kwargs) with_serial = [(dev, dev_info) for (dev, dev_info) in devices if dev_info.serial] if with_serial: history = AppData("history") cache = history.setdefault("devices", {}) for dev, dev_info in with_serial: if dev_info.serial: k = str(dev_info.serial) cache[k] = cache.pop(k, None) or _describe_device(dev, dev_info, False) # 5, chosen by fair dice roll [cache.pop(k) for k in list(cache.keys())[: -max(5, len(with_serial))]] history.write() return devices def require_device(connection_types, serial=None): # Find all connected devices devices, state = scan_devices() n_devs = sum(devices.values()) if serial is None: if n_devs == 0: # The device might not yet be ready, wait a bit try: devices, state = _scan_changes(state) n_devs = sum(devices.values()) except TimeoutError: raise CliFail("No YubiKey detected!") if n_devs > 1: list_all_devices() # Update device cache raise CliFail( "Multiple YubiKeys detected. Use --device SERIAL to specify " "which one to use." ) # Only one connected device, check if any needed interfaces are available pid = next(iter(devices.keys())) supported = [c for c in connection_types if pid.supports_connection(c)] if WIN_CTAP_RESTRICTED and supported == [FidoConnection]: # FIDO-only command on Windows without Admin won't work. raise CliFail("FIDO access on Windows requires running as Administrator.") if not supported: interfaces = [c.usb_interface for c in connection_types] req = ", ".join(t.name or str(t) for t in interfaces) raise CliFail( f"Command requires one of the following USB interfaces " f"to be enabled: '{req}'.\n\n" "Use 'ykman config usb' to set the enabled USB interfaces." ) devs = list_all_devices(supported) if len(devs) != 1: raise CliFail("Failed to connect to YubiKey.") return devs[0] else: for retry in ( True, False, ): # If no match initially, wait a bit for state change. devs = list_all_devices(connection_types) for dev, dev_info in devs: if dev_info.serial == serial: return dev, dev_info try: if retry: _, state = _scan_changes(state) except TimeoutError: break raise CliFail( f"Failed connecting to a YubiKey with serial: {serial}.\n" "Make sure the application has the required permissions.", ) @click_group(context_settings=CLICK_CONTEXT_SETTINGS) @click.option( "-d", "--device", type=int, metavar="SERIAL", help="specify which YubiKey to interact with by serial number", shell_complete=lambda ctx, param, incomplete: [ click.shell_completion.CompletionItem( serial, help=description, ) for serial, description in AppData("history").get("devices", {}).items() if serial.startswith(incomplete) ], ) @click.option( "-r", "--reader", help="specify a YubiKey by smart card reader name " "(can't be used with --device or list)", metavar="NAME", default=None, shell_complete=lambda ctx, param, incomplete: [ f'"{reader.name}"' for reader in list_readers() ], ) @click.option( "-t", "--scp-ca", type=click.File("rb"), help="specify the CA to use to verify the SCP11 card key (CA-KLCC)", ) @click.option( "-c", "--scp-sd", metavar="KID KVN", type=(ScpKidParamType(), HexIntParamType()), default=(0, 0), callback=click_parse_scp_ref, hidden="--full-help" not in sys.argv, help="specify which key the YubiKey is using to authenticate", ) @click.option( "-o", "--scp-oce", metavar="KID KVN", type=HexIntParamType(), nargs=2, default=(0, 0), hidden="--full-help" not in sys.argv, help="specify which key the OCE is using to authenticate", ) @click.option( "-s", "--scp", "scp_cred", metavar="CRED", multiple=True, help="specify private key and certificate chain for secure messaging, " "can be used multiple times to provide key and certificates in multiple " "files (private key, certificates in leaf-last order), OR SCP03 keys in hex " " separated by colon (:) K-ENC:K-MAC[:K-DEK]", ) @click.option( "-p", "--scp-password", "scp_cred_password", metavar="PASSWORD", help="specify a password required to access the --scp file, if needed", ) @click.option( "-l", "--log-level", default=None, type=EnumChoice(LOG_LEVEL, hidden=[LOG_LEVEL.NOTSET]), help="enable logging at given verbosity level", ) @click.option( "--log-file", default=None, type=str, metavar="FILE", help="write log to FILE instead of printing to stderr (requires --log-level)", ) @click.option( "--diagnose", is_flag=True, callback=print_diagnostics, expose_value=False, is_eager=True, help="show diagnostics information useful for troubleshooting", ) @click.option( "-v", "--version", is_flag=True, callback=print_version, expose_value=False, is_eager=True, help="show version information about the app", ) @click.option( "--full-help", is_flag=True, expose_value=False, help="show --help output, including hidden commands", ) @click.pass_context def cli( ctx, device, scp_ca, scp_sd, scp_oce, scp_cred, scp_cred_password, log_level, log_file, reader, ): """ Configure your YubiKey via the command line. Examples: \b List connected YubiKeys, only output serial number: $ ykman list --serials \b Show information about YubiKey with serial number 123456: $ ykman --device 123456 info """ ctx.obj = YkmanContextObject() if log_level: init_logging(log_level, log_file=log_file, replace=log_file is None) logger.info("\n".join(pretty_print({"System info": sys_info()}))) elif log_file: ctx.fail("--log-file requires specifying --log-level.") if reader and device: ctx.fail("--reader and --device options can't be combined.") use_scp = bool(any(scp_sd) or scp_cred or scp_ca) subcmd = next(c for c in COMMANDS if c.name == ctx.invoked_subcommand) # Commands that don't directly act on a key if subcmd in (list_keys,): if device: ctx.fail("--device can't be used with this command.") if reader: ctx.fail("--reader can't be used with this command.") if use_scp: ctx.fail("SCP can't be used with this command.") return # Commands which need a YubiKey to act on connections = getattr( subcmd, "connections", [SmartCardConnection, FidoConnection, OtpConnection] ) if connections: if connections == [FidoConnection] and WIN_CTAP_RESTRICTED: # FIDO-only command on Windows without Admin won't work. raise CliFail("FIDO access on Windows requires running as Administrator.") def resolve(): items = getattr(resolve, "items", None) if not items: # We might be connecting over NFC, and thus may require SCP11 if reader is not None: items = require_reader(connections, reader) else: items = require_device(connections, device) if items[1].version.major == 0: logger.info( "Debug key detected, " f"overriding version with {_OVERRIDE_VERSION}" ) # Preview build, override version and get new DeviceInfo _override_version(_OVERRIDE_VERSION) for c in connections: if items[0].supports_connection(c): try: with items[0].open_connection(c) as conn: info = read_info(conn, items[0].pid) items = (items[0], info) except Exception: logger.debug("Failed", exc_info=True) continue break else: raise CliFail("Failed to connect to YubiKey.") setattr(resolve, "items", items) return items ctx.obj.add_resolver("device", lambda: resolve()[0]) ctx.obj.add_resolver("pid", lambda: resolve()[0].pid) ctx.obj.add_resolver("info", lambda: resolve()[1]) if use_scp: if SmartCardConnection not in connections: raise CliFail("SCP can only be used with CCID commands.") scp_kid, scp_kvn = scp_sd if scp_kid: try: scp_kid = ScpKid(scp_kid) except ValueError: raise CliFail(f"Invalid KID for card certificate: {scp_kid}.") if scp_ca: ca = scp_ca.read() else: ca = None key_fmt = r"[0-9a-fA-F]{32}" re_hex_keys = re.compile(rf"^{key_fmt}:{key_fmt}(:{key_fmt})?$") if len(scp_cred) == 1 and re_hex_keys.match(scp_cred[0]): scp03_keys = StaticKeys( *(bytes.fromhex(k) for k in scp_cred[0].split(":")) ) scp11_creds = None else: f = click.File("rb") scp11_creds = [f.convert(fn, None, ctx).read() for fn in scp_cred] scp03_keys = None if not scp_kid: if scp03_keys: scp_kid = ScpKid.SCP03 elif not scp11_creds: scp_kid = ScpKid.SCP11b if scp03_keys and scp_kid != ScpKid.SCP03: raise CliFail("--scp with SCP03 keys can only be used with SCP03.") if scp_kid == ScpKid.SCP03: if scp_ca: raise CliFail("--scp-ca can only be used with SCP11.") def params_f(_): return Scp03KeyParams( ref=KeyRef(ScpKid.SCP03, scp_kvn), keys=scp03_keys or StaticKeys.default(), ) elif scp11_creds: # SCP11 a/c if scp_kid and scp_kid not in (ScpKid.SCP11a, ScpKid.SCP11c): raise CliFail("--scp with file(s) can only be used with SCP11 a/c.") first = scp11_creds.pop(0) password = scp_cred_password.encode() if scp_cred_password else None while True: try: sk_oce_ecka = parse_private_key(first, password) break except InvalidPasswordError: if scp_cred_password: raise CliFail("Wrong password to decrypt private key.") logger.debug("Error parsing key", exc_info=True) password = click_prompt( "Enter password to decrypt SCP11 key", default="", hide_input=True, show_default=False, ).encode() if scp11_creds: certificates = [] for c in scp11_creds: certificates.extend(parse_certificates(c, None)) else: certificates = parse_certificates(first, password) # If the bundle contains the CA we strip it out _, inter, leaf = organize_scp11_certificates(certificates) # Send the KA-KLOC and OCE certificates certificates = list(inter) + [leaf] def params_f(conn): if not scp_kid: # TODO: Find key based on CA # Check for SCP11a key, then SCP11c try: params = find_scp11_params(conn, ScpKid.SCP11a, scp_kvn, ca) except (ValueError, InvalidSignature) as e: try: params = find_scp11_params( conn, ScpKid.SCP11c, scp_kvn, ca ) except (ValueError, InvalidSignature): raise e else: params = find_scp11_params(conn, scp_kid, scp_kvn, ca) return replace( params, oce_ref=KeyRef(*scp_oce), sk_oce_ecka=sk_oce_ecka, certificates=certificates, ) else: # SCP11b if scp_kid not in (ScpKid.SCP11b, None): raise CliFail(f"{scp_kid.name} requires --scp.") if any(scp_oce): raise CliFail("SCP11b cannot be used with --scp-oce.") def params_f(conn): return find_scp11_params(conn, ScpKid.SCP11b, scp_kvn, ca) connections = [SmartCardConnection] ctx.obj.add_resolver("scp", lambda: params_f) @cli.command("list") @click.option( "-s", "--serials", is_flag=True, help="output only serial numbers, one per line " "(devices without serial will be omitted)", ) @click.option("-r", "--readers", is_flag=True, help="list available smart card readers") @click.pass_context def list_keys(ctx, serials, readers): """ List connected YubiKeys. """ if readers: for reader in list_readers(): click.echo(reader.name) ctx.exit() # List all attached devices pids = set() for dev, dev_info in list_all_devices(): if serials: if dev_info.serial: click.echo(dev_info.serial) else: click.echo( _describe_device(dev, dev_info) + (f" Serial: {dev_info.serial}" if dev_info.serial else "") ) pids.add(dev.pid) # Look for FIDO devices that we can't access if not serials: devs, _ = scan_devices() for pid, count in devs.items(): if pid not in pids: for _ in range(count): name = pid.yubikey_type.value mode = pid.name.split("_", 1)[1].replace("_", "+") click.echo(f"{name} [{mode}] ") def _describe_device(dev, dev_info, include_mode=True): if dev.pid is None: # Devices from list_all_devices should always have PID. raise AssertionError("PID is None") name = get_name(dev_info, dev.pid.yubikey_type) version = dev_info.version or "unknown" description = f"{name} ({version})" if include_mode: mode = dev.pid.name.split("_", 1)[1].replace("_", "+") description += f" [{mode}]" return description COMMANDS = ( list_keys, info, otp, openpgp, oath, piv, fido, config, apdu, run_script, hsmauth, securitydomain, ) for cmd in COMMANDS: cli.add_command(cmd) class _DefaultFormatter(logging.Formatter): def __init__(self, show_trace=False): self.show_trace = show_trace def format(self, record): message = f"{record.levelname}: {record.getMessage()}" if self.show_trace and record.exc_info: message += self.formatException(record.exc_info) return message def main(): # Set up default logging handler = logging.StreamHandler() handler.setLevel(logging.WARNING) formatter = _DefaultFormatter() handler.setFormatter(formatter) logging.getLogger().addHandler(handler) try: # --full-help triggers --help, hidden commands will already have read it by now. sys.argv[sys.argv.index("--full-help")] = "--help" except ValueError: pass # No --full-help try: cli(obj={}) except Exception as e: status = 1 if isinstance(e, CliFail): status = e.status msg = e.args[0] elif isinstance(e, ApplicationNotAvailableError): msg = ( "The functionality required for this command is not enabled or not " "available on this YubiKey." ) elif isinstance(e, ValueError): msg = f"{e}" else: msg = "An unexpected error has occurred" formatter.show_trace = True logger.exception(msg) logging.shutdown() sys.exit(status) if __name__ == "__main__": main() yubikey_manager-5.6.1/ykman/_cli/apdu.py0000644000175000017500000001601114777516541017612 0ustar winniewinnie# Copyright (c) 2020 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from binascii import a2b_hex from yubikit.core.smartcard import ( SmartCardConnection, SmartCardProtocol, ApduFormat, ApduError, SW, AID, ) from .util import EnumChoice, CliFail, click_command from typing import Tuple, Optional import re import sys import click import struct import logging logger = logging.getLogger(__name__) APDU_PATTERN = re.compile( r"^" r"(?P[0-9a-f]{2})?(?P[0-9a-f]{2})(?P[0-9a-f]{4})?" r"(?::(?P(?:[0-9a-f]{2})+))?" r"(?:/(?P[0-9a-f]{2}))?" r"(?P=(?P[0-9a-f]{4})?)?" r"$", re.IGNORECASE, ) def _hex(data: bytes) -> str: return " ".join(f"{d:02X}" for d in data) def _parse_apdu( data: str, ) -> Tuple[Tuple[int, int, int, int, bytes, int], Optional[int]]: m = APDU_PATTERN.match(data) if not m: raise ValueError("Invalid APDU format: " + data) cla = int(m.group("cla") or "00", 16) ins = int(m.group("ins"), 16) params = int(m.group("params") or "0000", 16) body = a2b_hex(m.group("body") or "") le = int(m.group("le") or "00", 16) if m.group("check"): sw: Optional[int] = int(m.group("sw") or "9000", 16) else: sw = None p1, p2 = params >> 8, params & 0xFF return (cla, ins, p1, p2, body, le), sw def _print_response(resp: bytes, sw: int, no_pretty: bool) -> None: click.echo(f"RECV (SW={sw:04X})" + (":" if resp else "")) if no_pretty: click.echo(resp.hex().upper()) else: for i in range(0, len(resp), 16): chunk = resp[i : i + 16] click.echo( " ".join(f"{c:02X}" for c in chunk).ljust(50) # Replace non-printable characters with a dot. + "".join(chr(c) if 31 < c < 127 else chr(183) for c in chunk) ) @click_command(connections=[SmartCardConnection], hidden="--full-help" not in sys.argv) @click.pass_context @click.option( "-x", "--no-pretty", is_flag=True, help="print only the hex output of a response" ) @click.option( "-a", "--app", type=EnumChoice(AID), required=False, help="select application", ) @click.option("--short", is_flag=True, help="use short APDUs instead of extended") @click.argument("apdu", nargs=-1) @click.option("-s", "--send-apdu", multiple=True, help="provide full APDUs") def apdu(ctx, no_pretty, app, short, apdu, send_apdu): """ Execute arbitrary APDUs. Provide APDUs as a hex encoded, space-separated list using the following syntax: [CLA]INS[P1P2][:DATA][/LE][=EXPECTED_SW] If not provided CLA, P1 and P2 are all set to zero. Setting EXPECTED_SW will cause the command to check the response SW and fail if it differs. "=" can be used as shorthand for "=9000" (SW=OK). Examples: \b Select the OATH application, send a LIST instruction (0xA1), and make sure we get sw=9000 (these are equivalent): $ ykman apdu a40400:a000000527210101=9000 a1=9000 or $ ykman apdu -a oath a1= \b Factory reset the OATH application: $ ykman apdu -a oath 04dead or $ ykman apdu a40400:a000000527210101 04dead or (using full-apdu mode) $ ykman apdu -s 00a4040008a000000527210101 -s 0004dead \b Get 8 random bytes from the OpenPGP application: $ ykman apdu -a openpgp 84/08= """ if apdu and send_apdu: ctx.fail("Cannot mix positional APDUs and -s/--send-apdu.") elif not send_apdu: apdus = [_parse_apdu(data) for data in apdu] if not apdus and not app: ctx.fail("No commands provided.") dev = ctx.obj["device"] info = ctx.obj["info"] scp_resolve = ctx.obj.get("scp") with dev.open_connection(SmartCardConnection) as conn: protocol = SmartCardProtocol(conn) is_first = True if scp_resolve: params = scp_resolve(conn) else: params = None # Use extended APDUs on YK 4+, unless --short is specified if not short and info.version[0] >= 4: protocol.apdu_format = ApduFormat.EXTENDED elif params: ctx.fail("--short cannot be used with SCP") if app: is_first = False click.echo("SELECT AID: " + _hex(app)) resp = protocol.select(app) _print_response(resp, SW.OK, no_pretty) if params: click.echo("INITIALIZE SCP") protocol.init_scp(params) if send_apdu: # Compatibility mode (full APDUs) for apdu in send_apdu: if not is_first: click.echo() else: is_first = False apdu = a2b_hex(apdu) click.echo("SEND: " + _hex(apdu)) resp, sw = protocol.connection.send_and_receive(apdu) _print_response(resp, sw, no_pretty) else: # Standard mode for apdu, check in apdus: if not is_first: click.echo() else: is_first = False header, body, le = apdu[:4], apdu[4], apdu[5] req = _hex(struct.pack(">BBBB", *header)) if body: req += " -- " + _hex(body) if le: req += f" (LE={le:02X})" click.echo("SEND: " + req) try: resp = protocol.send_apdu(*apdu) sw = SW.OK except ApduError as e: resp = e.data sw = e.sw _print_response(resp, sw, no_pretty) if check is not None and sw != check: raise CliFail(f"Aborted due to error (expected SW={check:04X}).") yubikey_manager-5.6.1/ykman/_cli/piv.py0000644000175000017500000013173514777516541017472 0ustar winniewinnie# Copyright (c) 2017 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import NotSupportedError, TRANSPORT from yubikit.core.smartcard import SmartCardConnection from yubikit.management import CAPABILITY from yubikit.piv import ( PivSession, InvalidPinError, KEY_TYPE, MANAGEMENT_KEY_TYPE, OBJECT_ID, SLOT, PIN_POLICY, TOUCH_POLICY, DEFAULT_MANAGEMENT_KEY, Chuid, ) from yubikit.core.smartcard import ApduError, SW from ..util import ( get_leaf_certificates, parse_private_key, parse_certificates, InvalidPasswordError, ) from ..piv import ( get_piv_info, get_pivman_data, get_pivman_protected_data, pivman_set_mgm_key, pivman_change_pin, pivman_set_pin_attempts, derive_management_key, generate_random_management_key, generate_chuid, generate_ccc, check_key, generate_self_signed_certificate, generate_csr, ) from .util import ( CliFail, click_group, click_force_option, click_format_option, click_postpone_execution, click_callback, click_prompt, prompt_timeout, EnumChoice, pretty_print, get_scp_params, log_or_echo, ) from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend from uuid import uuid4 import click import datetime import logging logger = logging.getLogger(__name__) @click_callback() def click_parse_piv_slot(ctx, param, val): try: return SLOT[val.upper().replace("-", "_")] except KeyError: try: return SLOT(int(val, 16)) except Exception: raise ValueError(val) @click_callback() def click_parse_piv_object(ctx, param, val): if val.upper() == "CCC": return OBJECT_ID.CAPABILITY try: return OBJECT_ID[val.upper().replace("-", "_")] except KeyError: try: return int(val, 16) except Exception: raise ValueError(val) @click_callback() def click_parse_management_key(ctx, param, val): try: key = bytes.fromhex(val) if key and len(key) not in (16, 24, 32): raise ValueError( "Management key must be exactly 16, 24, or 32 bytes " "(32, 48, or 64 hexadecimal digits) long." ) return key except Exception: raise ValueError(val) @click_callback() def click_parse_hash(ctx, param, val): try: return getattr(hashes, val) except AttributeError: raise ValueError(val) click_slot_argument = click.argument("slot", callback=click_parse_piv_slot) click_object_argument = click.argument( "object_id", callback=click_parse_piv_object, metavar="OBJECT" ) click_management_key_option = click.option( "-m", "--management-key", help="the management key", callback=click_parse_management_key, ) click_pin_option = click.option("-P", "--pin", help="PIN code") click_pin_policy_option = click.option( "--pin-policy", type=EnumChoice(PIN_POLICY), default=PIN_POLICY.DEFAULT.name, help="PIN policy for slot", ) click_touch_policy_option = click.option( "--touch-policy", type=EnumChoice(TOUCH_POLICY), default=TOUCH_POLICY.DEFAULT.name, help="touch policy for slot", ) click_hash_option = click.option( "-a", "--hash-algorithm", type=click.Choice(["SHA256", "SHA384", "SHA512"], case_sensitive=False), default="SHA256", show_default=True, help="hash algorithm", callback=click_parse_hash, ) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def piv(ctx): """ Manage the PIV application. Examples: \b Generate an ECC P-256 private key and a self-signed certificate in slot 9a: $ ykman piv keys generate --algorithm ECCP256 9a pubkey.pem $ ykman piv certificates generate --subject "CN=yubico" 9a pubkey.pem \b Change the PIN from 123456 to 654321: $ ykman piv access change-pin --pin 123456 --new-pin 654321 \b Reset all PIV data and restore default settings: $ ykman piv reset """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.PIV, conn) try: session = PivSession(conn, scp_params) except ApduError as e: if ( e.sw == SW.CONDITIONS_NOT_SATISFIED and not scp_params and dev.transport == TRANSPORT.NFC ): raise CliFail("Unable to manage PIV over NFC without SCP") raise info = ctx.obj["info"] ctx.obj["session"] = session ctx.obj["pivman_data"] = get_pivman_data(session) ctx.obj["fips_unready"] = ( CAPABILITY.PIV in info.fips_capable and CAPABILITY.PIV not in info.fips_approved ) @piv.command() @click.pass_context def info(ctx): """ Display general status of the PIV application. """ info = ctx.obj["info"] data = get_piv_info(ctx.obj["session"]) if CAPABILITY.PIV in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data[0]["FIPS approved"] = CAPABILITY.PIV in info.fips_approved click.echo("\n".join(pretty_print(data))) @piv.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all PIV data. This action will wipe all data and restore factory settings for the PIV application on the YubiKey. """ info = ctx.obj["info"] if CAPABILITY.PIV in info.reset_blocked: raise CliFail( "Cannot perform PIV reset when FIDO is configured, " "use 'ykman config reset' for full factory reset." ) force or click.confirm( "WARNING! This will delete all stored PIV data and restore factory " "settings. Proceed?", abort=True, err=True, ) click.echo("Resetting PIV data...") session = ctx.obj["session"] session.reset() try: has_puk = session.get_puk_metadata().attempts_remaining > 0 except NotSupportedError: has_puk = True click.echo("Reset complete. All PIV data has been cleared from the YubiKey.") if has_puk: click.echo("Your YubiKey now has the default PIN, PUK and Management Key:") click.echo("\tPIN:\t123456") click.echo("\tPUK:\t12345678") else: click.echo("Your YubiKey now has the default PIN and Management Key:") click.echo("\tPIN:\t123456") click.echo("\tManagement Key:\t010203040506070801020304050607080102030405060708") @piv.group() def access(): """Manage PIN, PUK, and Management Key.""" @access.command("set-retries") @click.pass_context @click.argument("pin-retries", type=click.IntRange(1, 255), metavar="PIN-RETRIES") @click.argument("puk-retries", type=click.IntRange(0, 255), metavar="PUK-RETRIES") @click_management_key_option @click_pin_option @click_force_option def set_pin_retries(ctx, management_key, pin, pin_retries, puk_retries, force): """ Set the number of PIN and PUK retry attempts. NOTE: This will reset the PIN and PUK to their factory defaults. """ session = ctx.obj["session"] info = ctx.obj["info"] if CAPABILITY.PIV in info.fips_capable: if not ( session.get_pin_metadata().default_value and session.get_puk_metadata().default_value ): raise CliFail( "Retry attempts must be set before PIN/PUK have been changed." ) try: # Can't change retries on Bio MPE session.get_bio_metadata() raise CliFail("PIN/PUK retries cannot be changed on this YubiKey.") except NotSupportedError: pass _ensure_authenticated( ctx, pin, management_key, require_pin_and_key=True, no_prompt=force ) click.echo("WARNING: This will reset the PIN and PUK to the factory defaults!") force or click.confirm( f"Set the number of PIN and PUK retry attempts to: {pin_retries} " f"{puk_retries}?", abort=True, err=True, ) try: pivman_set_pin_attempts(session, pin_retries, puk_retries) click.echo("Number of PIN/PUK retries set.") click.echo("Default PINs have been restored:") click.echo("\tPIN:\t123456") click.echo("\tPUK:\t12345678") except Exception: raise CliFail("Setting PIN retries failed.") def _validate_pin_length(pin, name, pin_complexity, min_len): unit = "characters" if pin_complexity else "bytes" pin_len = len(pin) if pin_complexity else len(pin.encode()) if not min_len <= pin_len <= 8: if min_len == 8: raise CliFail(f"{name} must be exactly 8 {unit} long.") else: raise CliFail(f"{name} must be between {min_len} and 8 {unit} long.") def _do_change_pin_puk(info, name, current, new, fn): pin_complexity = info.pin_complexity min_len = 8 if CAPABILITY.PIV in info.fips_capable else 6 _validate_pin_length(current, f"Current {name}", pin_complexity, 6) _validate_pin_length(new, f"New {name}", pin_complexity, min_len) try: fn() click.echo(f"New {name} set.") except InvalidPinError as e: attempts = e.attempts_remaining if attempts: raise CliFail(f"{name} change failed - %d tries left." % attempts) else: raise CliFail(f"{name} is blocked.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail(f"{name} does not meet complexity requirement.") raise @access.command("change-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") @click.option("-n", "--new-pin", help="a new PIN to set") def change_pin(ctx, pin, new_pin): """ Change the PIN code. The PIN must be between 6 and 8 bytes long, and supports any type of alphanumeric characters. For cross-platform compatibility, numeric PINs are recommended. """ info = ctx.obj["info"] session = ctx.obj["session"] if not session.get_pin_attempts(): raise CliFail("PIN is blocked.") if not pin: pin = _prompt_pin("Enter the current PIN") if not new_pin: new_pin = click_prompt( "Enter the new PIN", default="", hide_input=True, show_default=False, confirmation_prompt=True, ) _do_change_pin_puk( info, "PIN", pin, new_pin, lambda: pivman_change_pin(session, pin, new_pin), ) @access.command("change-puk") @click.pass_context @click.option("-p", "--puk", help="current PUK code") @click.option("-n", "--new-puk", help="a new PUK code to set") def change_puk(ctx, puk, new_puk): """ Change the PUK code. If the PIN is lost or blocked it can be reset using a PUK. The PUK must be between 6 and 8 bytes long, and supports any type of alphanumeric characters. """ info = ctx.obj["info"] session = ctx.obj["session"] try: if not session.get_puk_metadata().attempts_remaining: raise CliFail("PUK is blocked.") except NotSupportedError: pass if not puk: puk = _prompt_pin("Enter the current PUK") if not new_puk: new_puk = click_prompt( "Enter the new PUK", default="", hide_input=True, show_default=False, confirmation_prompt=True, ) _do_change_pin_puk( info, "PUK", puk, new_puk, lambda: session.change_puk(puk, new_puk), ) @access.command("change-management-key") @click.pass_context @click_pin_option @click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey when prompted for management key", ) @click.option( "-n", "--new-management-key", help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-m", "--management-key", help="current management key", callback=click_parse_management_key, ) @click.option( "-a", "--algorithm", help="management key algorithm", type=EnumChoice(MANAGEMENT_KEY_TYPE), ) @click.option( "-p", "--protect", is_flag=True, help="store new management key on the YubiKey, protected by PIN " "(a random key will be used if no key is provided)", ) @click.option( "-g", "--generate", is_flag=True, help="generate a random management key " "(implied by --protect unless --new-management-key is also given, " "can't be used with --new-management-key)", ) @click_force_option def change_management_key( ctx, management_key, algorithm, pin, new_management_key, touch, protect, generate, force, ): """ Change the management key. Management functionality is guarded by a management key. This key is required for administrative tasks, such as generating key pairs. A random key may be generated and stored on the YubiKey, protected by PIN. """ session = ctx.obj["session"] if ctx.obj["fips_unready"] and protect: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to using --protect." ) if not algorithm: try: algorithm = session.get_management_key_metadata().key_type except NotSupportedError: algorithm = MANAGEMENT_KEY_TYPE.TDES info = ctx.obj["info"] if CAPABILITY.PIV in info.fips_capable and algorithm in (MANAGEMENT_KEY_TYPE.TDES,): raise CliFail(f"{algorithm.name} not supported on YubiKey FIPS.") pin_verified = _ensure_authenticated( ctx, pin, management_key, require_pin_and_key=protect, mgm_key_prompt="Enter the current management key [blank to use default key]", no_prompt=force, ) # Can't combine new key with generate. if new_management_key and generate: raise CliFail( "Invalid options: --new-management-key conflicts with --generate." ) # Touch not supported on NEO. if touch and session.version < (4, 0, 0): raise CliFail("Require touch not supported on this YubiKey.") # If an old stored key needs to be cleared, the PIN is needed. pivman = ctx.obj["pivman_data"] if not pin_verified and pivman.has_stored_key: if pin: _verify_pin(ctx, session, pivman, pin, no_prompt=force) elif not force: click.confirm( "The current management key is stored on the YubiKey" " and will not be cleared if no PIN is provided. Continue?", abort=True, err=True, ) if not new_management_key: if protect or generate: new_management_key = generate_random_management_key(algorithm) if not protect: click.echo(f"Generated management key: {new_management_key.hex()}") elif force: raise CliFail( "New management key not given. Remove the --force " "flag, or set the --generate flag or the " "--new-management-key option." ) else: try: new_management_key = bytes.fromhex( click_prompt( "Enter the new management key", hide_input=True, confirmation_prompt=True, ) ) except Exception: raise CliFail("New management key has the wrong format.") if len(new_management_key) != algorithm.key_len: raise CliFail( "Management key has the wrong length (expected %d bytes)." % algorithm.key_len ) try: pivman_set_mgm_key( session, new_management_key, algorithm, touch=touch, store_on_device=protect ) click.echo("New management key set.") except ApduError: raise CliFail("Changing the management key failed.") @access.command("unblock-pin") @click.pass_context @click.option("-p", "--puk", required=False) @click.option("-n", "--new-pin", required=False, metavar="NEW-PIN") def unblock_pin(ctx, puk, new_pin): """ Unblock the PIN (using PUK). """ session = ctx.obj["session"] if not puk: puk = click_prompt("Enter PUK", default="", show_default=False, hide_input=True) if not new_pin: new_pin = click_prompt( "Enter a new PIN", default="", show_default=False, hide_input=True, confirmation_prompt=True, ) info = ctx.obj["info"] _validate_pin_length( new_pin, "New PIN", info.pin_complexity, 8 if CAPABILITY.PIV in info.fips_capable else 6, ) try: session.unblock_pin(puk, new_pin) click.echo("New PIN set.") except InvalidPinError as e: attempts = e.attempts_remaining if attempts: raise CliFail("PIN unblock failed - %d tries left." % attempts) else: raise CliFail("PUK is blocked.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("PIN does not meet complexity requirement.") raise @piv.group() def keys(): """ Manage private keys. """ @keys.command("generate") @click.pass_context @click_management_key_option @click_pin_option @click.option( "-a", "--algorithm", help="algorithm to use in key generation", type=EnumChoice(KEY_TYPE), default=KEY_TYPE.RSA2048.name, show_default=True, ) @click_format_option @click_pin_policy_option @click_touch_policy_option @click_slot_argument @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") def generate_key( ctx, slot, public_key_output, management_key, pin, algorithm, format, pin_policy, touch_policy, ): """ Generate an asymmetric key pair. The private key is generated on the YubiKey, and written to one of the slots. \b SLOT PIV slot of the private key PUBLIC-KEY file containing the generated public key (use '-' to use stdout) """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to key generation." ) _check_key_support_fips(ctx, algorithm, pin_policy) session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) public_key = session.generate_key(slot, algorithm, pin_policy, touch_policy) key_encoding = format public_key_output.write( public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) log_or_echo( f"Private key generated in slot {slot}, public key written to " f"{_fname(public_key_output)}", logger, public_key_output, ) @keys.command("import") @click.pass_context @click_pin_option @click_management_key_option @click_pin_policy_option @click_touch_policy_option @click_slot_argument @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") @click.option("-p", "--password", help="password used to decrypt the private key") def import_key( ctx, management_key, pin, slot, private_key, pin_policy, touch_policy, password ): """ Import a private key from file. Write a private key to one of the PIV slots on the YubiKey. \b SLOT PIV slot of the private key PRIVATE-KEY file containing the private key (use '-' to use stdin) """ if ctx.obj["fips_unready"]: raise CliFail("YubiKey FIPS must be in FIPS approved mode prior to key import.") session = ctx.obj["session"] data = private_key.read() while True: if password is not None: password = password.encode() try: private_key = parse_private_key(data, password) except InvalidPasswordError: logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break _check_key_support_fips( ctx, KEY_TYPE.from_public_key(private_key.public_key()), pin_policy ) _ensure_authenticated(ctx, pin, management_key) session.put_key(slot, private_key, pin_policy, touch_policy) click.echo(f"Private key imported into slot {slot.name}.") @keys.command() @click.pass_context @click_format_option @click_slot_argument @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def attest(ctx, slot, certificate, format): """ Generate an attestation certificate for a key pair. Attestation is used to show that an asymmetric key was generated on the YubiKey and therefore doesn't exist outside the device. \b SLOT PIV slot of the private key CERTIFICATE file to write attestation certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.attest_key(slot) except ApduError: raise CliFail("Attestation failed.") certificate.write(cert.public_bytes(encoding=format)) log_or_echo( f"Attestation certificate for slot {slot} written to {_fname(certificate)}", logger, certificate, ) @keys.command("info") @click.pass_context @click_slot_argument def metadata(ctx, slot): """ Show metadata about a private key. This will show what type of key is stored in a specific slot, whether it was imported into the YubiKey, or generated on-chip, and what the PIN and Touch policies are for using the key. \b SLOT PIV slot of the private key """ session = ctx.obj["session"] try: metadata = session.get_slot_metadata(slot) info = { "Key slot": slot, "Algorithm": metadata.key_type.name, "Origin": "GENERATED" if metadata.generated else "IMPORTED", "PIN required for use": metadata.pin_policy.name, "Touch required for use": metadata.touch_policy.name, } click.echo("\n".join(pretty_print(info))) except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in slot {slot}.") raise @keys.command() @click.pass_context @click_format_option @click_slot_argument @click.option( "-v", "--verify", is_flag=True, help="verify that the public key matches the private key in the slot", ) @click.option("-P", "--pin", help="PIN code (used for --verify)") @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") def export(ctx, slot, public_key_output, format, verify, pin): """ Export a public key corresponding to a stored private key. This command uses several different mechanisms for exporting the public key corresponding to a stored private key, which may fail. If a certificate is stored in the slot it is assumed to contain the correct public key. If this is not the case, the wrong public key will be returned. The --verify flag can be used to verify that the public key being returned matches the private key, by using the slot to create and verify a signature. This may require the PIN to be provided. \b SLOT PIV slot of the private key PUBLIC-KEY file to write the public key to (use '-' to use stdout) """ session = ctx.obj["session"] try: # Prefer metadata if available public_key = session.get_slot_metadata(slot).public_key logger.debug("Public key read from YubiKey") except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in slot {slot}.") raise CliFail(f"Unable to export public key from slot {slot}.") except NotSupportedError: try: # Try attestation public_key = session.attest_key(slot).public_key() logger.debug("Public key read using attestation") except (NotSupportedError, ApduError): try: # Read from stored certificate public_key = session.get_certificate(slot).public_key() logger.debug("Public key read from stored certificate") if verify: # Only needed when read from certificate def do_verify(): with prompt_timeout(timeout=1.0): if not check_key(session, slot, public_key): raise CliFail( "This public key is not tied to the private key in " f"slot {slot}." ) _verify_pin_if_needed(ctx, session, do_verify, pin) except ApduError: raise CliFail(f"Unable to export public key from slot {slot}.") key_encoding = format public_key_output.write( public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) log_or_echo( f"Public key for slot {slot} written to {_fname(public_key_output)}", logger, public_key_output, ) @keys.command("move") @click.pass_context @click_management_key_option @click_pin_option @click.argument("source", callback=click_parse_piv_slot) @click.argument("dest", callback=click_parse_piv_slot) def move_key(ctx, management_key, pin, source, dest): """ Moves a key. Moves a key from one PIV slot into another. \b SOURCE PIV slot of the key to move DEST PIV slot to move the key into """ if source == dest: raise CliFail("SOURCE must be different from DEST.") session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) try: session.move_key(source, dest) click.echo(f"Key moved from slot {source.name} to slot {dest.name}.") except ApduError as e: if e.sw == SW.INCORRECT_PARAMETERS: raise CliFail("DEST slot is not empty.") if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail("No key in SOURCE slot.") raise @keys.command("delete") @click.pass_context @click_management_key_option @click_pin_option @click_slot_argument def delete_key(ctx, management_key, pin, slot): """ Delete a key. Delete a key from a PIV slot on the YubiKey. \b SLOT PIV slot of the key """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) try: session.delete_key(slot) click.echo(f"Key in slot {slot.name} deleted.") except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in slot {slot}.") raise @piv.group("certificates") def cert(): """ Manage certificates. """ def _update_chuid(session): try: chuid_data = session.get_object(OBJECT_ID.CHUID) try: chuid = Chuid.from_bytes(chuid_data) except ValueError: logger.debug("Leaving unparsable CHUID as-is") return if chuid.asymmetric_signature: # Signed CHUID, leave it alone logger.debug("Leaving signed CHUID as-is") return chuid.guid = uuid4().bytes chuid_data = bytes(chuid) logger.debug("Updating CHUID GUID") except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: logger.debug("Generating new CHUID") chuid_data = generate_chuid() else: raise session.put_object(OBJECT_ID.CHUID, chuid_data) @cert.command("import") @click.pass_context @click_management_key_option @click_pin_option @click.option("-p", "--password", help="a password may be needed to decrypt the data") @click.option( "-v", "--verify", is_flag=True, help="verify that the certificate matches the private key in the slot", ) @click.option( "-c", "--compress", is_flag=True, help="compresses the certificate before storing" ) @click_slot_argument @click.argument("cert", type=click.File("rb"), metavar="CERTIFICATE") def import_certificate( ctx, management_key, pin, slot, cert, password, verify, compress ): """ Import an X.509 certificate. Write a certificate to one of the PIV slots on the YubiKey. \b SLOT PIV slot of the certificate CERTIFICATE file containing the certificate (use '-' to use stdin) """ session = ctx.obj["session"] data = cert.read() while True: if password is not None: password = password.encode() try: certs = parse_certificates(data, password) except InvalidPasswordError: logger.debug("Error parsing certificate", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt certificate", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break if len(certs) > 1: # If multiple certs, only import leaf. # Leaf is the cert with a subject that is not an issuer in the chain. leafs = get_leaf_certificates(certs) cert_to_import = leafs[0] else: cert_to_import = certs[0] _ensure_authenticated(ctx, pin, management_key) if verify: public_key = cert_to_import.public_key() try: metadata = session.get_slot_metadata(slot) if metadata.pin_policy in (PIN_POLICY.ALWAYS, PIN_POLICY.ONCE): pivman = ctx.obj["pivman_data"] _verify_pin(ctx, session, pivman, pin) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No private key in slot {slot}.") raise except NotSupportedError: timeout = 1.0 def do_verify(): with prompt_timeout(timeout=timeout): if not check_key(session, slot, public_key): raise CliFail( "The public key of the certificate does not match the " f"private key in slot {slot}." ) _verify_pin_if_needed(ctx, session, do_verify, pin) session.put_certificate(slot, cert_to_import, compress) _update_chuid(session) click.echo(f"Certificate imported into slot {slot.name}") @cert.command("export") @click.pass_context @click_format_option @click_slot_argument @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def export_certificate(ctx, format, slot, certificate): """ Export an X.509 certificate. Reads a certificate from one of the PIV slots on the YubiKey. \b SLOT PIV slot of the certificate CERTIFICATE file to write certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.get_certificate(slot) certificate.write(cert.public_bytes(encoding=format)) log_or_echo( f"Certificate from slot {slot} exported to {_fname(certificate)}", logger, certificate, ) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: raise CliFail("No certificate found.") else: raise CliFail("Failed reading certificate.") @cert.command("generate") @click.pass_context @click_management_key_option @click_pin_option @click_slot_argument @click.argument( "public-key", type=click.File("rb"), metavar="PUBLIC-KEY", required=False ) @click.option( "-s", "--subject", help="subject for the certificate, as an RFC 4514 string", required=True, ) @click.option( "-d", "--valid-days", help="number of days until the certificate expires", type=click.INT, default=365, show_default=True, ) @click_hash_option def generate_certificate( ctx, management_key, pin, slot, public_key, subject, valid_days, hash_algorithm ): """ Generate a self-signed X.509 certificate. A self-signed certificate is generated and written to one of the slots on the YubiKey. A private key must already be present in the corresponding key slot. \b SLOT PIV slot of the certificate PUBLIC-KEY file containing a public key (use '-' to use stdin) """ session = ctx.obj["session"] try: metadata = session.get_slot_metadata(slot) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No private key in slot {slot}.") raise except NotSupportedError: timeout = 1.0 if public_key: data = public_key.read() public_key = serialization.load_pem_public_key(data, default_backend()) elif session.version < (5, 4, 0): raise CliFail("PUBLIC-KEY required for YubiKey prior to 5.4.") else: public_key = session.get_slot_metadata(slot).public_key now = datetime.datetime.now(datetime.timezone.utc) valid_to = now + datetime.timedelta(days=valid_days) if "=" not in subject: # Old style, common name only. subject = "CN=" + subject # This verifies PIN, make sure next action is sign _ensure_authenticated(ctx, pin, management_key, require_pin_and_key=True) try: with prompt_timeout(timeout=timeout): cert = generate_self_signed_certificate( session, slot, public_key, subject, now, valid_to, hash_algorithm ) session.put_certificate(slot, cert) _update_chuid(session) click.echo(f"Certificate generated in slot {slot.name}.") except ApduError: raise CliFail("Certificate generation failed.") @cert.command("request") @click.pass_context @click_pin_option @click_slot_argument @click.argument("public-key", type=click.File("rb"), metavar="PUBLIC-KEY") @click.argument("csr-output", type=click.File("wb"), metavar="CSR") @click.option( "-s", "--subject", help="subject for the requested certificate, as an RFC 4514 string", required=True, ) @click_hash_option def generate_certificate_signing_request( ctx, pin, slot, public_key, csr_output, subject, hash_algorithm ): """ Generate a Certificate Signing Request (CSR). A private key must already be present in the corresponding key slot. \b SLOT PIV slot of the certificate PUBLIC-KEY file containing a public key (use '-' to use stdin) CSR file to write CSR to (use '-' to use stdout) """ session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] data = public_key.read() public_key = serialization.load_pem_public_key(data, default_backend()) if "=" not in subject: # Old style, common name only. subject = "CN=" + subject try: metadata = session.get_slot_metadata(slot) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No private key in slot {slot}.") raise except NotSupportedError: timeout = 1.0 # This verifies PIN, make sure next action is sign _verify_pin(ctx, session, pivman, pin) try: with prompt_timeout(timeout=timeout): csr = generate_csr(session, slot, public_key, subject, hash_algorithm) except ApduError: raise CliFail("Certificate Signing Request generation failed.") csr_output.write(csr.public_bytes(encoding=serialization.Encoding.PEM)) log_or_echo( f"CSR for slot {slot} written to {_fname(csr_output)}", logger, csr_output ) @cert.command("delete") @click.pass_context @click_management_key_option @click_pin_option @click_slot_argument def delete_certificate(ctx, management_key, pin, slot): """ Delete a certificate. Delete a certificate from a PIV slot on the YubiKey. \b SLOT PIV slot of the certificate """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) session.delete_certificate(slot) _update_chuid(session) click.echo(f"Certificate in slot {slot.name} deleted.") @piv.group("objects") def objects(): """ Manage PIV data objects. Examples: \b Write the contents of a file to data object with ID: abc123: $ ykman piv objects import abc123 myfile.txt \b Read the contents of the data object with ID: abc123 into a file: $ ykman piv objects export abc123 myfile.txt \b Generate a random value for CHUID: $ ykman piv objects generate chuid """ @objects.command("export") @click_pin_option @click.pass_context @click_object_argument @click.argument("output", type=click.File("wb"), metavar="OUTPUT") def read_object(ctx, pin, object_id, output): """ Export an arbitrary PIV data object. \b OBJECT name of PIV data object, or ID in HEX OUTPUT file to write object to (use '-' to use stdout) """ session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] if ctx.obj["fips_unready"] and object_id in ( OBJECT_ID.PRINTED, OBJECT_ID.FINGERPRINTS, OBJECT_ID.FACIAL, OBJECT_ID.IRIS, ): raise CliFail( "YubiKey FIPS must be in FIPS approved mode to export this object." ) def do_read_object(retry=True): try: output.write(session.get_object(object_id)) log_or_echo( f"Exported object {object_id} to {_fname(output)}", logger, output ) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: raise CliFail("No data found.") elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED and retry: _verify_pin(ctx, session, pivman, pin) do_read_object(retry=False) else: raise do_read_object() @objects.command("import") @click_pin_option @click_management_key_option @click.pass_context @click_object_argument @click.argument("data", type=click.File("rb"), metavar="DATA") def write_object(ctx, pin, management_key, object_id, data): """ Write an arbitrary PIV object. Write a PIV object by providing the object id. Yubico writable PIV objects are available in the range 5f0000 - 5fffff. \b OBJECT name of PIV data object, or ID in HEX DATA file containing the data to be written (use '-' to use stdin) """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) try: session.put_object(object_id, data.read()) click.echo("Object imported.") except ApduError as e: if e.sw == SW.INCORRECT_PARAMETERS: raise CliFail("Something went wrong, is the object id valid?") raise CliFail("Error writing object.") @objects.command("generate") @click_pin_option @click_management_key_option @click.pass_context @click_object_argument def generate_object(ctx, pin, management_key, object_id): """ Generate and write data for a supported data object. \b Supported data objects: "CHUID" (Card Holder Unique ID) "CCC" (Card Capability Container) \b OBJECT name of PIV data object, or ID in HEX """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) if OBJECT_ID.CHUID == object_id: session.put_object(OBJECT_ID.CHUID, generate_chuid()) elif OBJECT_ID.CAPABILITY == object_id: session.put_object(OBJECT_ID.CAPABILITY, generate_ccc()) else: raise CliFail("Unsupported object ID for generate.") click.echo("Object generated.") def _prompt_management_key(prompt="Enter a management key [blank to use default key]"): management_key = click_prompt( prompt, default="", hide_input=True, show_default=False ) if management_key == "": return DEFAULT_MANAGEMENT_KEY try: return bytes.fromhex(management_key) except Exception: raise CliFail("Management key has the wrong format.") def _prompt_pin(prompt="Enter PIN"): return click_prompt(prompt, default="", hide_input=True, show_default=False) def _ensure_authenticated( ctx, pin=None, management_key=None, require_pin_and_key=False, mgm_key_prompt=None, no_prompt=False, ): session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] if pivman.has_protected_key and not management_key: _verify_pin(ctx, session, pivman, pin, no_prompt=no_prompt) return True _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=no_prompt) if require_pin_and_key: # Ensure verify was the last thing we did _verify_pin(ctx, session, pivman, pin, no_prompt=no_prompt) return True def _verify_pin(ctx, session, pivman, pin, no_prompt=False): if not pin: if no_prompt: raise CliFail("PIN required.") else: pin = _prompt_pin() try: session.verify_pin(pin) if pivman.has_derived_key: with prompt_timeout(): session.authenticate(derive_management_key(pin, pivman.salt)) session.verify_pin(pin) # Ensure verify was the last thing we did elif pivman.has_stored_key: pivman_prot = get_pivman_protected_data(session) with prompt_timeout(): session.authenticate(pivman_prot.key) session.verify_pin(pin) # Ensure verify was the last thing we did except InvalidPinError as e: attempts = e.attempts_remaining if attempts > 0: raise CliFail(f"PIN verification failed, {attempts} tries left.") else: raise CliFail("PIN is blocked.") except Exception: raise CliFail("PIN verification failed.") def _verify_pin_if_needed(ctx, session, func, pin=None, no_prompt=False): try: return func() except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: logger.debug("Command failed due to PIN required, verifying and retrying") pivman = ctx.obj["pivman_data"] _verify_pin(ctx, session, pivman, pin, no_prompt) else: raise return func() def _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=False): if not management_key: if no_prompt: raise CliFail("Management key required.") else: if mgm_key_prompt is None: management_key = _prompt_management_key() else: management_key = _prompt_management_key(mgm_key_prompt) try: with prompt_timeout(): session.authenticate(management_key) except Exception: raise CliFail("Authentication with management key failed.") def _check_key_support_fips(ctx, key_type, pin_policy): info = ctx.obj["info"] if CAPABILITY.PIV in info.fips_capable: if key_type in (KEY_TYPE.RSA1024, KEY_TYPE.X25519): raise CliFail(f"Key type {key_type.name} not supported on YubiKey FIPS.") if pin_policy in (PIN_POLICY.NEVER,): raise CliFail( f"PIN policy {pin_policy.name} not supported on YubiKey FIPS." ) yubikey_manager-5.6.1/ykman/_cli/securitydomain.py0000644000175000017500000003241314777516541021724 0ustar winniewinnie# Copyright (c) 2024 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core.smartcard import SmartCardConnection, ApduError, SW from yubikit.core.smartcard.scp import ( ScpKid, KeyRef, Scp03KeyParams, Scp11KeyParams, StaticKeys, ) from yubikit.management import CAPABILITY from yubikit.securitydomain import SecurityDomainSession from ..util import ( parse_private_key, parse_certificates, InvalidPasswordError, ) from .util import ( CliFail, click_group, click_force_option, click_postpone_execution, click_callback, click_prompt, HexIntParamType, pretty_print, get_scp_params, organize_scp11_certificates, log_or_echo, ) from cryptography import x509 from cryptography.hazmat.primitives import serialization from typing import Dict, List, Any import click import logging import sys logger = logging.getLogger(__name__) @click_group( "sd", connections=[SmartCardConnection], hidden="--full-help" not in sys.argv ) @click.pass_context @click_postpone_execution def securitydomain(ctx): """ Manage the Security Domain application, which holds keys for SCP. """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) session = SecurityDomainSession(conn) scp_params = get_scp_params(ctx, CAPABILITY(-1), conn) if scp_params: session.authenticate(scp_params) ctx.obj["authenticated"] = ( isinstance(scp_params, Scp03KeyParams) or isinstance(scp_params, Scp11KeyParams) and scp_params.ref.kid == ScpKid.SCP11a ) ctx.obj["session"] = session @securitydomain.command() @click.pass_context def info(ctx): """ List keys in the Security Domain of the YubiKey. """ sd = ctx.obj["session"] data: List[Any] = [] cas = sd.get_supported_ca_identifiers() for ref in sd.get_key_information().keys(): if ref.kid == 1: # SCP03 data.append( { f"SCP03 (KID=0x01-0x03, KVN=0x{ref.kvn:02X})": [ "Default key set" if ref.kvn == 0xFF else "Imported key set" ] } ) elif ref.kid in (2, 3): # SCP03 always in full key sets continue else: # SCP11 inner: Dict[str, Any] = {} if ref in cas: inner["CA Key Identifier"] = ":".join(f"{b:02X}" for b in cas[ref]) try: inner["Certificate chain"] = [ c.subject.rfc4514_string() for c in sd.get_certificate_bundle(ref) ] except ApduError: pass try: name = ScpKid(ref.kid).name except ValueError: name = "SCP11 OCE CA" data.append({f"{name} (KID=0x{ref.kid:02X}, KVN=0x{ref.kvn:02X})": inner}) click.echo("\n".join(pretty_print({"SCP keys": data}))) @securitydomain.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all Security Domain data. This action will wipe all keys and restore factory settings for the Security Domain on the YubiKey. """ if "scp" in ctx.obj: raise CliFail("Reset must be performed without an active SCP session.") force or click.confirm( "WARNING! This will delete all stored Security Domain data and restore factory " "settings. Proceed?", abort=True, err=True, ) click.echo("Resetting Security Domain data...") ctx.obj["session"].reset() click.echo( "Reset complete. Security Domain data has been cleared from the YubiKey." ) click.echo("Your YubiKey now has the default SCP key set") @securitydomain.group() def keys(): """Manage SCP keys.""" def _require_auth(ctx): if not ctx.obj["authenticated"]: raise CliFail("This command requires authentication, invoke ykman with --scp.") def _fname(fobj): return getattr(fobj, "name", fobj) @click_callback() def click_parse_scp_ref(ctx, param, val): try: return KeyRef(*val) except AttributeError: raise ValueError(val) class ScpKidParamType(HexIntParamType): name = "kid" def convert(self, value, param, ctx): if isinstance(value, int): return value try: name = value.upper()[:-1] + value[-1].lower() return ScpKid[name] except KeyError: try: if value.lower().startswith("0x"): return int(value[2:], 16) if ":" in value: return int(value.replace(":", ""), 16) return int(value) except ValueError: self.fail(f"{value!r} is not a valid integer", param, ctx) click_key_argument = click.argument( "key", metavar="KID KVN", type=(ScpKidParamType(), HexIntParamType()), callback=click_parse_scp_ref, ) @keys.command("generate") @click.pass_context @click_key_argument @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") @click.option( "-r", "--replace-kvn", type=HexIntParamType(), default=0, help="replace an existing key of the same type (same KID)", ) def generate_key(ctx, key, public_key_output, replace_kvn): """ Generate an asymmetric key pair. The private key is generated on the YubiKey, and written to one of the slots. \b KID KVN key reference for the new key PUBLIC-KEY file containing the generated public key (use '-' to use stdout) """ _require_auth(ctx) valid = (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c) if key.kid not in valid: values_str = ", ".join(f"0x{v:x} ({v.name})" for v in valid) raise CliFail(f"KID must be one of {values_str}.") session = ctx.obj["session"] try: public_key = session.generate_ec_key(key, replace_kvn=replace_kvn) except ApduError as e: if e.sw == SW.NO_SPACE: raise CliFail("No space left for SCP keys.") raise key_encoding = serialization.Encoding.PEM public_key_output.write( public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) log_or_echo( f"Private key generated for {key}, public key written to " f"{_fname(public_key_output)}", logger, public_key_output, ) @keys.command("import") @click.pass_context @click_key_argument @click.argument("input", metavar="INPUT") @click.option("-p", "--password", help="password used to decrypt the file (if needed)") @click.option( "-r", "--replace-kvn", type=HexIntParamType(), default=0, help="replace an existing key of the same type (same KID)", ) def import_key(ctx, key, input, password, replace_kvn): """ Import a key or certificate. KID 0x01 expects the input to be a ":"-separated triple of K-ENC:K-MAC:K-DEK. KID 0x11, 0x13, and 0x15 expect the input to be a file containing a private key and (optionally) a certificate chain. KID 0x10, 0x20-0x2F expect the file to contain a CA-KLOC certificate. \b KID KVN key reference for the new key INPUT SCP03 keyset, or input file (use '-' to use stdin) """ _require_auth(ctx) session = ctx.obj["session"] if key.kid == ScpKid.SCP03: session.put_key(key, StaticKeys(*[bytes.fromhex(k) for k in input.split(":")])) return file = click.File("rb").convert(input, None, ctx) data = file.read() if key.kid in (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c): # Expect a private key while True: if password is not None: password = password.encode() try: target = parse_private_key(data, password) break except InvalidPasswordError: logger.debug("Error parsing file", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt file", default="", hide_input=True, show_default=False, ) else: password = None click.echo("Wrong password.") ca, bundle, leaf = organize_scp11_certificates( parse_certificates(data, password) ) if leaf: bundle = list(bundle) + [leaf] elif key.kid in (0x10, *range(0x20, 0x30)): # Public CA key ca, inter, leaf = organize_scp11_certificates(parse_certificates(data, None)) if not ca: raise CliFail("Input does not contain a valid CA-KLOC certificate.") target = ca.public_key() bundle = None else: raise CliFail(f"Invalid value for KID={key.kid:x}.") try: session.put_key(key, target, replace_kvn) click.echo(f"Key stored for {target}.") except ApduError as e: if e.sw == SW.NO_SPACE: raise CliFail("No space left for SCP keys.") raise # If we have a bundle of intermediate certificates, store them if bundle: session.store_certificate_bundle(key, bundle) click.echo("Certificate bundle stored.") # If the CA has a Subject Key Identifer we should store it if ca: ski = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) session.store_ca_issuer(key, ski.value.digest) click.echo("CA key identifier stored.") @keys.command() @click.pass_context @click_key_argument @click.argument("certificates-output", type=click.File("wb"), metavar="OUTPUT") def export(ctx, key, certificates_output): """ Export certificate chain for a key. \b KID KVN key reference to output certificate chain for OUTPUT file to write the certificate chain to (use '-' to use stdout) """ session = ctx.obj["session"] pems = [ cert.public_bytes(encoding=serialization.Encoding.PEM) for cert in reversed(session.get_certificate_bundle(key)) ] if pems: certificates_output.write(b"".join(pems)) log_or_echo( f"Certificate chain for {key} written to {_fname(certificates_output)}", logger, certificates_output, ) else: raise CliFail(f"No certificate chain stored for {key}.") @keys.command("delete") @click.pass_context @click_key_argument @click_force_option def delete_key(ctx, key, force): """ Delete a key or keyset. Deletes the key or keyset with the given KID and KVN. Set either KID or KVN to 0 to use it as a wildcard and delete all keys matching the specific KID or KVN \b KID KVN key reference for the key to delete """ _require_auth(ctx) session = ctx.obj["session"] force or click.confirm( "WARNING! This will delete all matching SCP keys. Proceed?", abort=True, err=True, ) try: session.delete_key(key.kid, key.kvn) click.echo("SCP key deleted.") except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in {key}.") if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail( "This would delete ALL SCP keys, use the reset command instead." ) raise @keys.command("set-allowlist") @click.pass_context @click_key_argument @click.argument("serials", nargs=-1, type=HexIntParamType()) def set_allowlist(ctx, key, serials): """ Set an allowlist of certificate serial numbers for a key. Each certificate in the chain used when authenticating an SCP11a/c session will be checked and rejected if their serial number is not in this allowlist. \b KID KVN key reference to set the allowlist for SERIALS serial numbers of certificates to allow (space separated) """ _require_auth(ctx) session = ctx.obj["session"] session.store_allowlist(key, serials) click.echo(f"SCP serial number allowlist set for {key}.") yubikey_manager-5.6.1/ykman/_cli/util.py0000644000175000017500000003357014777516541017647 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..util import parse_certificates from yubikit.core import ( TRANSPORT, Version, require_version, NotSupportedError, ApplicationNotAvailableError, ) from yubikit.core.smartcard import SmartCardConnection, ApduError from yubikit.core.smartcard.scp import ScpKid, KeyRef, ScpKeyParams, Scp11KeyParams from yubikit.management import DeviceInfo, CAPABILITY from yubikit.oath import parse_b32_key from yubikit.securitydomain import SecurityDomainSession from collections import OrderedDict from collections.abc import MutableMapping from cryptography.hazmat.primitives import serialization from cryptography import x509 from contextlib import contextmanager from threading import Timer from enum import Enum from typing import Optional, Sequence, Tuple, List import functools import click import sys import logging logger = logging.getLogger(__name__) class _YkmanCommand(click.Command): def __init__(self, *args, **kwargs): connections = kwargs.pop("connections", None) if connections and not isinstance(connections, list): connections = [connections] # Single type self.connections = connections super().__init__(*args, **kwargs) def get_short_help_str(self, limit=45): help_str = super().get_short_help_str(limit) return help_str[0].lower() + help_str[1:].rstrip(".") def get_help_option(self, ctx): option = super().get_help_option(ctx) option.help = "show this message and exit" return option class _YkmanGroup(_YkmanCommand, click.Group): command_class = _YkmanCommand def add_command(self, cmd, name=None): if not isinstance(cmd, (_YkmanGroup, _YkmanCommand)): raise ValueError( f"Command {cmd} does not inherit from _YkmanGroup or _YkmanCommand" ) super().add_command(cmd, name) def list_commands(self, ctx): return sorted( self.commands, key=lambda c: (isinstance(self.commands[c], click.Group), c) ) _YkmanGroup.group_class = _YkmanGroup def click_group(*args, connections=None, **kwargs): return click.group( *args, cls=_YkmanGroup, connections=connections, **kwargs, ) def click_command(*args, connections=None, **kwargs): return click.command( *args, cls=_YkmanCommand, connections=connections, **kwargs, ) class EnumChoice(click.Choice): """ Use an enum's member names as the definition for a choice option. Enum member names MUST be all uppercase. Options are not case sensitive. Underscores in enum names are translated to dashes in the option choice. """ def __init__(self, choices_enum, hidden=[]): self.choices_names = [ v.name.replace("_", "-") for v in choices_enum if v not in hidden ] super().__init__( self.choices_names, case_sensitive=False, ) self.hidden = hidden self.choices_enum = choices_enum def convert(self, value, param, ctx): if isinstance(value, self.choices_enum): return value try: # Allow aliases self.choices = [ k.replace("_", "-") for k, v in self.choices_enum.__members__.items() if v not in self.hidden ] name = super().convert(value, param, ctx).replace("-", "_") finally: self.choices = self.choices_names return self.choices_enum[name] class HexIntParamType(click.ParamType): name = "integer" def convert(self, value, param, ctx): if isinstance(value, int): return value try: if value.lower().startswith("0x"): return int(value[2:], 16) if ":" in value: return int(value.replace(":", ""), 16) return int(value) except ValueError: self.fail(f"{value!r} is not a valid integer", param, ctx) def click_callback(invoke_on_missing=False): def wrap(f): @functools.wraps(f) def inner(ctx, param, val): if not invoke_on_missing and not param.required and val is None: return None try: return f(ctx, param, val) except ValueError as e: ctx.fail(f'Invalid value for "{param.name}": {str(e)}') return inner return wrap @click_callback() def click_parse_format(ctx, param, val): if val == "PEM": return serialization.Encoding.PEM elif val == "DER": return serialization.Encoding.DER else: raise ValueError(val) click_force_option = click.option( "-f", "--force", is_flag=True, help="confirm the action without prompting" ) click_format_option = click.option( "-F", "--format", type=click.Choice(["PEM", "DER"], case_sensitive=False), default="PEM", show_default=True, help="encoding format", callback=click_parse_format, ) class YkmanContextObject(MutableMapping): def __init__(self): self._objects = OrderedDict() self._resolved = False def add_resolver(self, key, f): if self._resolved: f = f() self._objects[key] = f def resolve(self): if not self._resolved: self._resolved = True for k, f in self._objects.copy().items(): self._objects[k] = f() def __getitem__(self, key): self.resolve() return self._objects[key] def __setitem__(self, key, value): if not self._resolved: raise ValueError("BUG: Attempted to set item when unresolved.") self._objects[key] = value def __delitem__(self, key): del self._objects[key] def __len__(self): return len(self._objects) def __iter__(self): return iter(self._objects) def click_postpone_execution(f): @functools.wraps(f) def inner(*args, **kwargs): click.get_current_context().obj.add_resolver(str(f), lambda: f(*args, **kwargs)) return inner @click_callback() def click_parse_b32_key(ctx, param, val): return parse_b32_key(val) def click_prompt(prompt, err=True, **kwargs): """Replacement for click.prompt to better work when piping input to the command. Note that we change the default of err to be True, since that's how we typically use it. """ logger.debug(f"Input requested ({prompt})") if not sys.stdin.isatty(): # Piped from stdin, see if there is data logger.debug("TTY detected, reading line from stdin...") line = sys.stdin.readline() if line: return line.rstrip("\n") logger.debug("No data available on stdin") # No piped data, use standard prompt logger.debug("Using interactive prompt...") return click.prompt(prompt, err=err, **kwargs) def prompt_for_touch(): logger.debug("Prompting user to touch YubiKey...") try: click.echo("Touch your YubiKey...", err=True) except Exception: sys.stderr.write("Touch your YubiKey...\n") @contextmanager def prompt_timeout(timeout=0.5): timer = Timer(timeout, prompt_for_touch) try: timer.start() yield None finally: timer.cancel() class CliFail(Exception): def __init__(self, message, status=1): super().__init__(message) self.status = status def pretty_print(value, level: int = 0) -> Sequence[str]: """Pretty-prints structured data, as that returned by get_diagnostics. Returns a list of strings which can be printed as lines. """ indent = " " * level lines: List[str] = [] if isinstance(value, list): for v in value: lines.extend(pretty_print(v, level)) elif isinstance(value, dict): res = [] mlen = 0 for k, v in value.items(): if isinstance(k, Enum): k = k.name or str(k) p = pretty_print(v, level + 1) ml = len(p) > 1 or isinstance(v, (list, dict)) if not ml: mlen = max(mlen, len(k)) res.append((k, p, ml)) mlen += len(indent) + 1 for k, p, ml in res: k_line = f"{indent}{k}:".ljust(mlen) if ml: lines.append(k_line) lines.extend(p) if lines[-1] != "": lines.append("") else: lines.append(f"{k_line} {p[0].lstrip()}") elif isinstance(value, bytes): lines.append(f"{indent}{value.hex()}") else: lines.append(f"{indent}{value}") return lines def check_version(version: Version, req: Tuple[int, int, int]) -> bool: try: require_version(version, req) return True except NotSupportedError: return False def is_yk4_fips(info: DeviceInfo) -> bool: return info.version[0] == 4 and info.is_fips def _fileno(f) -> int: try: return f.fileno() except Exception: return -1 def log_or_echo(message: str, log: logging.Logger, *files) -> None: fno = _fileno(sys.stdout) if any(_fileno(f) == fno for f in files): log.info(message) else: click.echo(f"{message}.") def find_scp11_params( connection: SmartCardConnection, kid: int, kvn: int, ca: Optional[bytes] = None ) -> Scp11KeyParams: try: scp = SecurityDomainSession(connection) except ApplicationNotAvailableError: raise ValueError("Security Domain application not available") if not kvn: if ca: # Find by CA for ref, ca_check in scp.get_supported_ca_identifiers(klcc=True).items(): if ca_check == ca: if not kid or ref.kid == kid: kid, kvn = ref break # Find any matching KID for ref in scp.get_key_information().keys(): if ref.kid == kid: kvn = ref.kvn break else: raise ValueError(f"No SCP key found matching KID=0x{kid:x}") try: ref = KeyRef(kid, kvn) chain = scp.get_certificate_bundle(ref) if not chain: raise ValueError(f"No certificate chain stored for {ref}") if ca: logger.debug("Validating KLCC CA using supplied file") parent = parse_certificates(ca, None)[0] for cert in chain: # Requires cryptography >= 40 cert.verify_directly_issued_by(parent) parent = cert logger.info("KLCC CA validated") else: logger.info("No CA supplied, skipping KLCC CA validation") pub_key = chain[-1].public_key() return Scp11KeyParams(ref, pub_key) except ApduError: raise ValueError(f"Unable to get SCP key paramaters ({ref})") def get_scp_params( ctx: click.Context, capability: CAPABILITY, connection: SmartCardConnection ) -> Optional[ScpKeyParams]: # Explicit SCP resolve = ctx.obj.get("scp") if resolve: return resolve(connection) # Automatic SCP11b if needed info = ctx.obj["info"] if connection.transport == TRANSPORT.NFC and capability in info.fips_capable: logger.debug("Attempt to find SCP11b key") try: params = find_scp11_params(connection, ScpKid.SCP11b, 0) logger.info("SCP11b key found, using for FIPS capable applications") return params except ValueError: logger.debug("No SCP11b key found, not using SCP") return None def organize_scp11_certificates( certificates: Sequence[x509.Certificate], ) -> Tuple[ Optional[x509.Certificate], Sequence[x509.Certificate], Optional[x509.Certificate] ]: if not certificates: return None, [], None # Order leaf-last ordered, certificates = [certificates[0]], list(certificates[1:]) while certificates: for c in certificates: if c.subject == ordered[0].issuer: certificates.remove(c) ordered.insert(0, c) break if ordered[-1].subject == c.issuer: certificates.remove(c) ordered.append(c) break else: raise ValueError("Incomplete chain of certificates") ca, leaf = None, None # Check if root is self-signed: peek = ordered[0] if peek.issuer == peek.subject: ca = ordered.pop(0) # Check if leaf has keyAgreement policy: if ordered: kue = ordered[-1].extensions.get_extension_for_oid(x509.ExtensionOID.KEY_USAGE) if kue.value.key_agreement: leaf = ordered.pop() return ca, ordered, leaf yubikey_manager-5.6.1/ykman/_cli/__init__.py0000644000175000017500000000253314777516541020424 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. yubikey_manager-5.6.1/ykman/_cli/script.py0000644000175000017500000000641514777516541020174 0ustar winniewinnie# Copyright (c) 2021 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .util import click_force_option, click_command from .. import scripting # noqa - make sure this file gets included by PyInstaller. import sys import click import logging logger = logging.getLogger(__name__) _WARNING = """ WARNING: Never run a script without fully understanding what it does! Scripts are very powerful, and have the power to harm to both your YubiKey and your computer. ONLY run scripts that you fully trust! """ def _add_warning(obj): obj.__doc__ = obj.__doc__.format("\n ".join(_WARNING.splitlines())) return obj @click_command( "script", context_settings=dict(ignore_unknown_options=True), ) @click.pass_context @click.option( "-s", "--site-dir", type=click.Path(exists=True), multiple=True, metavar="DIR", help="specify additional path(s) to load python modules from", ) @click.argument("script", type=click.File("rb"), metavar="FILE") @click.argument("arguments", nargs=-1, type=click.UNPROCESSED) @click_force_option @_add_warning def run_script(ctx, site_dir, script, arguments, force): """ Run a python script. {0} Argument can be passed to the script by adding them after the end of the command. These will be accessible inside the script as sys.argv, with the script name as the initial value. For more information on scripting, see the "Scripting" page in the documentation. Examples: \b Run the file "myscript.py", passing arguments "123456" and "indata.csv": $ ykman script myscript.py 123456 indata.csv """ force or click.confirm( f"{_WARNING}\n" "You can bypass this message by running the command with the --force flag.\n\n" "Run script?", abort=True, err=True, ) for sd in site_dir: logger.debug("Add %s to path.", sd) sys.path.append(sd) script_body = script.read() sys.argv = [script.name, *arguments] exec(script_body, {}) # nosec yubikey_manager-5.6.1/ykman/_cli/openpgp.py0000644000175000017500000005032114777516541020333 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.openpgp import ( OpenPgpSession, UIF, PIN_POLICY, KEY_REF as _KEY_REF, KEY_STATUS, ) from yubikit.management import CAPABILITY from ..util import parse_certificates, parse_private_key from ..openpgp import get_openpgp_info, safe_reset, get_key_info from .util import ( CliFail, click_force_option, click_format_option, click_postpone_execution, click_prompt, click_group, EnumChoice, pretty_print, get_scp_params, log_or_echo, ) from enum import IntEnum import logging import click logger = logging.getLogger(__name__) class KEY_REF(IntEnum): SIG = 0x01 DEC = 0x02 AUT = 0x03 ATT = 0x81 ENC = 0x02 # Alias for backwards compatibility, will be removed in ykman 6 def __getattribute__(self, name: str): return _KEY_REF(self).__getattribute__(name) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def openpgp(ctx): """ Manage the OpenPGP application. Examples: \b Set the retries for PIN, Reset Code and Admin PIN to 10: $ ykman openpgp access set-retries 10 10 10 \b Require touch to use the authentication key: $ ykman openpgp keys set-touch aut on """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.OPENPGP, conn) try: ctx.obj["session"] = OpenPgpSession(conn, scp_params) except ApduError as e: if ( e.sw == SW.CONDITIONS_NOT_SATISFIED and not scp_params and dev.transport == TRANSPORT.NFC ): raise CliFail("Unable to manage OpenPGP over NFC without SCP") elif e.sw == SW.MEMORY_FAILURE: if ctx.invoked_subcommand == "reset": ctx.obj["conn"] = conn else: raise CliFail( "Memory corruption detected, OpenPGP needs to be reset using " "'ykman openpgp reset'" ) else: raise @openpgp.command() @click.pass_context def info(ctx): """ Display general status of the OpenPGP application. """ info = ctx.obj["info"] data = get_openpgp_info(ctx.obj["session"]) if CAPABILITY.OPENPGP in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data["FIPS approved"] = CAPABILITY.OPENPGP in info.fips_approved click.echo("\n".join(pretty_print(data))) @openpgp.command() @click_force_option @click.pass_context def reset(ctx, force): """ Reset all OpenPGP data. This action will wipe all OpenPGP data, and set all PINs to their default values. The attestation key and certificate will NOT be reset. """ force or click.confirm( "WARNING! This will delete all stored OpenPGP keys and data and restore " "factory settings. Proceed?", abort=True, err=True, ) click.echo("Resetting OpenPGP data, don't remove the YubiKey...") if "session" in ctx.obj: ctx.obj["session"].reset() else: safe_reset(ctx.obj["conn"]) logger.info("OpenPGP application data reset") click.echo( "Reset complete. OpenPGP data has been cleared and default PINs are set." ) echo_default_pins() def echo_default_pins(): click.echo("PIN: 123456") click.echo("Reset code: NOT SET") click.echo("Admin PIN: 12345678") @openpgp.group("access") def access(): """Manage PIN, Reset Code, and Admin PIN.""" @access.command("set-retries") @click.argument("user-pin-retries", type=click.IntRange(1, 99), metavar="PIN-RETRIES") @click.argument( "reset-code-retries", type=click.IntRange(1, 99), metavar="RESET-CODE-RETRIES" ) @click.argument( "admin-pin-retries", type=click.IntRange(1, 99), metavar="ADMIN-PIN-RETRIES" ) @click.option("-a", "--admin-pin", help="admin PIN for OpenPGP") @click_force_option @click.pass_context def set_pin_retries( ctx, admin_pin, user_pin_retries, reset_code_retries, admin_pin_retries, force ): """ Set the number of retry attempts for the User PIN, Reset Code, and Admin PIN. """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) resets_pins = session.version < (4, 0, 0) if resets_pins: click.echo("WARNING: Setting PIN retries will reset the values for all 3 PINs!") if force or click.confirm( f"Set PIN retry counters to: {user_pin_retries} {reset_code_retries} " f"{admin_pin_retries}?", abort=True, err=True, ): session.verify_admin(admin_pin) session.set_pin_attempts( user_pin_retries, reset_code_retries, admin_pin_retries ) click.echo("Number of PIN/Reset Code/Admin PIN retries set.") if resets_pins: click.echo("Default values have been restored:") 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 the User PIN. The PIN has a minimum length of 6 (or 8, for YubiKey 5.7+ FIPS when not using KDF), and supports any type of alphanumeric characters. """ session = ctx.obj["session"] 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, ) try: session.change_pin(pin, new_pin) click.echo("User PIN has been changed.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("PIN does not meet complexity requirement.") raise @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 the Reset Code. The Reset Code has a minimum length of 6, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] 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, ) session.verify_admin(admin_pin) try: session.set_reset_code(reset_code) click.echo("Reset Code has been changed.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Reset Code does not meet complexity requirement.") raise @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 the Admin PIN. The Admin PIN has a minimum length of 8, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] 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, ) try: session.change_admin(admin_pin, new_admin_pin) click.echo("Admin PIN has been changed.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Admin PIN does not meet complexity requirement.") raise @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 the PIN (using Reset Code or Admin PIN). If the PIN is lost or blocked you can reset it to a new value using the Reset Code. Alternatively, the Admin PIN can be used (using the "-a, --admin-pin" option) instead of the Reset Code. The new PIN has a minimum length of 6, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] 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: session.verify_admin(admin_pin) try: session.reset_pin(new_pin, reset_code) click.echo("User PIN has been changed.") except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("New PIN does not meet complexity requirement.") raise @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 the Signature PIN policy. 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. \b POLICY signature PIN policy to set (always, once) """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: session.verify_admin(admin_pin) session.set_signature_pin_policy(policy) click.echo("Signature PIN policy has been set.") except Exception: raise CliFail("Failed to set new Signature PIN policy.") @openpgp.group("keys") def keys(): """Manage private keys.""" @keys.command("info") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) def metadata(ctx, key): """ Show metadata about a private key. This will show what type of key is stored in a specific slot, whether it was imported into the YubiKey, or generated on-chip, and what the Touch policy is for using the key. \b KEY key slot to set (sig, dec, aut or att) """ session = ctx.obj["session"] discretionary = session.get_application_related_data().discretionary status = discretionary.key_information.get(key) if status == KEY_STATUS.NONE: raise CliFail(f"No key stored in slot {key.name}.") info = get_key_info(discretionary, key, status) click.echo("\n".join(pretty_print(info))) @keys.command("set-touch") @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("policy", metavar="POLICY", type=EnumChoice(UIF)) @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click_force_option @click.pass_context def set_touch(ctx, key, policy, admin_pin, force): """ Set the touch policy for OpenPGP keys. The touch policy is used to require user interaction for all operations using the private key on the YubiKey. The touch policy is set individually for each key slot. To see the current touch policy, run the "openpgp info" subcommand. WARNING: Setting the touch policy of the attestation key to "fixed" cannot be undone without replacing the attestation private key. Touch policies: \b Off (default) no touch required On touch required Fixed touch required, can't be disabled without deleting the private key Cached touch required, cached for 15s after use Cached-Fixed touch required, cached for 15s after use, can't be disabled without deleting the private key \b KEY key slot to set (sig, dec, aut or att) POLICY touch policy to set (on, off, fixed, cached or cached-fixed) """ session = ctx.obj["session"] policy_name = policy.name.lower().replace("_", "-") if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) prompt = f"Set touch policy of {key.name} key to {policy_name}?" if policy.is_fixed: prompt = ( "WARNING: This touch policy cannot be changed without deleting the " + "corresponding key slot!\n" + prompt ) if force or click.confirm(prompt, abort=True, err=True): try: session.verify_admin(admin_pin) session.set_uif(key, policy) click.echo(f"Touch policy for slot {key.name} set.") except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("Touch policy not allowed.") raise CliFail("Failed to set touch policy.") @keys.command("import") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") def import_key(ctx, key, private_key, admin_pin): """ Import a private key for OpenPGP attestation. The attestation key is by default pre-generated during production with a Yubico-issued key and certificate. WARNING: This private key cannot be recovered once overwritten! \b KEY key slot to import to (only 'att' supported) PRIVATE-KEY file containing the private key (use '-' to use stdin) """ session = ctx.obj["session"] if key != KEY_REF.ATT: ctx.fail("Importing keys is only supported for the Attestation slot.") if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: private_key = parse_private_key(private_key.read(), password=None) except Exception: raise CliFail("Failed to parse private key.") try: session.verify_admin(admin_pin) session.put_key(key, private_key) click.echo(f"Private key imported for slot {key.name}.") except Exception: raise CliFail("Failed to import attestation key.") @keys.command() @click.pass_context @click.option("-P", "--pin", help="PIN code") @click_format_option @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF, hidden=[KEY_REF.ATT])) @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def attest(ctx, key, certificate, pin, format): """ Generate an attestation certificate for a key. Attestation is used to show that an asymmetric key was generated on the YubiKey and therefore doesn't exist outside the device. \b KEY key slot to attest (sig, dec, aut) CERTIFICATE file to write attestation certificate to (use '-' to use stdout) """ session = ctx.obj["session"] if not pin: pin = click_prompt("Enter PIN", hide_input=True) try: cert = session.get_certificate(key) except ValueError: cert = None if not cert or click.confirm( f"There is already data stored in the certificate slot for {key.value}, " "do you want to overwrite it?" ): touch_policy = session.get_uif(KEY_REF.ATT) if touch_policy in [UIF.ON, UIF.FIXED]: click.echo("Touch the YubiKey sensor...") try: session.verify_pin(pin) cert = session.attest_key(key) certificate.write(cert.public_bytes(encoding=format)) log_or_echo( f"Attestation certificate for slot {key.name} written to " f"{_fname(certificate)}", logger, certificate, ) except Exception: raise CliFail("Attestation failed.") @openpgp.group("certificates") def certificates(): """ Manage certificates. """ @certificates.command("export") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click_format_option @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def export_certificate(ctx, key, format, certificate): """ Export an OpenPGP certificate. \b KEY key slot to read from (sig, dec, aut, or att) CERTIFICATE file to write certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.get_certificate(key) except ValueError: raise CliFail(f"Failed to read certificate from slot {key.name}.") certificate.write(cert.public_bytes(encoding=format)) log_or_echo( f"Certificate for slot {key.name} exported to {_fname(certificate)}", logger, certificate, ) @certificates.command("delete") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) def delete_certificate(ctx, key, admin_pin): """ Delete an OpenPGP certificate. \b KEY key slot to delete certificate from (sig, dec, aut, or att) """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: session.verify_admin(admin_pin) session.delete_certificate(key) click.echo(f"Certificate for slot {key.name} deleted.") except Exception: raise CliFail("Failed to delete certificate.") @certificates.command("import") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("cert", type=click.File("rb"), metavar="CERTIFICATE") def import_certificate(ctx, key, cert, admin_pin): """ Import an OpenPGP certificate. \b KEY key slot to import certificate to (sig, dec, aut, or att) CERTIFICATE file containing the certificate (use '-' to use stdin) """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: certs = parse_certificates(cert.read(), password=None) except Exception: raise CliFail("Failed to parse certificate.") if len(certs) != 1: raise CliFail("Can only import one certificate.") try: session.verify_admin(admin_pin) session.put_certificate(key, certs[0]) click.echo(f"Certificate imported into slot {key.name}") except Exception: raise CliFail("Failed to import certificate.") yubikey_manager-5.6.1/ykman/_cli/hsmauth.py0000644000175000017500000005443214777516541020343 0ustar winniewinnie# Copyright (c) 2023 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT from yubikit.core.smartcard import SmartCardConnection, ApduError, SW from yubikit.hsmauth import ( HsmAuthSession, InvalidPinError, ALGORITHM, MANAGEMENT_KEY_LEN, CREDENTIAL_PASSWORD_LEN, ) from yubikit.management import CAPABILITY from ..util import parse_private_key, InvalidPasswordError from ..hsmauth import ( get_hsmauth_info, generate_random_management_key, ) from .util import ( CliFail, click_force_option, click_postpone_execution, click_callback, click_format_option, click_prompt, click_group, pretty_print, get_scp_params, log_or_echo, ) from cryptography.hazmat.primitives import serialization import click import os import logging logger = logging.getLogger(__name__) def handle_credential_error( e: Exception, default_exception_msg, target="Credential password" ): if isinstance(e, InvalidPinError): attempts = e.attempts_remaining if attempts: raise CliFail(f"Wrong management key, {attempts} attempts remaining.") else: raise CliFail("Management key is blocked.") elif isinstance(e, ApduError): if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("A credential with the provided label already exists.") elif e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for YubiHSM Auth credentials.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential with the provided label was not found.") elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("The device was not touched.") elif e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail(f"{target} does not meet complexity requirement.") elif isinstance(e, ValueError): raise # Re-raise, ValueErrors are handled elsewhere raise CliFail(default_exception_msg) def _parse_touch_required(touch_required: bool) -> str: if touch_required: return "On" else: return "Off" def _parse_algorithm(algorithm: ALGORITHM) -> str: if algorithm == ALGORITHM.AES128_YUBICO_AUTHENTICATION: return "Symmetric" else: return "Asymmetric" def _parse_hex(hex): try: return bytes.fromhex(hex) except Exception as e: raise ValueError(e) def _parse_key(key, key_len, key_type): key = _parse_hex(key) if len(key) != key_len: raise ValueError( f"must be exactly {key_len} bytes long ({key_len * 2} hexadecimal digits) " "long" ) return key def _parse_password(value, key_len, name): encoded = value.encode() if len(encoded) <= key_len: return encoded.ljust(key_len, b"\0") if len(encoded) == key_len * 2: return _parse_hex(value) raise ValueError(f"{name} must be at most 16 bytes") @click_callback() def click_parse_management_key(ctx, param, val): return _parse_key(val, MANAGEMENT_KEY_LEN, "Management key") @click_callback() def click_parse_management_password(ctx, param, val): return _parse_password(val, MANAGEMENT_KEY_LEN, "Management password") @click_callback() def click_parse_credential_password(ctx, param, val): return _parse_password(val, CREDENTIAL_PASSWORD_LEN, "Credential password") @click_callback() def click_parse_enc_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key") @click_callback() def click_parse_mac_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "MAC key") @click_callback() def click_parse_card_crypto(ctx, param, val): return _parse_hex(val) @click_callback() def click_parse_context(ctx, param, val): return _parse_hex(val) def _prompt_management_key(prompt="Enter management password", confirm=False): management_password = click_prompt( prompt, default="", hide_input=True, show_default=False, confirmation_prompt=confirm, ) return _parse_password( management_password, MANAGEMENT_KEY_LEN, "Management password" ) def _prompt_credential_password(prompt="Enter credential password"): credential_password = click_prompt( prompt, hide_input=True, confirmation_prompt=True, ) return _parse_password( credential_password, CREDENTIAL_PASSWORD_LEN, "Credential password" ) def _prompt_symmetric_key(name): symmetric_key = click_prompt(f"Enter {name}") return _parse_key( symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, name ) def _fname(fobj): return getattr(fobj, "name", fobj) click_credential_password_option = click.option( "-c", "--credential-password", help="password to protect credential", callback=click_parse_credential_password, ) click_management_key_option = click.option( "-m", "--management-password", "--management-key", "management_key", help="the management password", callback=click_parse_management_password, ) click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to access credential" ) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def hsmauth(ctx): """ Manage the YubiHSM Auth application """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.HSMAUTH, conn) ctx.obj["session"] = HsmAuthSession(conn, scp_params) try: if not scp_params and dev.transport == TRANSPORT.NFC: # Dummy command to test access ctx.obj["session"].get_management_key_retries() except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Unable to manage HSMAuth over NFC without SCP") raise info = ctx.obj["info"] ctx.obj["fips_unready"] = ( CAPABILITY.HSMAUTH in info.fips_capable and CAPABILITY.HSMAUTH not in info.fips_approved ) @hsmauth.command() @click.pass_context def info(ctx): """ Display general status of the YubiHSM Auth application. """ info = ctx.obj["info"] data = get_hsmauth_info(ctx.obj["session"]) if CAPABILITY.HSMAUTH in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data["FIPS approved"] = CAPABILITY.HSMAUTH in info.fips_approved click.echo("\n".join(pretty_print(data))) @hsmauth.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all YubiHSM Auth data. This action will wipe all data and restore factory setting for the YubiHSM Auth application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored YubiHSM Auth data and restore factory " "setting. Proceed?", abort=True, err=True, ) click.echo("Resetting YubiHSM Auth data...") ctx.obj["session"].reset() click.echo( "Reset complete. All YubiHSM Auth data has been cleared from the YubiKey." ) click.echo("Your YubiKey now has an empty Management password.") @hsmauth.group() def credentials(): """Manage YubiHSM Auth credentials.""" @credentials.command() @click.pass_context def list(ctx): """ List all credentials. List all credentials stored on the YubiKey. """ session = ctx.obj["session"] creds = session.list_credentials() if len(creds) == 0: click.echo("No items found") else: click.echo(f"Found {len(creds)} item(s)") max_size_label = max(len(cred.label) for cred in creds) max_size_type = ( 10 if any( c.algorithm == ALGORITHM.EC_P256_YUBICO_AUTHENTICATION for c in creds ) else 9 ) format_str = "{0: <{label_width}}\t{1: <{type_width}}\t{2}\t{3}" click.echo( format_str.format( "Label", "Type", "Touch", "Retries", label_width=max_size_label, type_width=max_size_type, ) ) for cred in creds: click.echo( format_str.format( cred.label, _parse_algorithm(cred.algorithm), _parse_touch_required(cred.touch_required), cred.counter, label_width=max_size_label, type_width=max_size_type, ) ) @credentials.command() @click.pass_context @click.argument("label") @click_credential_password_option @click_management_key_option @click_touch_option def generate(ctx, label, credential_password, management_key, touch): """Generate an asymmetric credential. This will generate an asymmetric YubiHSM Auth credential (private key) on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() session = ctx.obj["session"] try: session.generate_credential_asymmetric( management_key, label, credential_password, touch ) click.echo("Asymmetric credential generated.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to generate asymmetric credential." ) @credentials.command("import") @click.pass_context @click.argument("label") @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") @click.option("-p", "--password", help="password used to decrypt the private key") @click_credential_password_option @click_management_key_option @click_touch_option def import_credential( ctx, label, private_key, password, credential_password, management_key, touch ): """Import an asymmetric credential. This will import a private key as an asymmetric YubiHSM Auth credential to the YubiKey. \b LABEL label for the YubiHSM Auth credential PRIVATE-KEY file containing the private key (use '-' to use stdin) """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() session = ctx.obj["session"] data = private_key.read() while True: if password is not None: password = password.encode() try: private_key = parse_private_key(data, password) except InvalidPasswordError: logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break try: session.put_credential_asymmetric( management_key, label, private_key, credential_password, touch, ) click.echo("Asymmetric credential imported.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import asymmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") @click_format_option def export(ctx, label, public_key_output, format): """Export the public key corresponding to an asymmetric credential. This will export the long-term public key corresponding to the asymmetric YubiHSM Auth credential stored on the YubiKey. \b LABEL label for the YubiHSM Auth credential PUBLIC-KEY file to write the public key to (use '-' to use stdout) """ session = ctx.obj["session"] try: public_key = session.get_public_key(label) key_encoding = format public_key_encoded = public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) public_key_output.write(public_key_encoded) log_or_echo( f"Public key for {label} written to {_fname(public_key_output)}", logger, public_key_output, ) except ApduError as e: if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("The entry is not an asymmetric credential.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential not found.") else: raise CliFail("Unable to export public key.") @credentials.command() @click.pass_context @click.argument("label") @click.option("-E", "--enc-key", help="the ENC key", callback=click_parse_enc_key) @click.option("-M", "--mac-key", help="the MAC key", callback=click_parse_mac_key) @click.option( "-g", "--generate", is_flag=True, help="generate a random encryption and mac key" ) @click_credential_password_option @click_management_key_option @click_touch_option def symmetric( ctx, label, credential_password, management_key, enc_key, mac_key, generate, touch ): """Import a symmetric credential. This will import an encryption and mac key as a symmetric YubiHSM Auth credential on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() if generate and (enc_key or mac_key): ctx.fail("--enc-key and --mac-key cannot be combined with --generate") if generate: enc_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) mac_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) click.echo("Generated ENC and MAC keys:") click.echo("\n".join(pretty_print({"ENC-KEY": enc_key, "MAC-KEY": mac_key}))) if not enc_key: enc_key = _prompt_symmetric_key("ENC key") if not mac_key: mac_key = _prompt_symmetric_key("MAC key") session = ctx.obj["session"] try: session.put_credential_symmetric( management_key, label, enc_key, mac_key, credential_password, touch, ) click.echo("Symmetric credential stored.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import symmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click.option( "-d", "--derivation-password", help="deriviation password for ENC and MAC keys" ) @click_credential_password_option @click_management_key_option @click_touch_option def derive(ctx, label, derivation_password, credential_password, management_key, touch): """Import a symmetric credential derived from a password. This will import a symmetric YubiHSM Auth credential by deriving ENC and MAC keys from a password. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() if derivation_password is None: derivation_password = click_prompt( "Enter derivation password", hide_input=True, confirmation_prompt=True, ) session = ctx.obj["session"] try: session.put_credential_derived( management_key, label, derivation_password, credential_password, touch ) click.echo("Derived symmetric credential stored.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import symmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click_management_key_option @click_force_option def delete(ctx, label, management_key, force): """ Delete a credential. This will delete a YubiHSM Auth credential from the YubiKey. \b LABEL a label to match a single credential (as shown in "list") """ if management_key is None: management_key = _prompt_management_key() force or click.confirm( f"Delete credential: {label} ?", abort=True, err=True, ) session = ctx.obj["session"] try: session.delete_credential(management_key, label) click.echo("Credential deleted.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to delete credential.", ) @hsmauth.group() def access(): """Manage Management Key for YubiHSM Auth""" @access.command(hidden=True) @click.pass_context @click.option( "-m", "--management-key", help="current management key", show_default=True, callback=click_parse_management_key, ) @click.option( "-n", "--new-management-key", help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-g", "--generate", is_flag=True, help="generate a random management key " "(can't be used with --new-management-key)", ) def change_management_key(ctx, management_key, new_management_key, generate): """ Change the management key. Allows you to change the management key which is required to add and delete YubiHSM Auth credentials stored on the YubiKey. """ if management_key is None: management_key = _prompt_management_key( "Enter current management key [blank to use default key]" ) session = ctx.obj["session"] # Can't combine new key with generate. if new_management_key and generate: ctx.fail("Invalid options: --new-management-key conflicts with --generate") if new_management_key is None: if generate: new_management_key = generate_random_management_key() click.echo(f"Generated management key: {new_management_key.hex()}") else: try: new_management_key = bytes.fromhex( click_prompt( "Enter the new management key", hide_input=True, confirmation_prompt=True, ) ) except Exception: ctx.fail("New management key has the wrong format.") if len(new_management_key) != MANAGEMENT_KEY_LEN: raise CliFail( "Management key has the wrong length (expected %d bytes)" % MANAGEMENT_KEY_LEN ) try: session.put_management_key(management_key, new_management_key) click.echo("Management key changed.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to change management key.", target="Management key", ) @access.command() @click.pass_context @click.option( "-m", "--management-password", "management_key", help="current management password", show_default=True, callback=click_parse_management_password, ) @click.option( "-n", "--new-management-password", "new_management_key", help="a new management password to set", callback=click_parse_management_password, ) def change_management_password(ctx, management_key, new_management_key): """ Change the management key. Allows you to change the management key which is required to add and delete YubiHSM Auth credentials stored on the YubiKey. """ if management_key is None: management_key = _prompt_management_key( "Enter your current management password", ) if new_management_key is None: new_management_key = _prompt_management_key( "Enter a new management password", confirm=True ) session = ctx.obj["session"] try: session.put_management_key(management_key, new_management_key) click.echo("Management password changed.") except Exception as e: handle_credential_error( e, default_exception_msg="Failed to change management password.", target="Management password", ) yubikey_manager-5.6.1/ykman/fido.py0000644000175000017500000000742314777516541016703 0ustar winniewinnie# Copyright (c) 2018 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import time import struct from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SW from fido2.ctap1 import Ctap1, ApduError from typing import Optional U2F_VENDOR_FIRST = 0x40 # FIPS specific INS values INS_FIPS_VERIFY_PIN = U2F_VENDOR_FIRST + 3 INS_FIPS_SET_PIN = U2F_VENDOR_FIRST + 4 INS_FIPS_RESET = U2F_VENDOR_FIRST + 5 INS_FIPS_VERIFY_FIPS_MODE = U2F_VENDOR_FIRST + 6 def is_in_fips_mode(fido_connection: FidoConnection) -> bool: """Check if a YubiKey 4 FIPS is in FIPS approved mode. :param fido_connection: A FIDO connection. """ try: ctap = Ctap1(fido_connection) ctap.send_apdu(ins=INS_FIPS_VERIFY_FIPS_MODE) return True except ApduError as e: # 0x6a81: Function not supported (PIN not set - not FIPS Mode) if e.code == SW.FUNCTION_NOT_SUPPORTED: return False raise def fips_change_pin( fido_connection: FidoConnection, old_pin: Optional[str], new_pin: str ): """Change the PIN on a YubiKey 4 FIPS. If no PIN is set, pass None or an empty string as old_pin. :param fido_connection: A FIDO connection. :param old_pin: The old PIN. :param new_pin: The new PIN. """ ctap = Ctap1(fido_connection) old_pin_bytes = old_pin.encode() if old_pin else b"" new_pin_bytes = new_pin.encode() new_length = len(new_pin_bytes) data = struct.pack("B", new_length) + old_pin_bytes + new_pin_bytes ctap.send_apdu(ins=INS_FIPS_SET_PIN, data=data) def fips_verify_pin(fido_connection: FidoConnection, pin: str): """Unlock the YubiKey 4 FIPS U2F module for credential creation. :param fido_connection: A FIDO connection. :param pin: The FIDO PIN. """ ctap = Ctap1(fido_connection) ctap.send_apdu(ins=INS_FIPS_VERIFY_PIN, data=pin.encode()) def fips_reset(fido_connection: FidoConnection): """Reset the FIDO module of a YubiKey 4 FIPS. Note: This action is only permitted immediately after YubiKey power-up. It also requires the user to touch the flashing button on the YubiKey, and will halt until that happens, or the command times out. :param fido_connection: A FIDO connection. """ ctap = Ctap1(fido_connection) while True: try: ctap.send_apdu(ins=INS_FIPS_RESET) return except ApduError as e: if e.code == SW.CONDITIONS_NOT_SATISFIED: time.sleep(0.5) else: raise e yubikey_manager-5.6.1/ykman/oath.py0000644000175000017500000000724314777516541016715 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core.smartcard import ApduError, SW from yubikit.oath import OathSession, Credential, OATH_TYPE from time import time from typing import Optional import struct import logging logger = logging.getLogger(__name__) STEAM_CHAR_TABLE = "23456789BCDFGHJKMNPQRTVWXY" def is_hidden(credential: Credential) -> bool: """Check if OATH credential is hidden.""" return credential.issuer == "_hidden" def is_steam(credential: Credential) -> bool: """Check if OATH credential is steam.""" return credential.oath_type == OATH_TYPE.TOTP and credential.issuer == "Steam" def calculate_steam( app: OathSession, credential: Credential, timestamp: Optional[int] = None ) -> str: """Calculate steam codes.""" timestamp = int(timestamp or time()) resp = app.calculate(credential.id, struct.pack(">q", timestamp // 30)) offset = resp[-1] & 0x0F code = struct.unpack(">I", resp[offset : offset + 4])[0] & 0x7FFFFFFF chars = [] for i in range(5): chars.append(STEAM_CHAR_TABLE[code % len(STEAM_CHAR_TABLE)]) code //= len(STEAM_CHAR_TABLE) return "".join(chars) def is_in_fips_mode(app: OathSession) -> bool: """Check if OATH application is in FIPS mode.""" return app.locked def delete_broken_credential(app: OathSession) -> bool: """Checks for credential in a broken state and deletes it.""" logger.debug("Probing for broken credentials") creds = app.list_credentials() broken = [] for c in creds: if c.oath_type == OATH_TYPE.TOTP and not c.touch_required: for i in range(5): try: app.calculate_code(c) logger.debug(f"Credential appears OK: {c.id!r}") break except ApduError as e: if e.sw == SW.MEMORY_FAILURE: if i == 0: logger.debug(f"Memory failure in: {c.id!r}") continue raise else: broken.append(c.id) logger.warning(f"Credential appears to be broken: {c.id!r}") if len(broken) == 1: logger.info("Deleting broken credential") app.delete_credential(broken[0]) return True logger.warning(f"Requires a single broken credential, found {len(broken)}") return False yubikey_manager-5.6.1/ykman/hid/0000775000175000017500000000000014775540152016141 5ustar winniewinnieyubikey_manager-5.6.1/ykman/hid/linux.py0000644000175000017500000001020714777516541017657 0ustar winniewinnie# Copyright (c) 2020 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core.otp import OtpConnection from yubikit.logging import LOG_LEVEL from .base import OtpYubiKeyDevice, YUBICO_VID, USAGE_OTP from typing import Set import glob import fcntl import struct import logging import sys # Don't typecheck this file on Windows assert sys.platform != "win32" # nosec logger = logging.getLogger(__name__) # usb_ioctl.h USB_GET_REPORT = 0xC0094807 USB_SET_REPORT = 0xC0094806 # hidraw.h HIDIOCGRAWINFO = 0x80084803 HIDIOCGRDESCSIZE = 0x80044801 HIDIOCGRDESC = 0x90044802 class HidrawConnection(OtpConnection): def __init__(self, path): self.handle = open(path, "wb") def close(self): self.handle.close() def receive(self): buf = bytearray(1 + 8) fcntl.ioctl(self.handle, USB_GET_REPORT, buf, True) data = buf[1:] logger.log(LOG_LEVEL.TRAFFIC, "RECV: %s", data.hex()) return data def send(self, data): logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", data.hex()) buf = bytearray([0]) # Prepend the report ID. buf.extend(data) fcntl.ioctl(self.handle, USB_SET_REPORT, buf, True) def get_info(dev): buf = bytearray(4 + 2 + 2) fcntl.ioctl(dev, HIDIOCGRAWINFO, buf, True) return struct.unpack(">/boot/loader.conf< HIDIOCGRAWINFO = 0x40085520 HIDIOCGRDESC = 0x2000551F HIDIOCGRDESCSIZE = 0x4004551E HIDIOCGFEATURE_9 = 0xC0095524 HIDIOCSFEATURE_9 = 0x80095523 class HidrawConnection(OtpConnection): """ hidraw(4) is FreeBSD's modern raw access driver, based on usbhid(4). It is available since FreeBSD 13 and can be activated by adding `hw.usb.usbhid.enable="1"` to `/boot/loader.conf`. The actual kernel module is loaded with `kldload hidraw`. """ def __init__(self, path): self.fd = os.open(path, os.O_RDWR) def close(self): os.close(self.fd) def receive(self): buf = bytearray(1 + 8) fcntl.ioctl(self.fd, HIDIOCGFEATURE_9, buf, True) return buf[1:] def send(self, data): buf = bytes([0]) + data fcntl.ioctl(self.fd, HIDIOCSFEATURE_9, buf) @staticmethod def get_info(dev): buf = bytearray(4 + 2 + 2) fcntl.ioctl(dev, HIDIOCGRAWINFO, buf, True) return struct.unpack("B", data)[0], data[1:] key, size = REPORT_DESCRIPTOR_KEY_MASK & head, SIZE_MASK & head value = struct.unpack_from(" List[CtapYubiKeyDevice]: devs = [] for desc in list_descriptors(): if desc.vid == 0x1050: try: devs.append(CtapYubiKeyDevice(desc)) except ValueError: logger.debug(f"Unsupported Yubico device with PID: {desc.pid:02x}") return devs except Exception: # CTAP not supported on this platform class CtapYubiKeyDevice(YkmanDevice): # type: ignore def __init__(self, *args, **kwargs): raise NotImplementedError( "CTAP HID support is not implemented on this platform" ) def list_ctap_devices() -> List[CtapYubiKeyDevice]: raise NotImplementedError( "CTAP HID support is not implemented on this platform" ) yubikey_manager-5.6.1/ykman/diagnostics.py0000644000175000017500000001666614777516541020302 0ustar winniewinniefrom . import __version__ as ykman_version from .util import get_windows_version from .pcsc import list_readers, list_devices as list_ccid_devices from .hid import list_otp_devices, list_ctap_devices from .piv import get_piv_info from .openpgp import get_openpgp_info from .hsmauth import get_hsmauth_info from yubikit.core import Tlv from yubikit.core.smartcard import SmartCardConnection from yubikit.core.fido import FidoConnection from yubikit.core.otp import OtpConnection from yubikit.management import ManagementSession from yubikit.yubiotp import YubiOtpSession from yubikit.piv import PivSession from yubikit.oath import OathSession from yubikit.openpgp import OpenPgpSession from yubikit.hsmauth import HsmAuthSession from yubikit.support import read_info, get_name from fido2.ctap import CtapError from fido2.ctap2 import Ctap2, ClientPin from dataclasses import asdict from datetime import datetime from typing import List, Dict, Any import platform import ctypes import sys import os def sys_info(): info: Dict[str, Any] = { "ykman": ykman_version, "Python": sys.version, "Platform": sys.platform, "Arch": platform.machine(), "System date": datetime.today().strftime("%Y-%m-%d"), } if sys.platform == "win32": info.update( { "Running as admin": bool(ctypes.windll.shell32.IsUserAnAdmin()), "Windows version": get_windows_version(), } ) else: info["Running as admin"] = os.getuid() == 0 return info def mgmt_info(pid, conn): data: List[Any] = [] try: m = ManagementSession(conn) raw_info = m.backend.read_config() if Tlv.parse_dict(raw_info[1:]).get(0x10) == b"\1": raw_info += m.backend.read_config(1) data.append( { "Raw Info": raw_info, } ) except Exception as e: data.append(f"Failed to read device info via Management: {e!r}") try: info = read_info(conn, pid) data.append( { "DeviceInfo": asdict(info), "Name": get_name(info, pid.yubikey_type), } ) except Exception as e: data.append(f"Failed to read device info: {e!r}") return data def piv_info(conn): try: piv = PivSession(conn) return get_piv_info(piv) except Exception as e: return f"PIV not accessible {e!r}" def openpgp_info(conn): try: openpgp = OpenPgpSession(conn) return get_openpgp_info(openpgp) except Exception as e: return f"OpenPGP not accessible {e!r}" def oath_info(conn): try: oath = OathSession(conn) return { "Oath version": ".".join("%d" % d for d in oath.version), "Password protected": oath.locked, } except Exception as e: return f"OATH not accessible {e!r}" def hsmauth_info(conn): try: hsmauth = HsmAuthSession(conn) return get_hsmauth_info(hsmauth) except Exception as e: return f"YubiHSM Auth not accessible {e!r}" def ccid_info(): try: readers = {} for reader in list_readers(): try: c = reader.createConnection() c.connect() c.disconnect() result = "Success" except Exception as e: result = f"<{e.__class__.__name__}>" readers[reader.name] = result yubikeys: Dict[str, Any] = {} for dev in list_ccid_devices(): try: with dev.open_connection(SmartCardConnection) as conn: yubikeys[f"{dev!r}"] = { "Management": mgmt_info(dev.pid, conn), "PIV": piv_info(conn), "OATH": oath_info(conn), "OpenPGP": openpgp_info(conn), "YubiHSM Auth": hsmauth_info(conn), } except Exception as e: yubikeys[f"{dev!r}"] = f"PC/SC connection failure: {e!r}" return { "Detected PC/SC readers": readers, "Detected YubiKeys over PC/SC": yubikeys, } except Exception as e: return f"PC/SC failure: {e!r}" def otp_info(): try: yubikeys: Dict[str, Any] = {} for dev in list_otp_devices(): try: dev_info = [] with dev.open_connection(OtpConnection) as conn: dev_info.append( { "Management": mgmt_info(dev.pid, conn), } ) otp = YubiOtpSession(conn) try: config = otp.get_config_state() dev_info.append({"OTP": [f"{config}"]}) except ValueError as e: dev_info.append({"OTP": f"Couldn't read OTP state: {e!r}"}) yubikeys[f"{dev!r}"] = dev_info except Exception as e: yubikeys[f"{dev!r}"] = f"OTP connection failure: {e!r}" return { "Detected YubiKeys over HID OTP": yubikeys, } except Exception as e: return f"HID OTP backend failure: {e!r}" def fido_info(): try: yubikeys: Dict[str, Any] = {} for dev in list_ctap_devices(): try: dev_info: List[Any] = [] with dev.open_connection(FidoConnection) as conn: dev_info.append( { "CTAP device version": "%d.%d.%d" % conn.device_version, "CTAPHID protocol version": conn.version, "Capabilities": conn.capabilities, "Management": mgmt_info(dev.pid, conn), } ) try: ctap2 = Ctap2(conn) ctap_data: Dict[str, Any] = {"Ctap2Info": asdict(ctap2.info)} if ctap2.info.options.get("clientPin"): client_pin = ClientPin(ctap2) ctap_data["PIN retries"] = client_pin.get_pin_retries() bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: ctap_data["Fingerprint retries"] = ( client_pin.get_uv_retries() ) elif bio_enroll is False: ctap_data["Fingerprints"] = "Not configured" else: ctap_data["PIN"] = "Not configured" dev_info.append(ctap_data) except (ValueError, CtapError) as e: dev_info.append(f"Couldn't get CTAP2 info: {e!r}") yubikeys[f"{dev!r}"] = dev_info except Exception as e: yubikeys[f"{dev!r}"] = f"FIDO connection failure: {e!r}" return { "Detected YubiKeys over HID FIDO": yubikeys, } except Exception as e: return f"HID FIDO backend failure: {e!r}" def get_diagnostics(): """Runs diagnostics. The result of this can be printed using pretty_print. """ return [ sys_info(), ccid_info(), otp_info(), fido_info(), "End of diagnostics", ] yubikey_manager-5.6.1/ykman/piv.py0000644000175000017500000006400114777516541016553 0ustar winniewinnie# Copyright (c) 2017 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import Tlv, BadResponseError, NotSupportedError from yubikit.core.smartcard import ApduError, SW from yubikit.piv import ( PivSession, SLOT, OBJECT_ID, KEY_TYPE, MANAGEMENT_KEY_TYPE, ALGORITHM, TAG_LRC, SlotMetadata, FascN, Chuid, ) from .util import display_serial from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding, ed25519, x25519 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.backends import default_backend from cryptography.x509.oid import NameOID from datetime import datetime, date from uuid import uuid4 import logging import struct import os import re from typing import Union, Mapping, Optional, List, Dict, Type, Any, cast logger = logging.getLogger(__name__) OBJECT_ID_PIVMAN_DATA = 0x5FFF00 OBJECT_ID_PIVMAN_PROTECTED_DATA = OBJECT_ID.PRINTED # Use slot for printed information. _NAME_ATTRIBUTES = { "CN": NameOID.COMMON_NAME, "L": NameOID.LOCALITY_NAME, "ST": NameOID.STATE_OR_PROVINCE_NAME, "O": NameOID.ORGANIZATION_NAME, "OU": NameOID.ORGANIZATIONAL_UNIT_NAME, "C": NameOID.COUNTRY_NAME, "STREET": NameOID.STREET_ADDRESS, "DC": NameOID.DOMAIN_COMPONENT, "UID": NameOID.USER_ID, } _ESCAPED = "\\\"+,'<> #=" def _parse(value: str) -> List[List[str]]: remaining = list(value) name = [] entry = [] buf = "" hexbuf = b"" while remaining: c = remaining.pop(0) if c == "\\": c1 = remaining.pop(0) if c1 in _ESCAPED: c = c1 else: c2 = remaining.pop(0) hexbuf += bytes.fromhex(c1 + c2) try: c = hexbuf.decode() hexbuf = b"" except UnicodeDecodeError: continue # Possibly multi-byte, expect more hex elif c in ",+": entry.append(buf) buf = "" if c == ",": name.append(entry) entry = [] continue if hexbuf: raise ValueError("Invalid UTF-8 data") buf += c entry.append(buf) name.append(entry) return name _DOTTED_STRING_RE = re.compile(r"\d(\.\d+)+") def parse_rfc4514_string(value: str) -> x509.Name: """Parse an RFC 4514 string into a x509.Name. See: https://tools.ietf.org/html/rfc4514.html :param value: An RFC 4514 string. """ name = _parse(value) attributes: List[x509.RelativeDistinguishedName] = [] for entry in name: parts = [] for part in entry: if "=" not in part: raise ValueError("Invalid RFC 4514 string") k, v = part.split("=", 1) if k in _NAME_ATTRIBUTES: attr = _NAME_ATTRIBUTES[k] elif _DOTTED_STRING_RE.fullmatch(k): attr = x509.ObjectIdentifier(k) else: raise ValueError(f"Unsupported attribute: '{k}'") parts.append(x509.NameAttribute(attr, v)) attributes.insert(0, x509.RelativeDistinguishedName(parts)) return x509.Name(attributes) def _dummy_key(key_type): if key_type.algorithm == ALGORITHM.RSA: return rsa.generate_private_key(65537, key_type.bit_len, default_backend()) if key_type == KEY_TYPE.ECCP256: return ec.generate_private_key(ec.SECP256R1(), default_backend()) if key_type == KEY_TYPE.ECCP384: return ec.generate_private_key(ec.SECP384R1(), default_backend()) if key_type == KEY_TYPE.ED25519: return ed25519.Ed25519PrivateKey.generate() if key_type == KEY_TYPE.X25519: return x25519.X25519PrivateKey.generate() raise ValueError("Invalid algorithm") def derive_management_key(pin: str, salt: bytes) -> bytes: """Derive a management key from the users PIN and a salt. NOTE: This method of derivation is deprecated! Protect the management key using PivmanProtectedData instead. :param pin: The PIN. :param salt: The salt. """ kdf = PBKDF2HMAC(hashes.SHA1(), 24, salt, 10000, default_backend()) # nosec return kdf.derive(pin.encode("utf-8")) def generate_random_management_key(algorithm: MANAGEMENT_KEY_TYPE) -> bytes: """Generate a new random management key. :param algorithm: The algorithm for the management key. """ return os.urandom(algorithm.key_len) class PivmanData: def __init__(self, raw_data: bytes = Tlv(0x80)): data = Tlv.parse_dict(Tlv(raw_data).value) self._flags = struct.unpack(">B", data[0x81])[0] if 0x81 in data else None self.salt = data.get(0x82) self.pin_timestamp = struct.unpack(">I", data[0x83]) if 0x83 in data else None def _get_flag(self, mask: int) -> bool: return bool((self._flags or 0) & mask) def _set_flag(self, mask: int, value: bool) -> None: if value: self._flags = (self._flags or 0) | mask elif self._flags is not None: self._flags &= ~mask @property def puk_blocked(self) -> bool: return self._get_flag(0x01) @puk_blocked.setter def puk_blocked(self, value: bool) -> None: self._set_flag(0x01, value) @property def mgm_key_protected(self) -> bool: return self._get_flag(0x02) @mgm_key_protected.setter def mgm_key_protected(self, value: bool) -> None: self._set_flag(0x02, value) @property def has_protected_key(self) -> bool: return self.has_derived_key or self.has_stored_key @property def has_derived_key(self) -> bool: return self.salt is not None @property def has_stored_key(self) -> bool: return self.mgm_key_protected def get_bytes(self) -> bytes: data = b"" if self._flags: data += Tlv(0x81, struct.pack(">B", self._flags)) if self.salt is not None: data += Tlv(0x82, self.salt) if self.pin_timestamp is not None: data += Tlv(0x83, struct.pack(">I", self.pin_timestamp)) return Tlv(0x80, data) if data else b"" class PivmanProtectedData: def __init__(self, raw_data: bytes = Tlv(0x88)): data = Tlv.parse_dict(Tlv(raw_data).value) self.key = data.get(0x89) def get_bytes(self) -> bytes: data = b"" if self.key is not None: data += Tlv(0x89, self.key) return Tlv(0x88, data) if data else b"" def get_pivman_data(session: PivSession) -> PivmanData: """Read out the Pivman data from a YubiKey. :param session: The PIV session. """ logger.debug("Reading pivman data") try: return PivmanData(session.get_object(OBJECT_ID_PIVMAN_DATA)) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: # No data there, initialise a new object. logger.debug("No data, initializing blank") return PivmanData() raise def get_pivman_protected_data(session: PivSession) -> PivmanProtectedData: """Read out the Pivman protected data from a YubiKey. This function requires PIN verification prior to being called. :param session: The PIV session. """ logger.debug("Reading protected pivman data") try: return PivmanProtectedData(session.get_object(OBJECT_ID_PIVMAN_PROTECTED_DATA)) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: # No data there, initialise a new object. logger.debug("No data, initializing blank") return PivmanProtectedData() raise def pivman_set_mgm_key( session: PivSession, new_key: bytes, algorithm: MANAGEMENT_KEY_TYPE, touch: bool = False, store_on_device: bool = False, ) -> None: """Set a new management key, while keeping PivmanData in sync. :param session: The PIV session. :param new_key: The new management key. :param algorithm: The algorithm for the management key. :param touch: If set, touch is required. :param store_on_device: If set, the management key is stored on device. """ pivman = get_pivman_data(session) pivman_old_bytes = pivman.get_bytes() pivman_prot = None if store_on_device or (not store_on_device and pivman.has_stored_key): # Ensure we have access to protected data before overwriting key try: pivman_prot = get_pivman_protected_data(session) except Exception: logger.debug("Failed to initialize protected pivman data", exc_info=True) if store_on_device: raise # Set the new management key session.set_management_key(algorithm, new_key, touch) if pivman.has_derived_key: # Clear salt for old derived keys. logger.debug("Clearing salt in pivman data") pivman.salt = None # Set flag for stored or not stored key. pivman.mgm_key_protected = store_on_device # Update readable pivman data, if changed pivman_bytes = pivman.get_bytes() if pivman_old_bytes != pivman_bytes: session.put_object(OBJECT_ID_PIVMAN_DATA, pivman_bytes) if pivman_prot is not None: if store_on_device: # Store key in protected pivman data logger.debug("Storing key in protected pivman data") pivman_prot.key = new_key session.put_object(OBJECT_ID_PIVMAN_PROTECTED_DATA, pivman_prot.get_bytes()) elif pivman_prot.key: # If new key should not be stored and there is an old stored key, # try to clear it. logger.debug("Clearing old key in protected pivman data") try: pivman_prot.key = None session.put_object( OBJECT_ID_PIVMAN_PROTECTED_DATA, pivman_prot.get_bytes(), ) except ApduError: logger.debug("No PIN provided, can't clear key...", exc_info=True) def pivman_change_pin(session: PivSession, old_pin: str, new_pin: str) -> None: """Change the PIN, while keeping PivmanData in sync. :param session: The PIV session. :param old_pin: The old PIN. :param new_pin: The new PIN. """ session.change_pin(old_pin, new_pin) pivman = get_pivman_data(session) if pivman.has_derived_key: logger.debug("Has derived management key, update for new PIN") session.authenticate( derive_management_key(old_pin, cast(bytes, pivman.salt)), ) session.verify_pin(new_pin) new_salt = os.urandom(16) new_key = derive_management_key(new_pin, new_salt) session.set_management_key(MANAGEMENT_KEY_TYPE.TDES, new_key) pivman.salt = new_salt session.put_object(OBJECT_ID_PIVMAN_DATA, pivman.get_bytes()) def pivman_set_pin_attempts( session: PivSession, pin_attempts: int, puk_attempts: int ) -> None: """Set the number of PIN and PUK retry attempts, while keeping PivmanData in sync. :param session: The PIV session. :param pin_attempts: The PIN attempts. :param puk_attempts: The PUK attempts. """ session.set_pin_attempts(pin_attempts, puk_attempts) pivman = get_pivman_data(session) if pivman.puk_blocked: pivman.puk_blocked = False session.put_object(OBJECT_ID_PIVMAN_DATA, pivman.get_bytes()) def list_certificates(session: PivSession) -> Mapping[SLOT, Optional[x509.Certificate]]: """Read out and parse stored certificates. Only certificates which are successfully parsed are returned. :param session: The PIV session. """ certs = {} for slot in set(SLOT) - {SLOT.ATTESTATION}: try: certs[slot] = session.get_certificate(slot) except ApduError: pass except BadResponseError: certs[slot] = None return certs def _list_keys(session: PivSession) -> Mapping[SLOT, SlotMetadata]: keys = {} for slot in set(SLOT) - {SLOT.ATTESTATION}: try: keys[slot] = session.get_slot_metadata(slot) except ApduError as e: if e.sw != SW.REFERENCE_DATA_NOT_FOUND: raise return keys def check_key( session: PivSession, slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], ) -> bool: """Check that a given public key corresponds to the private key in a slot. This will create a signature using the private key, so the PIN must be verified prior to calling this function if the PIN policy requires it. :param session: The PIV session. :param slot: The slot. :param public_key: The public key. """ try: test_data = b"test" logger.debug( "Testing private key by creating a test signature, and verifying it" ) test_sig = session.sign( slot, KEY_TYPE.from_public_key(public_key), test_data, hashes.SHA256(), padding.PKCS1v15(), # Only used for RSA ) if isinstance(public_key, rsa.RSAPublicKey): public_key.verify( test_sig, test_data, padding.PKCS1v15(), hashes.SHA256(), ) elif isinstance(public_key, ec.EllipticCurvePublicKey): public_key.verify(test_sig, test_data, ec.ECDSA(hashes.SHA256())) else: raise ValueError("Unknown key type: " + type(public_key)) return True except ApduError as e: if e.sw in (SW.INCORRECT_PARAMETERS, SW.WRONG_PARAMETERS_P1P2): logger.debug(f"Couldn't create signature: SW={e.sw:04x}") return False raise except InvalidSignature: logger.debug("Signature verification failed") return False def generate_chuid() -> bytes: """Generate a CHUID (Cardholder Unique Identifier).""" chuid = Chuid( # Non-Federal Issuer FASC-N fasc_n=FascN(9999, 9999, 999999, 0, 1, 0000000000, 3, 0000, 1), guid=uuid4().bytes, # Expires on: 2030-01-01 expiration_date=date(2030, 1, 1), asymmetric_signature=b"", ) return bytes(chuid) def generate_ccc() -> bytes: """Generate a CCC (Card Capability Container).""" return ( Tlv(0xF0, b"\xa0\x00\x00\x01\x16\xff\x02" + os.urandom(14)) + Tlv(0xF1, b"\x21") + Tlv(0xF2, b"\x21") + Tlv(0xF3) + Tlv(0xF4, b"\x00") + Tlv(0xF5, b"\x10") + Tlv(0xF6) + Tlv(0xF7) + Tlv(0xFA) + Tlv(0xFB) + Tlv(0xFC) + Tlv(0xFD) + Tlv(TAG_LRC) ) def get_piv_info(session: PivSession): """Get human readable information about the PIV configuration. :param session: The PIV session. """ pivman = get_pivman_data(session) info: Dict[str, Any] = { "PIV version": session.version, } lines: List[Any] = [info] try: pin_data = session.get_pin_metadata() if pin_data.default_value: lines.append("WARNING: Using default PIN!") tries_str = "%d/%d" % (pin_data.attempts_remaining, pin_data.total_attempts) except NotSupportedError: # Largest possible number of PIN tries to get back is 15 tries = session.get_pin_attempts() tries_str = "15 or more" if tries == 15 else str(tries) info["PIN tries remaining"] = tries_str try: # Bio metadata bio = session.get_bio_metadata() if bio.configured: info["Biometrics"] = ( f"Configured, {bio.attempts_remaining} attempts remaining" ) else: info["Biometrics"] = "Not configured" except NotSupportedError: try: # PUK metadata (on non-bio) puk_data = session.get_puk_metadata() if puk_data.attempts_remaining == 0: lines.append("PUK is blocked") elif puk_data.default_value: lines.append("WARNING: Using default PUK!") tries_str = "%d/%d" % ( puk_data.attempts_remaining, puk_data.total_attempts, ) info["PUK tries remaining"] = tries_str except NotSupportedError: # YK < 5.3 if pivman.puk_blocked: lines.append("PUK is blocked") try: metadata = session.get_management_key_metadata() if metadata.default_value: lines.append("WARNING: Using default Management key!") key_type = metadata.key_type except NotSupportedError: key_type = MANAGEMENT_KEY_TYPE.TDES info["Management key algorithm"] = key_type.name if pivman.has_derived_key: lines.append("Management key is derived from PIN.") if pivman.has_stored_key: lines.append("Management key is stored on the YubiKey, protected by PIN.") objects: Dict[str, Any] = {} lines.append(objects) try: objects["CHUID"] = session.get_object(OBJECT_ID.CHUID) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: objects["CHUID"] = "No data available" try: objects["CCC"] = session.get_object(OBJECT_ID.CAPABILITY) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: objects["CCC"] = "No data available" certs = list_certificates(session) try: keys = _list_keys(session) except NotSupportedError: keys = {} for slot in set(SLOT) - {SLOT.ATTESTATION}: if slot not in keys and slot not in certs: continue cert_data: Dict[str, Any] = {} objects[f"Slot {slot}"] = cert_data if slot in keys: cert_data["Private key type"] = keys[slot].key_type else: cert_data["Private key type"] = "EMPTY" cert = certs.get(slot, None) if cert: try: subject_dn = cert.subject.rfc4514_string() issuer_dn = cert.issuer.rfc4514_string() except ValueError as e: # Malformed certificates may throw ValueError logger.debug("Failed parsing certificate", exc_info=True) cert_data["Error"] = f"Malformed certificate: {e}" continue fingerprint = cert.fingerprint(hashes.SHA256()).hex() try: key_algo = KEY_TYPE.from_public_key(cert.public_key()).name except ValueError: key_algo = "Unsupported" serial = cert.serial_number try: try: # Prefer timezone-aware variant (cryptography >= 42) not_before: Optional[datetime] = cert.not_valid_before_utc except AttributeError: not_before = cert.not_valid_before except ValueError: logger.debug("Failed reading not_valid_before", exc_info=True) not_before = None try: try: # Prefer timezone-aware variant (cryptography >= 42) not_after: Optional[datetime] = cert.not_valid_after_utc except AttributeError: not_after = cert.not_valid_after except ValueError: logger.debug("Failed reading not_valid_after", exc_info=True) not_after = None # Print out everything cert_data["Public key type"] = key_algo cert_data["Subject DN"] = subject_dn cert_data["Issuer DN"] = issuer_dn cert_data["Serial"] = display_serial(serial) cert_data["Fingerprint"] = fingerprint if not_before: cert_data["Not before"] = not_before.isoformat() if not_after: cert_data["Not after"] = not_after.isoformat() elif slot in certs: cert_data["Error"] = "Failed to parse certificate" return lines _AllowedHashTypes = Union[ hashes.SHA224, hashes.SHA256, hashes.SHA384, hashes.SHA512, hashes.SHA3_224, hashes.SHA3_256, hashes.SHA3_384, hashes.SHA3_512, ] def _hash(key_type, hash_algorithm): if key_type in (KEY_TYPE.ED25519, KEY_TYPE.X25519): return None return hash_algorithm() def sign_certificate_builder( session: PivSession, slot: SLOT, key_type: KEY_TYPE, builder: x509.CertificateBuilder, hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.Certificate: """Sign a Certificate. :param session: The PIV session. :param slot: The slot. :param key_type: The key type. :param builder: The x509 certificate builder object. :param hash_algorithm: The hash algorithm, ignored for Curve 25519. """ logger.debug("Signing a certificate") dummy_key = _dummy_key(key_type) cert = builder.sign(dummy_key, _hash(key_type, hash_algorithm), default_backend()) sig = session.sign( slot, key_type, cert.tbs_certificate_bytes, _hash(key_type, hash_algorithm), padding.PKCS1v15(), # Only used for RSA ) seq = Tlv.parse_list(Tlv.unpack(0x30, cert.public_bytes(Encoding.DER))) # Replace signature, add unused bits = 0 seq[2] = Tlv(seq[2].tag, b"\0" + sig) # Re-assemble sequence der = Tlv(0x30, b"".join(seq)) return x509.load_der_x509_certificate(der, default_backend()) def sign_csr_builder( session: PivSession, slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], builder: x509.CertificateSigningRequestBuilder, hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.CertificateSigningRequest: """Sign a CSR. :param session: The PIV session. :param slot: The slot. :param public_key: The public key. :param builder: The x509 certificate signing request builder object. :param hash_algorithm: The hash algorithm, ignored for Curve 25519. """ logger.debug("Signing a CSR") key_type = KEY_TYPE.from_public_key(public_key) dummy_key = _dummy_key(key_type) csr = builder.sign(dummy_key, _hash(key_type, hash_algorithm), default_backend()) seq = Tlv.parse_list(Tlv.unpack(0x30, csr.public_bytes(Encoding.DER))) # Replace public key pub_format = ( PublicFormat.PKCS1 if key_type.algorithm == ALGORITHM.RSA else PublicFormat.SubjectPublicKeyInfo ) dummy_bytes = dummy_key.public_key().public_bytes(Encoding.DER, pub_format) pub_bytes = public_key.public_bytes(Encoding.DER, pub_format) seq[0] = Tlv(seq[0].replace(dummy_bytes, pub_bytes)) sig = session.sign( slot, key_type, seq[0], _hash(key_type, hash_algorithm), padding.PKCS1v15(), # Only used for RSA ) # Replace signature, add unused bits = 0 seq[2] = Tlv(seq[2].tag, b"\0" + sig) # Re-assemble sequence der = Tlv(0x30, b"".join(seq)) return x509.load_der_x509_csr(der, default_backend()) def generate_self_signed_certificate( session: PivSession, slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], subject_str: str, valid_from: datetime, valid_to: datetime, hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.Certificate: """Generate a self-signed certificate using a private key in a slot. :param session: The PIV session. :param slot: The slot. :param public_key: The public key. :param subject_str: The subject RFC 4514 string. :param valid_from: The date from when the certificate is valid. :param valid_to: The date when the certificate expires. :param hash_algorithm: The hash algorithm. """ logger.debug("Generating a self-signed certificate") key_type = KEY_TYPE.from_public_key(public_key) subject = parse_rfc4514_string(subject_str) builder = ( x509.CertificateBuilder() .public_key(public_key) .subject_name(subject) .issuer_name(subject) # Same as subject on self-signed certificate. .serial_number(x509.random_serial_number()) .not_valid_before(valid_from) .not_valid_after(valid_to) ) return sign_certificate_builder(session, slot, key_type, builder, hash_algorithm) def generate_csr( session: PivSession, slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], subject_str: str, hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.CertificateSigningRequest: """Generate a CSR using a private key in a slot. :param session: The PIV session. :param slot: The slot. :param public_key: The public key. :param subject_str: The subject RFC 4514 string. :param hash_algorithm: The hash algorithm. """ logger.debug("Generating a CSR") builder = x509.CertificateSigningRequestBuilder().subject_name( parse_rfc4514_string(subject_str) ) return sign_csr_builder(session, slot, public_key, builder, hash_algorithm) yubikey_manager-5.6.1/ykman/pcsc/0000775000175000017500000000000014775540152016325 5ustar winniewinnieyubikey_manager-5.6.1/ykman/pcsc/__init__.py0000644000175000017500000001670314777516541020452 0ustar winniewinnie# Copyright (c) 2020 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..base import YkmanDevice from yubikit.core import TRANSPORT, YUBIKEY, PID from yubikit.core.smartcard import SmartCardConnection from yubikit.management import USB_INTERFACE from yubikit.logging import LOG_LEVEL from smartcard import System from smartcard.Exceptions import CardConnectionException from smartcard.pcsc.PCSCExceptions import ListReadersException from smartcard.pcsc.PCSCContext import PCSCContext from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection from fido2.pcsc import CtapPcscDevice from time import sleep import subprocess # nosec import os import logging logger = logging.getLogger(__name__) YK_READER_NAME = "yubico yubikey" _YKMAN_NO_EXCLUSIVE = "YKMAN_NO_EXLUSIVE" # Figure out what the PID should be based on the reader name def _pid_from_name(name): if YK_READER_NAME not in name.lower(): return None interfaces = USB_INTERFACE(0) for iface in USB_INTERFACE: if iface.name in name: interfaces |= iface if "U2F" in name: interfaces |= USB_INTERFACE.FIDO key_type = YUBIKEY.NEO if "NEO" in name else YUBIKEY.YK4 return PID.of(key_type, interfaces) class ScardYubiKeyDevice(YkmanDevice): """YubiKey Smart card device""" def __init__(self, reader): # Base transport on reader name: NFC readers will have a different name if YK_READER_NAME in reader.name.lower(): transport = TRANSPORT.USB else: transport = TRANSPORT.NFC super(ScardYubiKeyDevice, self).__init__( transport, reader.name, _pid_from_name(reader.name) ) self.reader = reader def supports_connection(self, connection_type): if issubclass(CtapPcscDevice, connection_type): return self.transport == TRANSPORT.NFC return issubclass(ScardSmartCardConnection, connection_type) def open_connection(self, connection_type): if issubclass(ScardSmartCardConnection, connection_type): return self._open_smartcard_connection() elif issubclass(CtapPcscDevice, connection_type): if self.transport == TRANSPORT.NFC: connection = self.reader.createConnection() if os.environ.get(_YKMAN_NO_EXCLUSIVE) is None: excl_connection = ExclusiveConnectCardConnection(connection) try: dev = CtapPcscDevice(excl_connection, self.reader.name) logger.debug("Using exclusive CCID connection") return dev except CardConnectionException: logger.info("Failed to get exclusive CCID access") return CtapPcscDevice(connection, self.reader.name) return super(ScardYubiKeyDevice, self).open_connection(connection_type) def _open_smartcard_connection(self) -> SmartCardConnection: try: return ScardSmartCardConnection(self.reader.createConnection()) except CardConnectionException as e: if kill_scdaemon() or kill_yubikey_agent(): return ScardSmartCardConnection(self.reader.createConnection()) raise e class ScardSmartCardConnection(SmartCardConnection): def __init__(self, connection): if os.environ.get(_YKMAN_NO_EXCLUSIVE) is None: excl_connection = ExclusiveConnectCardConnection(connection) try: excl_connection.connect() self.connection = excl_connection logger.debug("Using exclusive CCID connection") except CardConnectionException: logger.info("Failed to get exclusive CCID access") connection.connect() self.connection = connection else: connection.connect() self.connection = connection atr = self.connection.getATR() self._transport = ( TRANSPORT.USB if atr and atr[1] & 0xF0 == 0xF0 else TRANSPORT.NFC ) @property def transport(self): return self._transport def close(self): self.connection.disconnect() def send_and_receive(self, apdu): """Sends a command APDU and returns the response data and sw""" logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", apdu.hex()) data, sw1, sw2 = self.connection.transmit(list(apdu)) logger.log( LOG_LEVEL.TRAFFIC, "RECV: %s SW=%02x%02x", bytes(data).hex(), sw1, sw2 ) return bytes(data), sw1 << 8 | sw2 def kill_scdaemon(): killed = False try: # Works for Windows. from win32com.client import GetObject from win32api import OpenProcess, CloseHandle, TerminateProcess wmi = GetObject("winmgmts:") ps = wmi.InstancesOf("Win32_Process") for p in ps: if p.Properties_("Name").Value == "scdaemon.exe": pid = p.Properties_("ProcessID").Value handle = OpenProcess(1, False, pid) TerminateProcess(handle, -1) CloseHandle(handle) killed = True except ImportError: # Works for Linux and OS X. return_code = subprocess.call(["pkill", "-9", "scdaemon"]) # nosec if return_code == 0: killed = True if killed: sleep(0.1) return killed def kill_yubikey_agent(): killed = False return_code = subprocess.call(["pkill", "-HUP", "yubikey-agent"]) # nosec if return_code == 0: killed = True if killed: sleep(0.1) return killed def list_readers(): try: return System.readers() except ListReadersException: # If the PCSC system has restarted the context might be stale, try # forcing a new context (This happens on Windows if the last reader is # removed): PCSCContext.instance = None return System.readers() def list_devices(name_filter=None): name_filter = YK_READER_NAME if name_filter is None else name_filter devices = [] for reader in list_readers(): if name_filter.lower() in reader.name.lower(): devices.append(ScardYubiKeyDevice(reader)) return devices yubikey_manager-5.6.1/ykman/logging.py0000644000175000017500000000577114777516541017414 0ustar winniewinnie# Copyright (c) 2022 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.logging import LOG_LEVEL import logging logging.addLevelName(LOG_LEVEL.TRAFFIC, LOG_LEVEL.TRAFFIC.name) logger = logging.getLogger(__name__) def _print_box(*lines): w = max([len(ln) for ln in lines]) bar = "#" * (w + 4) box = ["", bar] for ln in [""] + list(lines) + [""]: box.append(f"# {ln.ljust(w)} #") box.append(bar) return "\n".join(box) TRAFFIC_WARNING = ( "WARNING: All data sent to/from the YubiKey will be logged!", "This data may contain sensitive values, such as secret keys, PINs or passwords!", ) DEBUG_WARNING = ( "WARNING: Sensitive data may be logged!", "Some personally identifying information may be logged, such as usernames!", ) def set_log_level(level: LOG_LEVEL): logging.getLogger().setLevel(level) logger.info(f"Logging at level: {level.name}") if level <= LOG_LEVEL.TRAFFIC: logger.warning(_print_box(*TRAFFIC_WARNING)) elif level <= LOG_LEVEL.DEBUG: logger.warning(_print_box(*DEBUG_WARNING)) def init_logging(log_level: LOG_LEVEL, log_file=None, replace=False): formatter = logging.Formatter( "%(levelname)s %(asctime)s.%(msecs)d [%(name)s.%(funcName)s:%(lineno)d] " "%(message)s", "%H:%M:%S", "%", ) if log_file: handler: logging.Handler = logging.FileHandler(log_file) else: handler = logging.StreamHandler() handler.setFormatter(formatter) root = logging.getLogger() if replace: for h in root.handlers[:]: root.removeHandler(h) root.addHandler(handler) set_log_level(log_level) if log_file: logger.warning(f"Logging to file: {log_file}") yubikey_manager-5.6.1/ykman/settings.py0000644000175000017500000001030314777516541017611 0ustar winniewinnie# Copyright (c) 2017 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import json import keyring from pathlib import Path from cryptography.fernet import Fernet, InvalidToken import logging logger = logging.getLogger(__name__) XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", "~/.local/share") + "/ykman" XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", "~/.config") + "/ykman" KEYRING_SERVICE = os.environ.get("YKMAN_KEYRING_SERVICE", "ykman") KEYRING_KEY = os.environ.get("YKMAN_KEYRING_KEY", "wrap_key") class Settings(dict): _config_dir = XDG_CONFIG_HOME def __init__(self, name): self.fname = Path(self._config_dir).expanduser().resolve() / (name + ".json") if self.fname.is_file(): try: with self.fname.open("r") as fd: self.update(json.load(fd)) except Exception: # The file may be corrupted or unreadable, ignore it logger.warning("Error reading settings file", exc_info=True) def __eq__(self, other): return other is not None and self.fname == other.fname def __ne__(self, other): return other is None or self.fname != other.fname def write(self): conf_dir = self.fname.parent if not conf_dir.is_dir(): conf_dir.mkdir(0o700, parents=True) with self.fname.open("w") as fd: json.dump(self, fd, indent=2) __hash__ = None # Deprecated, just use Settings. Remove in 6.0 Configuration = Settings class KeystoreError(Exception): """Error accessing the OS keystore""" class UnwrapValueError(Exception): """Error unwrapping a particular secret value""" class AppData(Settings): _config_dir = XDG_DATA_HOME def __init__(self, name, keyring_service=KEYRING_SERVICE, keyring_key=KEYRING_KEY): super().__init__(name) self._service = keyring_service self._username = keyring_key @property def keyring_unlocked(self) -> bool: return hasattr(self, "_fernet") def ensure_unlocked(self): if not self.keyring_unlocked: try: wrap_key = keyring.get_password(self._service, self._username) except keyring.errors.KeyringError: raise KeystoreError("Keyring locked or unavailable") if wrap_key is None: key = Fernet.generate_key() keyring.set_password(self._service, self._username, key.decode()) self._fernet = Fernet(key) else: self._fernet = Fernet(wrap_key) def get_secret(self, key: str): self.ensure_unlocked() try: return json.loads(self._fernet.decrypt(self[key].encode())) except InvalidToken: raise UnwrapValueError("Undecryptable value") def put_secret(self, key: str, value) -> None: self.ensure_unlocked() self[key] = self._fernet.encrypt(json.dumps(value).encode()).decode() yubikey_manager-5.6.1/ykman/device.py0000644000175000017500000002472414777516541017224 0ustar winniewinnie# Copyright (c) 2015-2020 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import Connection, PID, TRANSPORT, YUBIKEY from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.management import ( DeviceInfo, USB_INTERFACE, ) from yubikit.support import read_info from .base import YkmanDevice from .hid import ( list_otp_devices as _list_otp_devices, list_ctap_devices as _list_ctap_devices, ) from .pcsc import list_devices as _list_ccid_devices from smartcard.pcsc.PCSCExceptions import EstablishContextException from smartcard.Exceptions import NoCardException from time import sleep, time from collections import Counter from typing import ( Dict, Mapping, List, Tuple, Iterable, Type, Hashable, Set, ) import sys import ctypes import logging logger = logging.getLogger(__name__) def _warn_once(message, e_type=Exception): warned: List[bool] = [] def outer(f): def inner(): try: return f() except e_type: if not warned: logger.warning(message) warned.append(True) raise return inner return outer @_warn_once( "PC/SC not available. Smart card (CCID) protocols will not function.", EstablishContextException, ) def list_ccid_devices(): """List CCID devices.""" return _list_ccid_devices() @_warn_once("No CTAP HID backend available. FIDO protocols will not function.") def list_ctap_devices(): """List CTAP devices.""" return _list_ctap_devices() @_warn_once("No OTP HID backend available. OTP protocols will not function.") def list_otp_devices(): """List OTP devices.""" return _list_otp_devices() _CONNECTION_LIST_MAPPING = { SmartCardConnection: list_ccid_devices, OtpConnection: list_otp_devices, FidoConnection: list_ctap_devices, } def scan_devices() -> Tuple[Mapping[PID, int], int]: """Scan USB for attached YubiKeys, without opening any connections. :return: A dict mapping PID to device count, and a state object which can be used to detect changes in attached devices. """ fingerprints = set() merged: Dict[PID, int] = {} for list_devs in _CONNECTION_LIST_MAPPING.values(): try: devs = list_devs() except Exception: logger.debug("Device listing error", exc_info=True) devs = [] merged.update(Counter(d.pid for d in devs if d.pid is not None)) fingerprints.update({d.fingerprint for d in devs}) if sys.platform == "win32" and not bool(ctypes.windll.shell32.IsUserAnAdmin()): from .hid.windows import list_paths counter: Counter[PID] = Counter() for pid, path in list_paths(): if pid not in merged: try: counter[PID(pid)] += 1 fingerprints.add(path) except ValueError: # Unsupported PID logger.debug(f"Unsupported Yubico device with PID: {pid:02x}") merged.update(counter) return merged, hash(tuple(fingerprints)) class _PidGroup: def __init__(self, pid): self._pid = pid self._infos: Dict[Hashable, DeviceInfo] = {} self._resolved: Dict[Hashable, Dict[USB_INTERFACE, YkmanDevice]] = {} self._unresolved: Dict[USB_INTERFACE, List[YkmanDevice]] = {} self._devcount: Dict[USB_INTERFACE, int] = Counter() self._fingerprints: Set[Hashable] = set() self._ctime = time() def _key(self, info): return ( info.serial, info.version, info.form_factor, str(info.supported_capabilities), info.config.get_bytes(False), info.is_locked, info.is_fips, info.is_sky, ) def add(self, conn_type, dev, force_resolve=False): logger.debug(f"Add device for {conn_type}: {dev}") iface = conn_type.usb_interface self._fingerprints.add(dev.fingerprint) self._devcount[iface] += 1 if force_resolve or len(self._resolved) < max(self._devcount.values()): try: with dev.open_connection(conn_type) as conn: info = read_info(conn, dev.pid) key = self._key(info) self._infos[key] = info self._resolved.setdefault(key, {})[iface] = dev logger.debug(f"Resolved device {info.serial}") return except Exception: logger.warning("Failed opening device", exc_info=True) self._unresolved.setdefault(iface, []).append(dev) def supports_connection(self, conn_type): return conn_type.usb_interface in self._devcount def connect(self, key, conn_type): iface = conn_type.usb_interface resolved = self._resolved[key].get(iface) if resolved: return resolved.open_connection(conn_type) devs = self._unresolved.get(iface, []) failed = [] try: while devs: dev = devs.pop() try: conn = dev.open_connection(conn_type) info = read_info(conn, dev.pid) dev_key = self._key(info) if dev_key in self._infos: self._resolved.setdefault(dev_key, {})[iface] = dev logger.debug(f"Resolved device {info.serial}") if dev_key == key: return conn elif self._pid.yubikey_type == YUBIKEY.NEO and not devs: self._resolved.setdefault(key, {})[iface] = dev logger.debug("Resolved last NEO device without serial") return conn conn.close() except Exception: logger.warning("Failed opening device", exc_info=True) failed.append(dev) finally: devs.extend(failed) if self._devcount[iface] < len(self._infos): logger.debug(f"Checking for more devices over {iface!s}") for dev in _CONNECTION_LIST_MAPPING[conn_type](): if self._pid == dev.pid and dev.fingerprint not in self._fingerprints: self.add(conn_type, dev, True) resolved = self._resolved[key].get(iface) if resolved: return resolved.open_connection(conn_type) # Retry if we are within a 5 second period after creation, # as not all USB interface become usable at the exact same time. if time() < self._ctime + 5: logger.debug("Device not found, retry in 1s") sleep(1.0) return self.connect(key, conn_type) raise ValueError("Failed to connect to the device") def get_devices(self): results = [] for key, info in self._infos.items(): dev = next(iter(self._resolved[key].values())) results.append( (_UsbCompositeDevice(self, key, dev.fingerprint, dev.pid), info) ) return results class _UsbCompositeDevice(YkmanDevice): def __init__(self, group, key, fingerprint, pid): super().__init__(TRANSPORT.USB, fingerprint, pid) self._group = group self._key = key def supports_connection(self, connection_type): return self._group.supports_connection(connection_type) def open_connection(self, connection_type): if not self.supports_connection(connection_type): raise ValueError("Unsupported Connection type") # Allow for ~3s reclaim time on NEO for CCID assert self.pid # nosec if self.pid.yubikey_type == YUBIKEY.NEO and issubclass( connection_type, SmartCardConnection ): for _ in range(6): try: return self._group.connect(self._key, connection_type) except (NoCardException, ValueError): sleep(0.5) return self._group.connect(self._key, connection_type) def list_all_devices( connection_types: Iterable[Type[Connection]] = _CONNECTION_LIST_MAPPING.keys(), ) -> List[Tuple[YkmanDevice, DeviceInfo]]: """Connect to all attached YubiKeys and read device info from them. :param connection_types: An iterable of YubiKey connection types. :return: A list of (device, info) tuples for each connected device. """ groups: Dict[PID, _PidGroup] = {} for connection_type in connection_types: for base_type in _CONNECTION_LIST_MAPPING: if issubclass(connection_type, base_type): connection_type = base_type break else: raise ValueError("Invalid connection type") try: for dev in _CONNECTION_LIST_MAPPING[connection_type](): group = groups.setdefault(dev.pid, _PidGroup(dev.pid)) group.add(connection_type, dev) except Exception: logger.exception("Unable to list devices for connection") devices = [] for group in groups.values(): devices.extend(group.get_devices()) return devices yubikey_manager-5.6.1/ykman/base.py0000644000175000017500000000400214777516541016662 0ustar winniewinnie# Copyright (c) 2015-2020 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import TRANSPORT, PID, YubiKeyDevice from typing import Optional, Hashable class YkmanDevice(YubiKeyDevice): """YubiKey device reference, with optional PID""" def __init__(self, transport: TRANSPORT, fingerprint: Hashable, pid: Optional[PID]): super(YkmanDevice, self).__init__(transport, fingerprint) self._pid = pid @property def pid(self) -> Optional[PID]: """Return the PID of the YubiKey, if available.""" return self._pid def __repr__(self): return "%s(pid=%04x, fingerprint=%r)" % ( type(self).__name__, self.pid or 0, self.fingerprint, ) yubikey_manager-5.6.1/ykman/util.py0000644000175000017500000001630314777516541016734 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core import Tlv, int2bytes from yubikit.core.smartcard import ( SmartCardConnection, SmartCardProtocol, ApduError, ApplicationNotAvailableError, ) from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend from cryptography import x509 from typing import Tuple import ctypes import logging logger = logging.getLogger(__name__) PEM_IDENTIFIER = b"-----BEGIN" class InvalidPasswordError(Exception): """Raised when parsing key/certificate and the password might be wrong/missing.""" def _parse_pkcs12(data, password): try: key, cert, cas = pkcs12.load_key_and_certificates( data, password, default_backend() ) if cert: cas.insert(0, cert) return key, cas except ValueError as e: # cryptography raises ValueError on wrong password raise InvalidPasswordError(e) def parse_private_key(data, password): """Identify, decrypt and return a cryptography private key object. :param data: The private key in bytes. :param password: The password to decrypt the private key (if it is encrypted). """ # PEM if is_pem(data): encrypted = b"ENCRYPTED" in data if encrypted and password is None: raise InvalidPasswordError("No password provided for encrypted key.") try: return serialization.load_pem_private_key( data, password, backend=default_backend() ) except ValueError as e: # Cryptography raises ValueError if decryption fails. if encrypted: raise InvalidPasswordError(e) logger.debug("Failed to parse PEM private key ", exc_info=True) except Exception: logger.debug("Failed to parse PEM private key ", exc_info=True) # PKCS12 if is_pkcs12(data): return _parse_pkcs12(data, password)[0] # DER try: return serialization.load_der_private_key( data, password, backend=default_backend() ) except Exception: logger.debug("Failed to parse private key as DER", exc_info=True) # All parsing failed raise ValueError("Could not parse private key.") def parse_certificates(data, password): """Identify, decrypt and return a list of cryptography x509 certificates. :param data: The certificate(s) in bytes. :param password: The password to decrypt the certificate(s). """ logger.debug("Attempting to parse certificate using PEM, PKCS12 and DER") # PEM if is_pem(data): certs = [] for cert in data.split(PEM_IDENTIFIER): if cert: try: certs.append( x509.load_pem_x509_certificate( PEM_IDENTIFIER + cert, default_backend() ) ) except Exception: logger.debug("Failed to parse PEM certificate", exc_info=True) # Could be valid PEM but not certificates. if not certs: raise ValueError("PEM file does not contain any certificate(s)") return certs # PKCS12 if is_pkcs12(data): return _parse_pkcs12(data, password)[1] # DER try: return [x509.load_der_x509_certificate(data, default_backend())] except Exception: logger.debug("Failed to parse certificate as DER", exc_info=True) raise ValueError("Could not parse certificate.") def get_leaf_certificates(certs): """Extract the leaf certificates from a list of certificates. Leaf certificates are ones whose subject does not appear as issuer among the others. :param certs: The list of cryptography x509 certificate objects. """ issuers = [cert.issuer for cert in certs] leafs = [cert for cert in certs if cert.subject not in issuers] return leafs def is_pem(data): return data and PEM_IDENTIFIER in data def is_pkcs12(data): """ Tries to identify a PKCS12 container. The PFX PDU version is assumed to be v3. See: https://tools.ietf.org/html/rfc7292. """ try: header = Tlv.parse_from(Tlv.unpack(0x30, data))[0] return header.tag == 0x02 and header.value == b"\x03" except ValueError: logger.debug("Unable to parse TLV", exc_info=True) return False def display_serial(serial: int) -> str: """Displays an x509 certificate serial number in a readable format.""" if serial >= 0x10000000000000000: return ":".join(f"{b:02x}" for b in int2bytes(serial, 20)) return f"{serial} ({hex(serial)})" class OSVERSIONINFOW(ctypes.Structure): _fields_ = [ ("dwOSVersionInfoSize", ctypes.c_ulong), ("dwMajorVersion", ctypes.c_ulong), ("dwMinorVersion", ctypes.c_ulong), ("dwBuildNumber", ctypes.c_ulong), ("dwPlatformId", ctypes.c_ulong), ("szCSDVersion", ctypes.c_wchar * 128), ] def get_windows_version() -> Tuple[int, int, int]: """Get the true Windows version, since sys.getwindowsversion lies.""" osvi = OSVERSIONINFOW() osvi.dwOSVersionInfoSize = ctypes.sizeof(osvi) ctypes.windll.Ntdll.RtlGetVersion(ctypes.byref(osvi)) # type: ignore return osvi.dwMajorVersion, osvi.dwMinorVersion, osvi.dwBuildNumber _RESTRICTED_NDEF = bytes.fromhex("001FD1011B5504") + b"yubico.com/getting-started" def is_nfc_restricted(connection: SmartCardConnection) -> bool: """Check if the given SmartCardConnection over NFC is in restricted NFC mode.""" try: p = SmartCardProtocol(connection) p.select(bytes.fromhex("D2760000850101")) p.send_apdu(0x00, 0xA4, 0x00, 0x0C, bytes([0xE1, 0x04])) ndef = p.send_apdu(0x00, 0xB0, 0x00, 0x00) except (ApduError, ApplicationNotAvailableError): ndef = None return ndef == _RESTRICTED_NDEF yubikey_manager-5.6.1/ykman/__init__.py0000644000175000017500000000256214777516541017520 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. __version__ = "5.6.1" yubikey_manager-5.6.1/ykman/py.typed0000644000175000017500000000000014777516541017067 0ustar winniewinnieyubikey_manager-5.6.1/ykman/openpgp.py0000644000175000017500000001244714777516541017434 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.core.smartcard import ( SmartCardConnection, SmartCardProtocol, ApduError, SW, AID, ) from yubikit.openpgp import ( OpenPgpSession, KEY_REF, KdfNone, PW, INS, _INVALID_PIN, AlgorithmAttributes, RsaAttributes, EcAttributes, ) from datetime import datetime, timezone import logging logger = logging.getLogger(__name__) def safe_reset(connection: SmartCardConnection) -> None: """Performs an OpenPGP factory reset while avoiding any unneccessary commands. If any data is unreadable preventing the OpenPgpSession from initializing, then OpenPgpSession.reset() will not be able to be called. This function can instead be used to reset the application into a fresh state. """ logger.debug("Attempting safe OpenPGP factory reset") protocol = SmartCardProtocol(connection) protocol.select(AID.OPENPGP) for pw in (PW.USER, PW.ADMIN): logger.debug(f"Verify {pw.name} PIN with invalid attempts until blocked") while True: try: protocol.send_apdu(0, INS.VERIFY, 0, pw, _INVALID_PIN) except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: continue # Either blocked, or an unexpected error, move to the next step break # Reset the application logger.debug("Sending TERMINATE, then ACTIVATE") protocol.send_apdu(0, INS.TERMINATE, 0, 0) protocol.send_apdu(0, INS.ACTIVATE, 0, 0) logger.info("OpenPGP application data reset performed") def _format_ref(ref: KEY_REF) -> str: if ref == KEY_REF.SIG: return "Signature key" if ref == KEY_REF.DEC: return "Decryption key" if ref == KEY_REF.AUT: return "Authentication key" if ref == KEY_REF.ATT: return "Attestation key" return ref.name def _format_fingerprint(fp: bytes) -> str: return " ".join( " ".join(fp[h * 10 + s * 2 :][:2].hex() for s in range(5)) for h in range(2) ).upper() def _format_date(timestamp: int) -> str: return datetime.fromtimestamp(timestamp, timezone.utc).isoformat() def _format_algorithm(alg: AlgorithmAttributes) -> str: if isinstance(alg, RsaAttributes): return f"RSA{alg.n_len}" if isinstance(alg, EcAttributes): return f"{alg.oid}" return "Unknown key type" def get_key_info(discretionary, ref, status): alg = discretionary.get_algorithm_attributes(ref) return { "Key slot": _format_ref(ref), "Fingerprint": _format_fingerprint(discretionary.fingerprints[ref]), "Algorithm": _format_algorithm(alg), "Origin": status.name if status is not None else "UNKNOWN", "Created": _format_date(discretionary.generation_times[ref]), "Touch policy": discretionary.get_uif(ref), } def get_openpgp_info(session: OpenPgpSession): """Get human readable information about the OpenPGP configuration. :param session: The OpenPGP session. """ data = session.get_application_related_data() discretionary = data.discretionary retries = discretionary.pw_status info = { "OpenPGP version": "%d.%d" % data.aid.version, "Application version": "%d.%d.%d" % session.version, "PIN tries remaining": retries.attempts_user, "Reset code tries remaining": retries.attempts_reset, "Admin PIN tries remaining": retries.attempts_admin, "Require PIN for signature": retries.pin_policy_user, "KDF enabled": not isinstance(session.get_kdf(), KdfNone), } for ref, fp in discretionary.fingerprints.items(): if session.version >= (5, 2, 0): if not discretionary.key_information[ref] or ref == KEY_REF.ATT: continue else: if not any(fp): continue info[_format_ref(ref)] = { "Fingerprint": _format_fingerprint(fp), "Touch policy": discretionary.get_uif(ref), } return info yubikey_manager-5.6.1/ykman/hsmauth.py0000644000175000017500000000355514777516541017435 0ustar winniewinnie# Copyright (c) 2023 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from yubikit.hsmauth import HsmAuthSession, INITIAL_RETRY_COUNTER import os def get_hsmauth_info(session: HsmAuthSession): """Get information about the YubiHSM Auth application.""" retries = session.get_management_key_retries() info = { "YubiHSM Auth version": session.version, "Management key retries remaining": f"{retries}/{INITIAL_RETRY_COUNTER}", } return info def generate_random_management_key() -> bytes: """Generate a new random management key.""" return os.urandom(16) yubikey_manager-5.6.1/ykman/logging_setup.py0000644000175000017500000000467714777516541020640 0ustar winniewinnie# Copyright (c) 2015 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ykman import __version__ as ykman_version from ykman.util import get_windows_version from ykman.logging import init_logging from yubikit.logging import LOG_LEVEL from datetime import datetime import platform import logging import warnings import ctypes import sys import os logger = logging.getLogger(__name__) def log_sys_info(log): log(f"ykman: {ykman_version}") log(f"Python: {sys.version}") log(f"Platform: {sys.platform}") log(f"Arch: {platform.machine()}") if sys.platform == "win32": log(f"Windows version: {get_windows_version()}") is_admin = bool(ctypes.windll.shell32.IsUserAnAdmin()) else: is_admin = os.getuid() == 0 log(f"Running as admin: {is_admin}") log("System date: %s", datetime.today().strftime("%Y-%m-%d")) def setup(log_level_name, log_file=None): warnings.warn( "logging_setup.setup is deprecated, use logging.init_loging instead", DeprecationWarning, ) log_level = LOG_LEVEL[log_level_name.upper()] init_logging(log_level, log_file=log_file, replace=log_file is None) log_sys_info(logger.debug) yubikey_manager-5.6.1/COPYING0000644000175000017500000000245214777516541015321 0ustar winniewinnieCopyright (c) 2015 Yubico AB All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. yubikey_manager-5.6.1/NEWS0000644000175000017500000004654214777516541014775 0ustar winniewinnie* Version 5.6.1 (released 2025-03-18) * Fix: Version 5.6.0 uses Exclusive smart card connections, which caused connections to fail if another application was accessing the YubiKey. This version adds a fallback to use non-exclusive connections in case of such a failure. * Bugfix: APDU encoding was slightly incorrect for commands which specify Le, but no data body. This caused issued on some platforms. * CLI: The "fido info" command now shows the YubiKey AAGUID, when available. * Version 5.6.0 (released 2025-03-12) * SCP: Add support for specifying Le (needed in OpenPGP get_challenge). * PIV: When writing a new CHUID, prefer to keep data from the old one if possible. * CLI: Specifying public-key is now optional when generating a PIV certificate, if a public key can be read from the YubiKey itself. * CLI: (YK FIPS) Disallow --protect for PIV when not in FIPS approved state. * CLI: Support specifying Le in "apdu" command. * CLI: Show OpenPGP key information in "openpgp info" and "openpgp keys info" commands. * CLI: Detect OpenPGP memory corruption, and correctly factory reset OpenPGP if needed. * CLI: Don't fail on corrupted configuration files, instead show a warning. * Require Poetry >= 2.0 for building and packaging of the library. * Bugfix: CLI - Don't use extended APDUs in the "apdu" command on old YubiKeys which do not support it. * Version 5.5.1 (released 2024-07-01) * Bugfix: CLI - Don't use formatting that doesn't work on older Python versions. Note: As the 5.5.0 installers bundle Python 3.12, this will be a source-only release. * Version 5.5.0 (released 2024-06-26) * Add Secure Channel support to smartcard sessions. * Support extended APDUs in the "apdu" command (this is now the default). * HSMAuth: Treat management key as a PIN/password instead of a key, adding new CLI commands. * PIV: Deprecate explicit passing of management key type when authenticating. * CLI: Add "config nfc --restrict" command to set "NFC restricted mode". * CLI: Display more information about PIN complexity and FIPS status for compatible YubiKeys. * CLI: Improved error messages for illegal values of PIV PIN and PUK. * CLI: Drop error messages for old 3.x commands. * CLI: Removal of --upload for YubiCloud credentials. Export to CSV and upload via web instead. * CLI: Add more detailed information to the CLI output for several commands. * Version 5.4.0 (released 2024-03-27) * Support for YubiKey Bio Multi-protocol Edition. * CLI: Improve error messages for several failures. * Attempt to send SIGHUP to yubikey-agent if it is blocking the connection. * Bugfix: Allow "fido config" to work when no PIN is set on the YubiKey. * Bugfix: MacOS - Fix race condition resulting in unneeded delay in fido commands over USB. * Bugfix: Linux - Fix error when listing OTP devices when no YubiKeys are attached. * Bugfix: OpenPGP - Fix RSA key generation on YubiKey NEO. * Version 5.3.0 (released 2024-01-31) ** FIDO: Add new CLI commands for PIN management and authenticator config (force-change, set-min-length, toggle-always-uv, enable-ep-attestation). ** PIV: Improve handling of legacy "PUK blocked" flag. ** PIV: Improve handling of malformed certificates. ** PIV: Display key information in "piv info" output on supported devices. ** OTP: Fix some commands incorrectly showing errors when used over NFC/CCID. ** Add tab-completion for YubiKey serial numbers and NFC readers. * Version 5.2.1 (released 2023-10-10) ** Add support for Python 3.12. ** OATH: detect and remove corrupted credentials. ** Bugfix: HSMAUTH: Fix order of CLI arguments. * Version 5.2.0 (released 2023-08-21) ** PIV: Support for compressed certificates. ** OpenPGP: Use InvalidPinError for wrong PIN. ** Add YubiHSM Auth application support. ** Improved API documentation. ** Scripting: Add name attribute to device. ** Bugfix: PIV: don't throw InvalidPasswordError on malformed PEM private key. * Version 5.1.1 (released 2023-04-27) ** Bugfix: PIV: string representation of SLOT caused infinite loop on Python <3.11. ** Bugfix: Fix errors in 'ykman config nfc' on YubiKeys without NFC capability. ** Bugfix: Fix error message shown when invalid modhex input length given for YubiOTP. * Version 5.1.0 (released 2023-04-17) ** Add OpenPGP functionality to supported API. ** Add PIV key info command to CLI. ** PIV: Support signing prehashed data via API. ** Bugfix: Fix signing PIV certificates/CSRs with key that always requires PIN. ** Bugfix: Fix incorrect display name detection for certain keys over NFC. * Version 5.0.1 (released 2023-01-17) ** Bugfix: Fix the interactive confirmation prompt for some CLI commands. ** Bugfix: OpenPGP Signature PIN policy values were swapped. ** Bugfix: FIDO: Handle discoverable credentials that are missing name or displayName. ** Add support for Python 3.11. ** Remove extra whitespace characters from CLI into command output. * Version 5.0.0 (released 2022-10-19) ** Various cleanups and improvements to the API. ** Improvements to the handling of YubiKeys and connections. ** Command aliases for ykman 3.x (introduced in ykman 4.0) have now been dropped. ** Installers for ykman are now provided for Windows (amd64) and MacOS (universal2). ** Logging has been improved, and a new TRAFFIC level has been introduced. ** The codebase has been improved for scripting usage, either directly as a Python module, or via the new "ykman script" command. See doc/Scripting.adoc, doc/Library_Usage.adoc, and examples/ for more details. ** PIV: Add support for dotted-string OIDs when parsing RFC4514 strings. ** PIV: Drop support for signing certificates and CSRs with SHA-1. ** FIDO: Credential management commands have been improved to deal with ambiguity in certain cases. ** OATH: Access Keys ("remembered" passwords) are now stored in the system keyring. ** OpenPGP: Commands have been added to manage PINs. * Version 4.0.9 (released 2022-06-17) ** Dependency: Add support for python-fido2 1.x ** Fix: Drop stated support for Click 6 as features from 7 are being used. * Version 4.0.8 (released 2022-01-31) ** Bugfix: Fix error message for invalid modhex when programing a YubiOTP credential. ** Bugfix: Fix issue with displaying a Steam credential when it is the only account. ** Bugfix: Prevent installation of files in site-packages root. ** Bugfix: Fix cleanup logic in PIV for protected management key. ** Add support for token identifier when programming slot-based HOTP. ** Add support for programming NDEF in text mode. ** Dependency: Add support for Cryptography <= 38. * Version 4.0.7 (released 2021-09-08) ** Bugfix release: Fix broken naming for "YubiKey 4", and a small OATH issue with touch Steam credentials. * Version 4.0.6 (released 2021-09-08) ** Improve handling of YubiKey device reboots. ** More consistently mask PIN/password input in prompts. ** Support switching mode over CCID for YubiKey Edge. ** Run pkill from PATH instead of fixed location. * Version 4.0.5 (released 2021-07-16) ** Bugfix: Fix PIV feature detection for some YubiKey NEO versions. ** Bugfix: Fix argument short form for --period when adding TOTP credentials. ** Bugfix: More strict validation for some arguments, resulting in better error messages. ** Bugfix: Correctly handle TOTP credentials using period != 30 AND touch_required. ** Bugfix: Fix prompting for access code in the otp settings command (now uses "-A -"). * Version 4.0.3 (released 2021-05-17) ** Add support for fido reset over NFC. ** Bugfix: The --touch argument to piv change-management-key was ignored. ** Bugfix: Don't prompt for password when importing PIV key/cert if file is invalid. ** Bugfix: Fix setting touch-eject/auto-eject for YubiKey 4 and NEO. ** Bugfix: Detect PKCS#12 format when outer sequence uses indefinite length. ** Dependency: Add support for Click 8. * Version 4.0.2 (released 2021-04-12) ** Update device names. ** Add read_info output to the --diagnose command, and show exception types. ** Bugfix: Fix read_info for YubiKey Plus. * Version 4.0.1 (released 2021-03-29) ** Add support for YK5-based FIPS YubiKeys. ** Bugfix: Fix OTP device enumeration on Win32. * Version 4.0.0 (released 2021-03-02) ** Drop support for Python < 3.6. ** Drop reliance on libusb and libykpersonalize. ** Support the "fido" and "otp" subcommands over NFC (using the --reader flag) ** New "ykman --diagnose" command to aid in troubleshooting. ** New "ykman apdu" command for sending raw APDUs over the smart card interface. ** Restructuring of subcommands, with aliases for old versions (to be removed in a future release). ** Major changes to the underlying "library" code: *** New "yubikit" package added for custom development and advanced scripting. *** Type hints added for a large part of the "public" API. ** OpenPGP: Add support for KDF enabled YubiKeys. ** Static password: Add support for FR, IT, UK and BEPO keyboard layouts. * Version 3.1.2 (released 2021-01-21) ** Bugfix release: Fix dependency on python-fido2 version. * Version 3.1.1 (released 2020-01-29) ** Add support for YubiKey 5C NFC ** OpenPGP: set-touch now performs compatibility checks before prompting for PIN ** OpenPGP: Improve error messages and documentation for set-touch ** PIV: read-object command no longer adds a trailing newline ** CLI: Hint at missing permissions when opening a device fails ** Linux: Improve error handling when pcscd is not running ** Windows: Improve how .DLL files are loaded, thanks to Marius Gabriel Mihai for reporting this! ** Bugfix: set-touch now accepts the cached-fixed option ** Bugfix: Fix crash in OtpController.prepare_upload_key() error parsing ** Bugfix: Fix crash in piv info command when a certificate slot contains an invalid certificate ** Library: PivController.read_certificate(slot) now wraps certificate parsing exceptions in new exception type `InvalidCertificate` ** Library: PivController.list_certificates() now returns `None` for slots containing invalid certificate, instead of raising an exception * Version 3.1.0 (released 2019-08-20) ** Add support for YubiKey 5Ci ** OpenPGP: the info command now prints OpenPGP specification version as well ** OpenPGP: Update support for attestation to match OpenPGP v3.4 ** PIV: Use UTC time for self-signed certificates ** OTP: Static password now supports the Norman keyboard layout * Version 3.0.0 (released 2019-06-24) ** Add support for new YubiKey Preview and lightning form factor ** FIDO: Support for credential management ** OpenPGP: Support for OpenPGP attestation, cardholder certificates and cached touch policies ** OTP: Add flag for using numeric keypad when sending digits * Version 2.1.1 (released 2019-05-28) ** OTP: Add initial support for uploading Yubico OTP credentials to YubiCloud ** Don't automatically select the U2F applet on YubiKey NEO, it might be blocked by the OS ** ChalResp: Always pad challenge correctly ** Bugfix: Don't crash with older versions of cryptography ** Bugfix: Password was always prompted in OATH command, even if sent as argument * Version 2.1.0 (released 2019-03-11) ** Add --reader flag to ykman list, to list available smart card readers ** FIPS: Checking if a YubiKey FIPS is in FIPS mode is now opt-in, with the --check-fips flag ** PIV: Add commands for writing and reading arbitrary PIV objects ** PIV: Verify that the PIN must be between 6 - 8 characters long ** PIV: In import-certificate, make the verification that the certificate and private key matches opt-in, with the --verify flag ** PIV: The piv info command now shows the serial number of the certificates ** PIV: The piv info command now shows the full Distinguished Name (DN) of the certificate subject and issuer, if possible ** PIV: Malformed certificates are now handled better ** OpenPGP: The openpgp touch command now shows current touch policies ** The ykman usb/nfc config command now accepts openpgp as well as opgp as an argument ** Bugfix: Fix support for german (DE) keyboard layout for static passwords * Version 2.0.0 (released 2019-01-09) ** Add support for Security Key NFC ** Add experimental support for external smart card reader. See --reader flag ** Add a minimal manpage ** Add examples in help texts ** PIV: update CHUID when importing a certificate ** PIV: Optionally validate that private key and certificate match when importing a certificate (on by default in CLI) ** PIV: Improve support for importing certificate chains and .PEM files with comments ** Breaking API changes: *** Merge CCID status word constants into a single SW enum in ykman.driver_ccid *** Throw custom exception types instead of raw APDUErrors from many methods of PivController *** Write CLI prompts to standard error instead of standard output *** Replace function `ykman.util.parse_certificate` with `parse_certificates` which returns a list * Version 1.0.1 (released 2018-10-10) ** Support for YubiKey 5A ** OATH: Ignore extra parameters in URI parsing ** Bugfix: Never say that NFC is supported for YubiKeys without NFC * Version 1.0.0 (released 2018-09-24) ** Add support for YubiKey 5 Series ** Config: Add flag to generate a random configuration lock ** OATH: Give a proper error message when a touch credential times out ** NDEF: Allow setting the NDEF prefix from the CLI ** FIDO: Block reset when multiple YubiKeys are connected * Version 0.7.1 (released 2018-07-09) ** Support for YubiKey FIPS. ** OTP: Allow setting and removing access codes on the slots. ** Interfaces: set-lock-code now only accepts hexadecimal inputs. ** Bugfix: Don't fail to open the YubiKey when the serial is not visible. * Version 0.7.0 (released 2018-05-07) ** Support for YubiKey Preview. ** Add command to configure enabled applications over USB and NFC. See ykman config -h. ** Add command for selecting which slot to use for NDEF. See ykman otp ndef -h. * Version 0.6.1 (released 2018-04-16) ** Support for YubiKeys with FIDO2. See ykman fido -h ** Report the form factor for YubiKeys that support it. ** OTP: slot command is now called otp. See ykman otp -h for all changes. ** Static password: Add support for different keyboard layouts. See ykman otp static -h ** PIV: Signatures for CSRs are now correct. ** PIV: Commands on slots with PIN policy ALWAYS no longer fail if the YubiKey has a management key protected by PIN. ** Mode: The U2F mode is now called FIDO. ** Dependencies: libu2f-host is no longer used for FIDO communication over USB, instead the python library fido2 is used. * Version 0.6.0 (released 2018-02-09) ** OpenPGP: Expose remaining PIN retries in info command and API. ** CCID: Only try YubiKey smart card readers by default. ** Handle NEO issues with challenge-response credentials better. ** Improve logging. ** Improve error handling when opening device over OTP. ** Bugfix: Fix adding OTP data through the interactive prompt. * Version 0.5.0 (released 2017-12-15) ** API breaking changes: *** OATH: New API more similar to yubioath-android ** CLI breaking changes: *** OATH: Touch prompt now written to stderr instead of stdout *** OATH: `-a|--algorithm` option to `list` command removed *** OATH: Columns in `code` command are now dynamically spaced depending on contents *** OATH: `delete` command now requires confirmation or `-f|--force` argument *** OATH: IDs printed by `list` command now include TOTP period if not 30 *** Changed outputs: **** INFO: "Device name" output changed to "Device type" **** PIV: "Management key is stored on device" output changed to "Management key is stored on the YubiKey" **** PIV: "All PIV data have been cleared from the device" output changed to "All PIV data have been cleared from your YubiKey" **** PIV: "The current management key is stored on the device" prompt changed to "The current management key is stored on the YubiKey" **** SLOT: "blank to use device serial" prompt changed to "blank to use YubiKey serial number" **** SLOT: "Using device serial" output changed to "Using YubiKey device serial" **** Lots of failure case outputs changed ** New features: *** Support for multiple devices via new top-level option `-d|--device` *** New top-level option `-l|--log-level` to enable logging *** OATH: Support for remembering passwords locally. *** OATH: New option `-s|--single` for `code` command *** PIV: `set-pin-retries` command now warns that PIN and PUK will be reset to factory defaults, and prints those defaults after resetting ** API bug fixes: *** OATH: `valid_from` and `valid_to` for `Code` are now absolute instead of relative to the credential period *** OATH: `period` for non-TOTP `Code` is now `None` * Version 0.4.6 (released 2017-10-17) ** Will now attempt to open device 3 times before failing ** OpenPGP: Don't say data is removed when not ** OpenPGP: Don't swallow APDU errors ** PIV: Block on-chip RSA key generation for firmware versions 4.2.0 to 4.3.4 (inclusive) since these chips are vulnerable to http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15361[CVE-2017-15631]. * Version 0.4.5 (released 2017-09-14) ** OATH: Don't print issuer if there is no issuer. * Version 0.4.4 (released 2017-09-06) ** OATH: Fix yet another issue with backwards compatibility, for adding new credentials. * Version 0.4.3 (released 2017-09-06) ** OATH: Fix issue with backwards compatibility, when used as a library. * Version 0.4.2 (released 2017-09-05) ** OATH: Support 7 digit credentials. ** OATH: Support credentials with a period other than 30 seconds. ** OATH: The remove command is now called delete. * Version 0.4.1 (released 2017-08-10) ** PIV: Dropped support for deriving a management key from PIN. ** PIV: Added support for generating a random management key and storing it on the device protected by the PIN. ** OpenPGP: The reset command now handles a device in terminated state. ** OATH: Credential filtering is now working properly on Python 2. * Version 0.4.0 (released 2017-06-19) ** Added PIV support. The tool and library now supports most of the PIV functionality found on the YubiKey 4 and NEO. To list the available commands, run ykman piv -h. ** Mode command now supports adding and removing modes incrementally. * Version 0.3.3 (released 2017-05-08) ** Bugfix: Fix issue with OATH credentials from Steam on YubiKey 4. * Version 0.3.2 (released 2017-04-24) ** Allow access code input through an interactive prompt. ** Bugfix: Some versions of YubiKey NEO occasionally failed calculating challenge-response credentials with touch. * Version 0.3.1 (released 2017-03-13) ** Allow programming of TOTP credentials in YubiKey Slots using the chalresp command. ** Add a calculate command (and library support) to perform a challenge-response operation. Can also be used to generate TOTP codes for credentials stored in a slot. ** OATH: Remove whitespace in secret keys provided by the user. ** OATH: Prompt the user to touch the YubiKey for HOTP touch credentials. ** Bugfix: The flag for showing hidden credentials was not working correctly for the oath code command. * Version 0.3.0 (released 2017-01-23) ** OATH functionality added. The tool now exposes the OATH functionality found on the YubiKey 4 and NEO. To list the available commands, run ykman oath -h. ** Added support for randomly generated static passwords. * Version 0.2.0 (released 2016-11-23) ** Removed all GUI code. This project is now only for the python library and CLI tool. The GUI will be re-released separately in a different project. ** Added command to update settings for YubiKey Slots. * Version 0.1.0 (released 2016-07-07) ** Initial release for beta testing. yubikey_manager-5.6.1/README.adoc0000644000175000017500000002006714777516541016055 0ustar winniewinnie== YubiKey Manager CLI image:https://github.com/Yubico/yubikey-manager/actions/workflows/source-package.yml/badge.svg["Source package build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/source-package.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/windows.yml/badge.svg["Windows build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/windows.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/macOS.yml/badge.svg["MacOS build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/macOS.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/ubuntu.yml/badge.svg["Ubuntu build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/ubuntu.yml"] Python 3.8 (or later) library and command line tool for configuring a YubiKey. If you're looking for a graphical application, check out https://developers.yubico.com/yubioath-flutter/[Yubico Authenticator]. === Usage For more usage information and examples, see the https://docs.yubico.com/software/yubikey/tools/ykman/Using_the_ykman_CLI.html[YubiKey Manager CLI User Manual]. .... Usage: ykman [OPTIONS] COMMAND [ARGS]... Configure your YubiKey via the command line. Examples: List connected YubiKeys, only output serial number: $ ykman list --serials Show information about YubiKey with serial number 0123456: $ ykman --device 0123456 info Options: -d, --device SERIAL specify which YubiKey to interact with by serial number -r, --reader NAME specify a YubiKey by smart card reader name (can't be used with --device or list) -l, --log-level [ERROR|WARNING|INFO|DEBUG|TRAFFIC] enable logging at given verbosity level --log-file FILE write log to FILE instead of printing to stderr (requires --log-level) --diagnose show diagnostics information useful for troubleshooting -v, --version show version information about the app --full-help show --help output, including hidden commands -h, --help show this message and exit Commands: info show general information list list connected YubiKeys config enable or disable applications fido manage the FIDO applications oath manage the OATH application openpgp manage the OpenPGP application otp manage the YubiOTP application piv manage the PIV application .... The `--help` argument can also be used to get detailed information about specific subcommands: ykman oath --help === Versioning/Compatibility This project follows https://semver.org/[Semantic Versioning]. Any project depending on yubikey-manager should take care when specifying version ranges to not include any untested major version, as it is likely to have backwards incompatible changes. For example, you should NOT depend on ">=5", as it has no upper bound. Instead, depend on ">=5, <6", as any release before 6 will be compatible. Note that any private variables (names starting with '_') are not part of the public API, and may be changed between versions at any time. === Installation YubiKey Manager can be installed independently of platform by using pip (or equivalent): pip install --user yubikey-manager On Linux platforms you will need `pcscd` installed and running to be able to communicate with a YubiKey over the SmartCard interface. Additionally, you may need to set permissions for your user to access YubiKeys via the HID interfaces. More information available link:doc/Device_Permissions.adoc[here]. Some of the libraries used by yubikey-manager have C-extensions, and may require additional dependencies to build, such as http://www.swig.org/[swig] and potentially https://pcsclite.apdu.fr/[PCSC lite]. === Pre-built packages Pre-built packages specific to your platform may be available from Yubico or third parties. Please refer to your platforms native package manager for detailed instructions on how to install, if available. ==== Windows A Windows installer is available to download from the https://github.com/Yubico/yubikey-manager/releases/latest[Releases page]. ==== MacOS A MacOS installer is available to download from the https://github.com/Yubico/yubikey-manager/releases/latest[Releases page]. Additionally, packages are available from Homebrew and MacPorts. ===== Input Monitoring access on MacOS When running one of the `ykman otp` commands you may run into an error such as: `Failed to open device for communication: -536870174`. This indicates a problem with the permission to access the OTP (keyboard) USB interface. To access a YubiKey over this interface the application needs the `Input Monitoring` permission. If you are not automatically prompted to grant this permission, you may have to do so manually. Note that it is the _terminal_ you are using that needs the permission, not the ykman executable. To add your terminal application to the `Input Monitoring` permission list, go to `System Preferences -> Security & Privacy -> Privacy -> Input Monitoring` to resolve this. ===== Uninstallation of the MacOS .pkg To uninstall yubikey-manager when installed via the pgk installer, run: $ sudo rm -rf /usr/local/bin/ykman /usr/local/ykman ==== Linux Packages are available for several Linux distributions by third party package maintainers. Yubico also provides packages for Ubuntu in the yubico/stable PPA: $ sudo apt-add-repository ppa:yubico/stable $ sudo apt update $ sudo apt install yubikey-manager ==== FreeBSD Although not being officially supported on this platform, YubiKey Manager can be installed on FreeBSD. It's available via its ports tree or as pre-built package. Should you opt to install and use YubiKey Manager on this platform, please be aware that it's **NOT** maintained by Yubico. To install the binary package, use `pkg install pyXY-yubikey-manager`, with `pyXY` specifying the version of Python the package was built for, so in order to install YubiKey Manager for Python 3.8, use: # pkg install py38-yubikey-manager For more information about how to install packages or ports on FreeBSD, please refer to its official documentation: https://docs.freebsd.org/en/books/handbook/ports[FreeBSD Handbook]. In order to use `ykman otp` commands, you need to make sure the _uhid(4)_ driver attaches to the USB device: # usbconfig ugenX.Y add_quirk UQ_KBD_IGNORE # usbconfig ugenX.Y reset The correct device to operate on _(ugenX.Y)_ can be determined using `usbconfig list`. When using FreeBSD 13 or higher, you can switch to the more modern _hidraw(4)_ driver. This allows YubiKey Manager to access OTP HID in a non-exclusive way, so that the key will still function as a USB keyboard: # sysrc kld_list+="hidraw hkbd" # cat >>/boot/loader.conf<