From fbac45f94a51f90aa809c6f999b7cec065ed8e34 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Wed, 26 Jun 2024 10:26:21 +0800 Subject: [PATCH] initial commit --- pyproject.toml | 40 +++++ src/jwt_cli.egg-info/PKG-INFO | 14 ++ src/jwt_cli.egg-info/SOURCES.txt | 12 ++ src/jwt_cli.egg-info/dependency_links.txt | 1 + src/jwt_cli.egg-info/entry_points.txt | 2 + src/jwt_cli.egg-info/requires.txt | 2 + src/jwt_cli.egg-info/top_level.txt | 1 + src/jwt_cli/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 176 bytes .../create_key_command.cpython-312.pyc | Bin 0 -> 5774 bytes src/jwt_cli/__pycache__/key.cpython-312.pyc | Bin 0 -> 5479 bytes .../__pycache__/key_command.cpython-312.pyc | Bin 0 -> 2538 bytes src/jwt_cli/__pycache__/main.cpython-312.pyc | Bin 0 -> 998 bytes src/jwt_cli/__pycache__/token.cpython-312.pyc | Bin 0 -> 4980 bytes .../__pycache__/token_command.cpython-312.pyc | Bin 0 -> 1969 bytes src/jwt_cli/create_key_command.py | 148 ++++++++++++++++++ src/jwt_cli/key.py | 85 ++++++++++ src/jwt_cli/key_command.py | 62 ++++++++ src/jwt_cli/main.py | 15 ++ src/jwt_cli/sign_command.py | 48 ++++++ src/jwt_cli/signature.py | 0 src/jwt_cli/token.py | 82 ++++++++++ src/jwt_cli/token_command.py | 42 +++++ tests/__pycache__/test_token.cpython-312.pyc | Bin 0 -> 1632 bytes tests/auth_token.txt | 1 + tests/jwks.json | 1 + tests/test_token.py | 27 ++++ 27 files changed, 584 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/jwt_cli.egg-info/PKG-INFO create mode 100644 src/jwt_cli.egg-info/SOURCES.txt create mode 100644 src/jwt_cli.egg-info/dependency_links.txt create mode 100644 src/jwt_cli.egg-info/entry_points.txt create mode 100644 src/jwt_cli.egg-info/requires.txt create mode 100644 src/jwt_cli.egg-info/top_level.txt create mode 100644 src/jwt_cli/__init__.py create mode 100644 src/jwt_cli/__pycache__/__init__.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/create_key_command.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/key.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/key_command.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/main.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/token.cpython-312.pyc create mode 100644 src/jwt_cli/__pycache__/token_command.cpython-312.pyc create mode 100644 src/jwt_cli/create_key_command.py create mode 100644 src/jwt_cli/key.py create mode 100644 src/jwt_cli/key_command.py create mode 100644 src/jwt_cli/main.py create mode 100644 src/jwt_cli/sign_command.py create mode 100644 src/jwt_cli/signature.py create mode 100644 src/jwt_cli/token.py create mode 100644 src/jwt_cli/token_command.py create mode 100644 tests/__pycache__/test_token.cpython-312.pyc create mode 100644 tests/auth_token.txt create mode 100644 tests/jwks.json create mode 100644 tests/test_token.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb0333b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "jwt-cli" +version = "0.0.1" +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "JWT command line utilities" +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "jwcrypto >= 1.5.6", + 'typing_extensions==4.7.1', + 'pwo >= 0.0.2' +] + +[project.urls] +"Homepage" = "https://gitea.woggioni.net/woggioni/jwt-cli" +"Bug Tracker" = "https://gitea.woggioni.net/woggioni/jwt-cli/issues" + +[project.scripts] +jwt = "jwt_cli:main" + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true \ No newline at end of file diff --git a/src/jwt_cli.egg-info/PKG-INFO b/src/jwt_cli.egg-info/PKG-INFO new file mode 100644 index 0000000..65600fb --- /dev/null +++ b/src/jwt_cli.egg-info/PKG-INFO @@ -0,0 +1,14 @@ +Metadata-Version: 2.1 +Name: jwt-cli +Version: 0.0.1 +Summary: JWT command line utilities +Author-email: Walter Oggioni +Project-URL: Homepage, https://gitea.woggioni.net/woggioni/jwt-cli +Project-URL: Bug Tracker, https://gitea.woggioni.net/woggioni/jwt-cli/issues +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: jwcrypto>=1.5.6 +Requires-Dist: typing_extensions==4.7.1 diff --git a/src/jwt_cli.egg-info/SOURCES.txt b/src/jwt_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000..200826f --- /dev/null +++ b/src/jwt_cli.egg-info/SOURCES.txt @@ -0,0 +1,12 @@ +pyproject.toml +src/jwt_cli/__init__.py +src/jwt_cli/create_key_command.py +src/jwt_cli/key.py +src/jwt_cli/key_command.py +src/jwt_cli/main.py +src/jwt_cli.egg-info/PKG-INFO +src/jwt_cli.egg-info/SOURCES.txt +src/jwt_cli.egg-info/dependency_links.txt +src/jwt_cli.egg-info/entry_points.txt +src/jwt_cli.egg-info/requires.txt +src/jwt_cli.egg-info/top_level.txt \ No newline at end of file diff --git a/src/jwt_cli.egg-info/dependency_links.txt b/src/jwt_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/jwt_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/jwt_cli.egg-info/entry_points.txt b/src/jwt_cli.egg-info/entry_points.txt new file mode 100644 index 0000000..0bef276 --- /dev/null +++ b/src/jwt_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +jwt = jwt_cli:main diff --git a/src/jwt_cli.egg-info/requires.txt b/src/jwt_cli.egg-info/requires.txt new file mode 100644 index 0000000..4a86738 --- /dev/null +++ b/src/jwt_cli.egg-info/requires.txt @@ -0,0 +1,2 @@ +jwcrypto>=1.5.6 +typing_extensions==4.7.1 diff --git a/src/jwt_cli.egg-info/top_level.txt b/src/jwt_cli.egg-info/top_level.txt new file mode 100644 index 0000000..2f40e56 --- /dev/null +++ b/src/jwt_cli.egg-info/top_level.txt @@ -0,0 +1 @@ +jwt_cli diff --git a/src/jwt_cli/__init__.py b/src/jwt_cli/__init__.py new file mode 100644 index 0000000..b668da9 --- /dev/null +++ b/src/jwt_cli/__init__.py @@ -0,0 +1 @@ +from .main import main \ No newline at end of file diff --git a/src/jwt_cli/__pycache__/__init__.cpython-312.pyc b/src/jwt_cli/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b630bf0ff8bdae9cd09ee3e4a8fb866fae1801d GIT binary patch literal 176 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 BYVZI6 literal 0 HcmV?d00001 diff --git a/src/jwt_cli/__pycache__/key_command.cpython-312.pyc b/src/jwt_cli/__pycache__/key_command.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..585ce05c1ae0acdd164fc7dde9fe81e4749fda5c GIT binary patch literal 2538 zcmbtV&2JM|5P$1kuh(9G zH;QH2960m{QYCWefg@F-7WKsc&`Y9FWwi<_Qq==Dqj2KXnO%Q`mPnC0w#W1J=FOXV z``h1BsRRM~<=1NEb&Qa|Q0Y*ATiAO7!XrWn0C6y$O#NYYQ0UFbJJ$?joQmHG&L6QPnqAST=Z?Y?;^aM>)vC>B~_oF45 z_URl=>s&>k{r81Zaz#`JUt!rQ=cm75^L0zFx;M(q(HU5z*_vzE)w21D3quAprk7oP zZb@I6(`?HsSErhaRsrqB2Zqgt!Ga}_Z7vR!w(O55Q= zRM!zIMs==McARCKQ9lVwdYV|P-!To%7n$zXSyh8MbO~h{etQ>zJt8g^9#E4sBTZqQ z54C%e+m;AvN=>fGH=|8)JsNsBvH}g6Q6mh~F$tI&XE=C=rcBC^}3-c@E`$z5S%N;>-JV zHVi4T8s`MCRq|$t$b%0)TWdVIu$`D#o!k{tKWGmwJeHq){p|c7!sR`PB7VZD->C&7 zb+m3fz=8E)6ZVt{wyH@M_j|#CL^&GS;MXAn=^oeQ7Q2lLZOEyx5nXSuz87gm-kV9J zv3;5NCcoHjcP|fKaT=!yh->e{kptpnnmklo3a)>~6+3&lcC7mbw^V_R!J&O20G^n(U>ln5 zAgtZ38%(FL#Vl47;#Qb%tdlP^VkO;#5O4IhN6c&FuLbp@tb9!MO1W;jj^f&i<5Hul z!U}ue4#5&-Hh@Xxy=!1swILM>Ardx*XRGFl(k>;mVMXxhyEfD3Tvpcs%z{ITWeqVM zM^~v=HC6HxWl8~D5%c{t%00aBlc)ov1@8rh&yFvchU5B4M|bBs2MKVA;gbWc9K|4B zv#6hj680rydGI&_Fa$-G#kdUS$FTP8VQ0XK9_K2+$tv-3AMNCdTe;%X=(Fj~TyZ-$ zwL0l#M|ZNPx3Z_7PH$#UZ)Y#B&UpDVJNb#N{KT{P=9jh2{KR(t?&?)fkavX9En#$R zXzhFN#HAKbqDS5kF8Vq5vgg`7UOd3(Nl8x{_EPF@YIL`6=*QdJeII!J*;Xn@6N`$i zJ`zoaTLiHqUTXZXv5>dWiUpYj$dvd`rs4tK0WWpZ%Z$C(`_s~9 z<;m&QDK-KPRbw*&T~SlM1QQJhjwtJkaPN)kJj2lhQ)D@my6?Vl?!4nNU$|r2W;kw~ z)XyZy9nA8rbkVYD-PA9!bKt_af%6-%7SD0q3-aL$GWIt)_a`~KMb5S)a#Ho+d*)NG za2ADA8*BP0c^9;aLI!p}Ut^alTeGyMY^ Ce{u%^ literal 0 HcmV?d00001 diff --git a/src/jwt_cli/__pycache__/main.cpython-312.pyc b/src/jwt_cli/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e2296722b70aeeb85fe9c0ef27be8163d0767dd GIT binary patch literal 998 zcmZuv&ubGw6rS0g&98MEwp6RO!Zy_4iZ%2uLa~UgH40XRsDy218#mn_VRq9tC506P zJ&B;udh5Z1(*747y);B<7~7MA^rqBPPtL4KQ^g1S=6m0pZ)WGc_eoJwfZ0d1Jf9H& z_{xK>NgZ&IXJ8FrfT0Zvd=+fy2nE3r3nBzq#K=aDRFEPp*)byV}mjEf6qy;$-b$8a&DR89rn zg6VciiVRZrOI*^mI0GX44k&YLAUMiZ*z^8147Y^M&W=^sfR*l(k8R}7+mbdr>Qy1& zH$>LxX~iJ<9~Q9_ct~g;ZwC`cS>lrzlMr4Unu@3*?+r!sHMd03 zBGc#+N$_;+kAkLpVk#eNHC6sa0D`p3eXy94JpDy@O>@K#$^cjV!wJiI>Jk~86C8!6%B^Qos(O*Hfd zy_$WUYN4S6b_*Nfyq+R`4AEFnuUc-I@UbL_yHngn^%~?TDw^mAB>GdLaPBO3rHv(2mI91Hoh`dQ|Fh!DarApIST?12k=;L^`n91g!&UPtS9-zJ;s%rC&==$zlx Cneq7m literal 0 HcmV?d00001 diff --git a/src/jwt_cli/__pycache__/token.cpython-312.pyc b/src/jwt_cli/__pycache__/token.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cec36c645a9a0c2d5e5a7ae4d71b0364cc0c4e6 GIT binary patch literal 4980 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 literal 0 HcmV?d00001 diff --git a/src/jwt_cli/__pycache__/token_command.cpython-312.pyc b/src/jwt_cli/__pycache__/token_command.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d07cea5b2f227035384f9d56402e35b48059f519 GIT binary patch literal 1969 zcma)6&1)M+6ra_8Xtk@AY&W(XB^H~sCEie3O(`^iQYa}cO&X_QlY&ZW)SZznTdNhb zs~8ET_>fC(&M7(ckW*=}4>{&fDD+}u7cWCh3Z;kKRP0kueKRZBj+Nse$9%l^ ze)Feg83=|~ZC4jmg#O`^!7%$^?>7LC5k^?5BTp(z632;p!jsD~U>PfQ#Z$_Pz-nFf zw6f+U%Slf!>q4j1Q=U;aJhN)NG$Uuoo{)h5q@8x0imGLZ#To5->B4IOR>v{ zOGzapb4#Q>=lY&kX<$cYIw&#MvD7lH#b##SB<-(jbu!y-l4l8K2%pejA`NE7USa~f z$TxnNon}U@(QJitRS03G-wF>VGGAB$5ipICutgiL!cW{EPy)o$zIPSGV-)g}iqS>g zL|0yy*5r78Uk&>Rp|0AM))KK^LT%(Erkw)`aqmI;rkZVLy``bN zqJ010Jct#vpF!s6YWeBR@8P!b>B{w&N_JIV{qZm5!XDTWGlJI7O;NGHJ-9p=cw+d4 z_hv!+763&jO-urp)&!%NDk|171=Z{d59}n0 zwNAQN6g(($9&`%da!%MB+mo17E3JAM*r9I+A+9wXi10gBu^6)@4zX_w>YN+{j!s7{ z7J*Ei$vcGBs%_h+HYP4@H$%G>)Ee`4xIpYC_3za%!Emmd9ZduT9p_ymW_Sv!t%mDp z%&1^I7q=(NaD3RUEY0V@CGqI6bU?zn!6gL=C})?W&~xx%vg=_s6f@<>8cEl0+#@s$ z_?y6qNXNPNFkkv@T3rVHDExv=5GyFkf3TA;ZRShQlP|oDd}%BH-OBYSm*2@bn>pvX zw~=$Ua$jvIXQIN?PT|^S;o6H!e^?uZYg>goE8j$lxucA4D&vu!j`Xp}D)y2{J-d$- zRqGi@O&_u5BCB+YH5*x<^;8ikL!f>na452_o@n~j+fc^0m5*L3V;i|k+sb5QW>#*{ zJlx7rreQe4Ooyz-N#{IcTG(zvX(`Waef|;W`yVxd>|+1` literal 0 HcmV?d00001 diff --git a/src/jwt_cli/create_key_command.py b/src/jwt_cli/create_key_command.py new file mode 100644 index 0000000..d79f055 --- /dev/null +++ b/src/jwt_cli/create_key_command.py @@ -0,0 +1,148 @@ +from argparse import ArgumentParser, Action +from enum import Enum +from jwcrypto.jwk import JWK +from typing import Optional, Any + +from .key import KeyFileType, write_key + + +class ECCurve(Enum): + p256 = 'P-256' + p384 = 'P-384' + p521 = 'P-521' + bp256 = 'BP-256' + bp384 = 'BP-384' + bp521 = 'BP-521' + secp256k1 = 'secp256k1' + + def __str__(self) -> str: + return self.value + + +class OKPCurve(Enum): + Ed25519 = 'Ed25519' + Ed448 = 'Ed448' + X25519 = 'X25519' + X448 = 'X448' + + def __str__(self) -> str: + return self.value + + +def _create_ec_key( + output_type: Optional[KeyFileType] = None, + curve: Optional[ECCurve] = None, + output_file: Optional[str] = None, + out_password: Optional[str] = None, + **kwargs: Any +) -> None: + jwk = JWK.generate(kty='EC', crv=curve.value) + write_key(jwk, output_file, output_type, out_password=out_password) + + +def _create_okp_key( + output_type: Optional[KeyFileType] = None, + curve: Optional[OKPCurve] = None, + output_file: Optional[str] = None, + out_password: Optional[str] = None, + **kwargs: Any +) -> None: + jwk = JWK.generate(kty='OKP', crv=curve.value) + write_key(jwk, output_file, output_type, out_password=out_password) + +def _create_oct_key( + output_type: Optional[KeyFileType] = None, + size: Optional[int] = None, + output_file: Optional[str] = None, + out_password: Optional[str] = None, + **kwargs: Any +) -> None: + jwk = JWK.generate(kty='oct', size=size) + write_key(jwk, output_file, output_type, out_password=out_password) + +def _create_rsa_key( + output_type: Optional[KeyFileType] = None, + exponent: Optional[int] = None, + size: Optional[int] = None, + output_file: Optional[str] = None, + out_password: Optional[str] = None, + **kwargs: Any +) -> None: + jwk = JWK.generate(kty='RSA', public_exponent=exponent, size=size) + write_key(jwk, output_file, output_type, out_password=out_password) + + +def _add_common_params(cmd: ArgumentParser) -> None: + cmd.add_argument('-P', '--out-password', help='Password for the input key') + cmd.add_argument( + '-O', + '--output-type', + help='Type of output key material', + type=KeyFileType, + choices=list(KeyFileType), + required=True + ) + cmd.add_argument( + '-o', + '--output-file', + help='Output file (defaults to stdout)' + ) + + +def create_key_command(subparsers: Action) -> ArgumentParser: + cmd: ArgumentParser = subparsers.add_parser("create", help="Create a new JWK key") + cmd_subparsers = cmd.add_subparsers() + + rsa_cmd = cmd_subparsers.add_parser("rsa", help="Create a new JWK RSA key") + _add_common_params(rsa_cmd) + rsa_cmd.add_argument( + '-e', + '--exponent', + type=int, + help='Public exponent', + default=65537 + ) + rsa_cmd.add_argument( + '-s', + '--size', + type=int, + help='Size', + default=1024 + ) + rsa_cmd.set_defaults(func=_create_rsa_key) + + ec_cmd = cmd_subparsers.add_parser("ec", help="Create a new JWK EC key") + _add_common_params(ec_cmd) + ec_cmd.add_argument( + '-c', + '--curve', + help='Elliptic curve name', + type=ECCurve, + choices=list(ECCurve), + default=ECCurve.p256 + ) + ec_cmd.set_defaults(func=_create_ec_key) + + okp_cmd = cmd_subparsers.add_parser("okp", help="Create a new JWK OKP key") + _add_common_params(okp_cmd) + okp_cmd.add_argument( + '-c', + '--curve', + help='Elliptic curve name', + type=OKPCurve, + choices=list(OKPCurve), + default=OKPCurve.Ed25519 + ) + okp_cmd.set_defaults(func=_create_okp_key) + + oct_cmd = cmd_subparsers.add_parser("oct", help="Create a new JWK oct key") + _add_common_params(oct_cmd) + oct_cmd.add_argument( + '-s', + '--size', + help='Key size', + type=int, + default=256 + ) + oct_cmd.set_defaults(func=_create_oct_key) + return cmd diff --git a/src/jwt_cli/key.py b/src/jwt_cli/key.py new file mode 100644 index 0000000..a135341 --- /dev/null +++ b/src/jwt_cli/key.py @@ -0,0 +1,85 @@ +import enum +import sys +from typing import Optional, BinaryIO, cast +from jwcrypto.jwk import JWK +from jwcrypto.common import json_decode +from cryptography.hazmat.primitives import serialization +from pwo import Maybe + + +@enum.unique +class KeyFileType(enum.Enum): + PEM = 'pem' + JSON = 'json' + DER = 'der' + + def __str__(self) -> str: + return self.value + + +def read_key( + input_file: Optional[str] = None, + input_type: Optional[KeyFileType] = None, + in_password: Optional[str] = None, +) -> JWK: + jwk = JWK() + + password = ( + Maybe.of_nullable(in_password) + .map(lambda it: it.encode('utf-8')) + .or_else(None) + ) + + def open_input_file() -> BinaryIO: + return open(input_file, 'rb') if input_file else sys.stdin.buffer + + if input_type == KeyFileType.PEM: + with open_input_file() as infile: + jwk.import_from_pem(infile.read().decode('utf-8'), password) + elif input_type == KeyFileType.JSON: + with open_input_file() as infile: + jwk.import_key(**json_decode(infile.read().decode('utf-8'))) + elif input_type == KeyFileType.DER: + with open_input_file() as infile: + der_obj = serialization.load_der_private_key(infile.read(), password) + jwk.import_from_pyca(der_obj) + return jwk + + +def write_key( + jwk: JWK, + output_file: Optional[str] = None, + output_type: Optional[KeyFileType] = None, + out_password: Optional[str] = None, + public: bool = False +) -> None: + + password = ( + Maybe.of_nullable(out_password) + .map(lambda it: it.encode('utf-8')) + .or_none() + ) + def open_output_file() -> BinaryIO: + return cast(BinaryIO, open(output_file, 'wb')) if output_file else sys.stdout.buffer + + private_key = not public + if output_type == KeyFileType.PEM: + with open_output_file() as outfile: + outfile.write(jwk.export_to_pem(private_key=private_key, password=password)) + elif output_type == KeyFileType.JSON: + with open_output_file() as outfile: + outfile.write(jwk.export(private_key=private_key).encode('utf-8')) + elif output_type == KeyFileType.DER: + with open_output_file() as outfile: + pem_obj = serialization.load_pem_private_key( + jwk.export_to_pem(private_key=private_key, password=None), + password=None + ) + der_data = pem_obj.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption() + ) + outfile.write(der_data) diff --git a/src/jwt_cli/key_command.py b/src/jwt_cli/key_command.py new file mode 100644 index 0000000..5eb156b --- /dev/null +++ b/src/jwt_cli/key_command.py @@ -0,0 +1,62 @@ +from argparse import ArgumentParser +from typing import Optional + +from .create_key_command import create_key_command +from .key import read_key, write_key, KeyFileType + + +def key_command( + input_file: Optional[str] = None, + output_file: Optional[str] = None, + input_type: Optional[KeyFileType] = None, + output_type: Optional[KeyFileType] = None, + in_password: Optional[str] = None, + out_password: Optional[str] = None, + public: bool = False, + **kwargs +) -> None: + jwk = read_key(input_file, input_type, in_password) + write_key(jwk, output_file, output_type, out_password, public) + +def add_key_command(subparsers: ArgumentParser) -> ArgumentParser: + cmd: ArgumentParser = subparsers.add_parser("key", help="Manage JWKs") + key_subparsers = cmd.add_subparsers(description="subcommands") + convert: ArgumentParser = key_subparsers.add_parser("convert", help="Convert JWKs in/from different formats") + convert.add_argument('-p', '--in-password', + help='Password for the input key') + convert.add_argument('-P', '--out-password', + help='Password for the input key') + convert.add_argument( + '-I', + '--input-type', + help='Type of input key material', + type=KeyFileType, + choices=list(KeyFileType), + required=True + ) + convert.add_argument( + '-O', + '--output-type', + help='Type of output key material', + type=KeyFileType, + choices=list(KeyFileType), + required=True + ) + convert.add_argument( + '-i', + '--input-file', + help='Input file (defaults to stdin)' + ) + convert.add_argument( + '-o', + '--output-file', + help='Output file (defaults to stdout)' + ) + convert.add_argument( + '--public', + help='Only output public key', + action='store_true' + ) + convert.set_defaults(func=key_command) + create_key_command(key_subparsers) + return cmd diff --git a/src/jwt_cli/main.py b/src/jwt_cli/main.py new file mode 100644 index 0000000..0010d80 --- /dev/null +++ b/src/jwt_cli/main.py @@ -0,0 +1,15 @@ +import argparse + +from typing import List, Optional +from .key_command import add_key_command +from .token_command import add_token_command + + +def main(args: Optional[List[str]] = None): + # Create the parser + parser = argparse.ArgumentParser(description="A simple CLI program to manage JWT") + subparsers = parser.add_subparsers(title="subcommands", description="valid subcommands") + key_command = add_key_command(subparsers) + token_command = add_token_command(subparsers) + args = parser.parse_args(args) + args.func(**vars(args)) diff --git a/src/jwt_cli/sign_command.py b/src/jwt_cli/sign_command.py new file mode 100644 index 0000000..52102cb --- /dev/null +++ b/src/jwt_cli/sign_command.py @@ -0,0 +1,48 @@ +from argparse import ArgumentParser +from typing import Optional + +from .key import read_key, KeyFileType +def signature_command( + input_file: Optional[str] = None, + input_type: Optional[KeyFileType] = None, + in_password: Optional[str] = None, + output_file: Optional[str] = None, + key: Optional[str] = None, + keys: Optional[str] = None, + **kwargs +) -> None: + jwk = read_key(input_file, input_type, in_password) + jwt = read_token(input_file, jwk=key, jwks=keys) + write_token(jwt, output_file) + + +def add_signature_command(subparsers: ArgumentParser) -> ArgumentParser: + cmd: ArgumentParser = subparsers.add_parser("signature", help="Manage JWSs") + signature_subparsers = cmd.add_subparsers(description="subcommands") + convert: ArgumentParser = signature_subparsers.add_parser("sign", help="Sign a payload") + convert.add_argument( + '-i', + '--input-file', + help='Input file (defaults to stdin)' + ) + convert.add_argument('-p', '--key-password', + help='Password for the signing key') + convert.add_argument( + '-I', + '--input-type', + help='Type of input key material', + type=KeyFileType, + choices=list(KeyFileType), + required=True + ) + convert.add_argument( + '-o', + '--output-file', + help='Output file (defaults to stdout)' + ) + convert.add_argument( + '--key', + help='Sign using the provided JWK', + ) + convert.set_defaults(func=signature_command) + return cmd \ No newline at end of file diff --git a/src/jwt_cli/signature.py b/src/jwt_cli/signature.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jwt_cli/token.py b/src/jwt_cli/token.py new file mode 100644 index 0000000..269dfa5 --- /dev/null +++ b/src/jwt_cli/token.py @@ -0,0 +1,82 @@ +from jwcrypto.jwt import JWT +from jwcrypto.jws import JWS +from jwcrypto.jwk import JWKSet, JWK +import json + +from pwo import Maybe +from typing import Optional, BinaryIO, TextIO, cast +import sys + +from pwo import retry, ExceptionHandlerOutcome +from urllib.request import Request, urlretrieve, urlopen + + +class HttpException(Exception): + http_status_code: int + message: Optional[str] + + def __init__(self, http_status_code: int, msg: Optional[str] = None): + self.message = msg + self.http_status_code = http_status_code + + def __repr__(self) -> str: + return f'HTTP status {self.http_status_code}' + f': {self.message}' if self.message else '' + + +def error_handler(err): + return ExceptionHandlerOutcome.THROW + + +@retry(max_attempts=5, initial_delay=1.0, exception_handler=error_handler) +def fetch_keyset(url): + with urlopen(url) as response: + return JWKSet.from_json(response.read()) + + +@retry(max_attempts=5, initial_delay=1.0, exception_handler=error_handler) +def fetch_key(url): + with urlopen(url) as response: + return JWK.from_json(response.read()) + + +def read_token( + input_file: Optional[str] = None, + jwks: Optional[str] = None, + jwk: Optional[str] = None +) -> JWT: + jwt = JWT() + if jwks and jwk: + raise ValueError('Only one between key and keyset must be provided') + + keys = (Maybe.of_nullable(jwks) + .map(lambda uri: fetch_keyset(uri))) + key = (Maybe.of_nullable(jwk) + .map(lambda uri: fetch_key(uri))) + + def open_input_file() -> TextIO: + return open(input_file, 'r') if input_file else sys.stdin + + with open_input_file() as infile: + content = infile.read() + jwt.deserialize(content, key=(keys or key).or_none()) + return jwt + + +def write_token( + jwt: JWT, + output_file: Optional[str] = None, +) -> None: + def open_output_file() -> TextIO: + return cast(TextIO, open(output_file, 'wb')) if output_file else sys.stdout.buffer + + with open_output_file() as outfile: + # jws = cast(JWS, jwt.token) + token = jwt.token + if isinstance(token, JWS): + jws = cast(JWS, token) + if jws.is_valid: + outfile.write(jws.payload) + else: + outfile.write(jws.objects['payload']) + # header = jws.jose_header + # issuer = jws.objects['payload'] diff --git a/src/jwt_cli/token_command.py b/src/jwt_cli/token_command.py new file mode 100644 index 0000000..a510809 --- /dev/null +++ b/src/jwt_cli/token_command.py @@ -0,0 +1,42 @@ +from argparse import ArgumentParser +from typing import Optional + +from .create_key_command import create_key_command +from .token import read_token, write_token + + +def token_command( + input_file: Optional[str] = None, + output_file: Optional[str] = None, + key: Optional[str] = None, + keys: Optional[str] = None, + **kwargs +) -> None: + jwt = read_token(input_file, jwk=key, jwks=keys) + write_token(jwt, output_file) + + +def add_token_command(subparsers: ArgumentParser) -> ArgumentParser: + cmd: ArgumentParser = subparsers.add_parser("token", help="Manage JWTs") + token_subparsers = cmd.add_subparsers(description="subcommands") + convert: ArgumentParser = token_subparsers.add_parser("parse", help="Parse a JWT") + convert.add_argument( + '-i', + '--input-file', + help='Input file (defaults to stdin)' + ) + convert.add_argument( + '-o', + '--output-file', + help='Output file (defaults to stdout)' + ) + convert.add_argument( + '--key', + help='Verify or decrypt using the provided JWK', + ) + convert.add_argument( + '--keys', + help='Verify or decrypt using the provided JWKS', + ) + convert.set_defaults(func=token_command) + return cmd diff --git a/tests/__pycache__/test_token.cpython-312.pyc b/tests/__pycache__/test_token.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b4cedb1e51a338a0a2c27a913512bfe04673b82 GIT binary patch literal 1632 zcma)6-D@O85byci*^k+57Cq17jL8L~b7yvqUx9G=km$)N29bc=ybR;cB$>_3&f49} zW|stf2ogasen3_(U>;B6{~+G0F9|^|bUg0B^Wokii3IXwb??rt#20IKtAAZpU0u~x zTfdrS1_6b%H9x8#^atN0r1ik@Hvl$~hde1nc6XI*3HEX*M~baPs;x>~u7p~o+q%GN zn2HSB5LgS-QO3?hSvxECdYFq$+Z0#|^LAeN9w^e*yJYsfQvR2@zN>90EwBPV;X zC0Xgm9L`!YQ{MY{j%9$ovs@3b?^zO4BR8nQs^CTqEHW->)hZ7431vEs>Ry1k(T6Tw ztZ*+0$vtp<8Nep;5zo_>eZ`a3&`wWjt1a15Sb8pA@@sS8vXUI*23h#=Fpoc%xQi9Z zliDC#l~+&|B__jiL!eq^2yPTS$V>mvmXVg__cNc?)&rR&KVPjT^MyprtB_YfPiObWE$enD}jZ zJZR_L23>T7xI~vJ$l|s>KEC9)NIO3tg#J{uxkS3BCHz>0V4;EZpyv5CYGqi?CB(<{ zBR3#E!CZ}b37JADW@=oA+f;G28pf{2RId@$LD?cqC)9)3ES;&u59gUd{iyChgEAfa zq!CgkIs76X1XHR_N_22MEa93%g18oxE3xO7!DhS?24&vuMDSi>^%g5| zp(FC{3fq^Sv~lUYj+c)74D2)XaPZmA2YZ9oy8h4{yk`#WnL}Icee>!OQqtu+?(ez6 z#*3ZF?YDQYP8}34Y$=@^x3dSuOS_j}KGNi3=>*AU`BVYh69c8q^@WY*wz@m^=0U#r z-AqUS_WdJ8%8#5NDShRUIk+*_8QnLBxbKPWu}8%Vo7UG>=f-|Vmdp`CBifE4CHY8x#?@lFJsrICzZR`wCUGVcPF(O zl$y0PW;jmGjeN&p8OMoYuMu*bbDU2bZrIge16<${U_YW`;0ONGAv?J%DnqJ>z2;0hNxP|gyc zXx*k75YsG(OEBDOJ|>uV1XJqG81oq+Vr8j147V`&1oOW$zR20r+{~DRyJuYJRuTLQ zTp{8c26Sdz@8