├── .dockerignore ├── .github └── workflows │ └── isort.yaml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 684292138a0a_many_devices_to_many_users.py │ ├── 8fbc59f03986_convert_device_to_wvd.py │ ├── c8ce82d0c054_rename_cdm_table.py │ ├── e1238372d8d1_init.py │ ├── f194cc3e699f_create_system_user.py │ └── f3df682d6393_add_id_to_keys.py ├── config.example.toml ├── docker-compose-dev.yaml ├── docker-compose.yaml ├── entrypoint.sh ├── getwvkeys ├── __init__.py ├── config.py ├── download │ └── getwvkeys.py ├── formats │ ├── widevine_pssh_data.proto │ ├── widevine_pssh_data_pb2.py │ ├── wv_proto2.proto │ ├── wv_proto2_pb2.py │ ├── wv_proto3.proto │ └── wv_proto3_pb2.py ├── libraries.py ├── main.py ├── models │ ├── APIKey.py │ ├── Base.py │ ├── Device.py │ ├── Key.py │ ├── Shared.py │ ├── User.py │ └── UserDevice.py ├── pssh_utils.py ├── redis.py ├── scripts.py ├── static │ ├── favicon.ico │ ├── main.css │ ├── main.js │ └── search.js ├── templates │ ├── admin_user.html │ ├── api.html │ ├── base.html │ ├── cache.html │ ├── error.html │ ├── faq.html │ ├── index.html │ ├── login.html │ ├── partials │ │ ├── footer.html │ │ └── nav.html │ ├── profile.html │ ├── scripts.html │ ├── search.html │ ├── success.html │ ├── upload.html │ └── upload_complete.html ├── user.py └── utils.py ├── poetry.lock └── pyproject.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv -------------------------------------------------------------------------------- /.github/workflows/isort.yaml: -------------------------------------------------------------------------------- 1 | name: Run isort 2 | on: 3 | - push 4 | 5 | permissions: write-all 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.9" 15 | - uses: isort/isort-action@master 16 | with: 17 | configuration: "--profile black" 18 | - name: Commit changes 19 | run: | 20 | git config --local user.email "action@github.com" 21 | git config --local user.name "GitHub Action" 22 | git add -A && git diff-index --cached --quiet HEAD || git commit -m 'isort' 23 | - name: Push changes 24 | uses: ad-m/github-push-action@v0.6.0 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | branch: ${{ github.ref }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | logs/ 3 | *.aria2 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env* 110 | !.env.sample 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # JetBrains project settings 126 | .idea 127 | 128 | # Visual Studio Code project settings 129 | .vscode 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | *.toml 143 | !config.example.toml 144 | devices/ 145 | util_scripts/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.4-slim-bookworm AS base 2 | 3 | ARG DEVELOPMENT 4 | 5 | ENV DEVELOPMENT=${DEVELOPMENT} \ 6 | PYTHONFAULTHANDLER=1 \ 7 | PYTHONUNBUFFERED=1 \ 8 | PYTHONHASHSEED=random \ 9 | PIP_NO_CACHE_DIR=off \ 10 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 11 | PIP_DEFAULT_TIMEOUT=100 \ 12 | POETRY_NO_INTERACTION=1 \ 13 | POETRY_VIRTUALENVS_CREATE=false \ 14 | POETRY_CACHE_DIR='/var/cache/pypoetry' \ 15 | POETRY_HOME='/usr/local' \ 16 | POETRY_VERSION=1.8.3 17 | 18 | RUN apt-get update && apt-get install -y --no-install-recommends curl build-essential libmariadb-dev-compat libmariadb-dev git 19 | RUN curl -sSL https://install.python-poetry.org | python3 - 20 | 21 | FROM base AS getwvkeys 22 | WORKDIR /app 23 | 24 | # Creating folders, and files for a project: 25 | COPY . /app 26 | 27 | # Project initialization: 28 | RUN poetry install --no-interaction --no-ansi -E mariadb 29 | 30 | ENTRYPOINT ["./entrypoint.sh"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GetWVKeys 2 | 3 | Widevine Utility Website and Remote Widevine Device API. 4 | 5 | # Setup 6 | 7 | - Install Python Poetry: https://python-poetry.org/docs/master/#installation 8 | - Install Dependencies: 9 | - For MySQL: `poetry install -E mysql` 10 | - For MariaDB: `poetry install -E mariadb` 11 | - Copy `.env.example` to `.env`.(#environment-variables) 12 | - Copy `getwvkeys/config.toml.example` to `getwvkeys/config.toml` 13 | - Edit `config.toml` 14 | - For a MySQL Database, use the prefix `mysql+mariadbconnector` 15 | - For a MariaDB Database, use the prefix `mariadb+mariadbconnector` 16 | - Run database migrations. see [Database Migrations](#database-migrations) 17 | - See [Deploy](#deploy) 18 | 19 | # Local Development 20 | 21 | For local development testing, you will need to disable the HTTPS requirement on the OAuth Callback URLs 22 | with the environment variable `OAUTHLIB_INSECURE_TRANSPORT=1` or you will get the error `InsecureTransportError`. 23 | 24 | - For Unix: `export OAUTHLIB_INSECURE_TRANSPORT=1` 25 | - Windows (CMD): `set OAUTHLIB_INSECURE_TRANSPORT=1` 26 | - Windows (Powershell): `$env:OAUTHLIB_INSECURE_TRANSPORT=1` 27 | 28 | You should also enable development mode with the `DEVELOPMENT` environment variable. 29 | 30 | - For Unix: `export DEVELOPMENT=1` 31 | - Windows (CMD): `set DEVELOPMENT=1` 32 | - Windows (Powershell): `$env:DEVELOPMENT=1` 33 | 34 | For local development, you can use the built-in flask server with `poetry run serve`. 35 | 36 | # Database Migrations 37 | 38 | `poetry run migrate` 39 | 40 | # Environment Variables 41 | 42 | - `OAUTHLIB_INSECURE_TRANSPORT`: disable ssl for oauth 43 | - `DEVELOPMENT`: Development mode, increased logging and reads environment variables from `.env.dev` 44 | - `STAGING`: Staging mode, reads environment variables from `.env.staging` 45 | 46 | # Deploy 47 | 48 | Gunicorn is the recommended to run the server in production. 49 | 50 | Example command to run on port 8081 listening on all interfaces: 51 | 52 | - `poetry run gunicorn -w 1 -b 0.0.0.0:8081 getwvkeys.main:app` 53 | 54 | or waitress: 55 | - `poetry run waitress-serve --listen=*:8081 getwvkeys.main:app` 56 | 57 | _never use more than 1 worker, getwvkeys does not currently support that and you will encounter issues with sessions._ 58 | 59 | # Other Info 60 | 61 | - GetWVKeys uses dynamic injection for scripts, this means that when a user downloads a script and is logged in, the server injects certain values by replacing strings such as their API key. Available placeholders are: 62 | - `__getwvkeys_api_key__`: Authenticated users api key 63 | - `__getwvkeys_api_url__`: The instances API URL, this is used for staging and production mainly but can also be used for self hosted instances 64 | 65 | 66 | # Docker 67 | - for development: 68 | - create `config.dev.toml` 69 | - `docker compose -f docker-compose-dev.yml up` 70 | - for non-development: 71 | - create a regular `config.toml` 72 | - `docker compose -f docker-compose.yml up` 73 | 74 | migrations are run automatically on boot -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | # this is overwritten in getwvkeys!!! 64 | sqlalchemy.url = driver://user:pass@localhost/dbname 65 | 66 | 67 | [post_write_hooks] 68 | # post_write_hooks defines scripts or Python functions that are run 69 | # on newly generated revision scripts. See the documentation for further 70 | # detail and examples 71 | 72 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 73 | hooks = black 74 | black.type = console_scripts 75 | black.entrypoint = black 76 | black.options = -l 79 77 | 78 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 79 | # hooks = ruff 80 | # ruff.type = exec 81 | # ruff.executable = %(here)s/.venv/bin/ruff 82 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 83 | 84 | # Logging configuration 85 | [loggers] 86 | keys = root,sqlalchemy,alembic 87 | 88 | [handlers] 89 | keys = console 90 | 91 | [formatters] 92 | keys = generic 93 | 94 | [logger_root] 95 | level = WARN 96 | handlers = console 97 | qualname = 98 | 99 | [logger_sqlalchemy] 100 | level = WARN 101 | handlers = 102 | qualname = sqlalchemy.engine 103 | 104 | [logger_alembic] 105 | level = INFO 106 | handlers = 107 | qualname = alembic 108 | 109 | [handler_console] 110 | class = StreamHandler 111 | args = (sys.stderr,) 112 | level = NOTSET 113 | formatter = generic 114 | 115 | [formatter_generic] 116 | format = %(levelname)-5.5s [%(name)s] %(message)s 117 | datefmt = %H:%M:%S 118 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging.config import fileConfig 3 | 4 | import toml 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | from alembic import context 8 | from getwvkeys.models import APIKey, Base, Device, Key, User 9 | 10 | IS_DEVELOPMENT = bool(os.environ.get("DEVELOPMENT", False)) 11 | IS_STAGING = bool(os.environ.get("STAGING", False)) 12 | CONFIG = toml.load("config.dev.toml" if IS_DEVELOPMENT else "config.staging.toml" if IS_STAGING else "config.toml") 13 | 14 | # this is the Alembic Config object, which provides 15 | # access to the values within the .ini file in use. 16 | config = context.config 17 | 18 | # Interpret the config file for Python logging. 19 | # This line sets up loggers basically. 20 | if config.config_file_name is not None: 21 | fileConfig(config.config_file_name) 22 | 23 | # add your model's MetaData object here 24 | # for 'autogenerate' support 25 | # from myapp import mymodel 26 | # target_metadata = mymodel.Base.metadata 27 | target_metadata = Base.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | # url = config.get_main_option("sqlalchemy.url") 48 | url = CONFIG["general"]["database_uri"] 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | ) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | def run_migrations_online() -> None: 61 | """Run migrations in 'online' mode. 62 | 63 | In this scenario we need to create an Engine 64 | and associate a connection with the context. 65 | 66 | """ 67 | url = CONFIG["general"]["database_uri"] 68 | cfg = config.get_section(config.config_ini_section, {}) 69 | cfg["sqlalchemy.url"] = url 70 | connectable = engine_from_config( 71 | cfg, 72 | prefix="sqlalchemy.", 73 | poolclass=pool.NullPool, 74 | ) 75 | 76 | with connectable.connect() as connection: 77 | context.configure(connection=connection, target_metadata=target_metadata) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /alembic/versions/684292138a0a_many_devices_to_many_users.py: -------------------------------------------------------------------------------- 1 | """many devices to many users 2 | 3 | Revision ID: 684292138a0a 4 | Revises: c8ce82d0c054 5 | Create Date: 2024-07-23 19:26:35.940451 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | 13 | from alembic import op 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "684292138a0a" 17 | down_revision: Union[str, None] = "c8ce82d0c054" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = "c8ce82d0c054" 20 | 21 | 22 | def upgrade() -> None: 23 | # Remove columns from devices table that are no longer needed 24 | op.drop_column("devices", "security_level") 25 | op.drop_column("devices", "session_id_type") 26 | 27 | # rename code to info 28 | op.alter_column("devices", "code", new_column_name="info", existing_type=sa.String(255)) 29 | op.add_column( 30 | "devices", 31 | sa.Column( 32 | "code", 33 | sa.String(length=255), 34 | nullable=False, 35 | default=sa.text( 36 | """ 37 | SHA2( 38 | CONCAT( 39 | SHA2(CONVERT(client_id_blob_filename USING utf8mb4), 256), 40 | ':', 41 | SHA2(CONVERT(client_id_blob_filename USING utf8mb4), 256) 42 | ), 43 | 256 44 | ) 45 | """ 46 | ), 47 | ), 48 | ) 49 | 50 | # remove all rows where uploaded_by is null, as they are not associated with a user 51 | op.execute("DELETE FROM devices WHERE uploaded_by IS NULL") 52 | op.alter_column("devices", "uploaded_by", nullable=False, existing_type=sa.String(255)) 53 | 54 | # generate code for rows as a sha256 in the format of "client_id_blob_filename sha256:device_private_key sha265:uploaded_by" 55 | op.execute( 56 | """ 57 | UPDATE devices 58 | SET code = SHA2( 59 | CONCAT( 60 | SHA2(CONVERT(client_id_blob_filename USING utf8mb4), 256), 61 | ':', 62 | SHA2(CONVERT(client_id_blob_filename USING utf8mb4), 256) 63 | ), 64 | 256 65 | ) 66 | """ 67 | ) 68 | 69 | # for mapping, cause we need to dedupe and create a unique constraint 70 | op.execute("DELETE FROM devices WHERE id NOT IN (SELECT MIN(id) FROM devices GROUP BY code)") 71 | op.create_unique_constraint(None, "devices", ["code"]) 72 | 73 | # create a user <-> device association table 74 | op.create_table( 75 | "user_device", 76 | sa.Column("user_id", sa.VARCHAR(255), nullable=False), 77 | sa.Column("device_code", sa.VARCHAR(255), nullable=False), 78 | sa.ForeignKeyConstraint(["device_code"], ["devices.code"], ondelete="CASCADE"), 79 | sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), 80 | ) 81 | 82 | # Insert existing data into user_device table 83 | old_devices = op.get_bind().execute(sa.text("SELECT * FROM devices")).fetchall() 84 | user_device_insert = [f"('{device[4]}', '{device[5]}')" for device in old_devices] 85 | if user_device_insert: 86 | user_device_insert_sql = "INSERT INTO user_device (user_id, device_code) VALUES " + ",".join(user_device_insert) 87 | op.execute(sa.text(user_device_insert_sql)) 88 | else: 89 | print("No devices found to insert into user_device table.") 90 | 91 | 92 | def downgrade() -> None: 93 | raise NotImplementedError("Downgrade is not supported for this migration.") 94 | -------------------------------------------------------------------------------- /alembic/versions/8fbc59f03986_convert_device_to_wvd.py: -------------------------------------------------------------------------------- 1 | """convert_device_to_wvd 2 | 3 | Revision ID: 8fbc59f03986 4 | Revises: 684292138a0a 5 | Create Date: 2024-07-24 16:35:56.530161 6 | 7 | """ 8 | 9 | import base64 10 | import logging 11 | from typing import Sequence, Union 12 | 13 | import sqlalchemy as sa 14 | from pywidevine.device import Device, DeviceTypes 15 | from sqlalchemy.dialects import mysql 16 | 17 | from alembic import op 18 | 19 | # revision identifiers, used by Alembic. 20 | revision: str = "8fbc59f03986" 21 | down_revision: Union[str, None] = "684292138a0a" 22 | branch_labels: Union[str, Sequence[str], None] = None 23 | depends_on: Union[str, Sequence[str], None] = "684292138a0a" 24 | 25 | logger = logging.getLogger("alembic.runtime.migration") 26 | 27 | 28 | def upgrade() -> None: 29 | # create the new wvd column and make it nullable temporarily 30 | op.add_column("devices", sa.Column("wvd", sa.Text(), nullable=True)) 31 | 32 | # DROP all devices that have an empty private key or client id 33 | op.execute("DELETE FROM devices WHERE device_private_key = '' OR client_id_blob_filename = ''") 34 | 35 | # run through all devices and convert them to a WVD 36 | devices = ( 37 | op.get_bind().execute(sa.text("SELECT code,client_id_blob_filename,device_private_key FROM devices")).fetchall() 38 | ) 39 | for code, client_id, private_key in devices: 40 | try: 41 | logger.info(f"Converting device {code} to WVD") 42 | wvd = Device( 43 | type_=DeviceTypes.ANDROID, 44 | security_level=3, 45 | flags=None, 46 | private_key=base64.b64decode(private_key), 47 | client_id=base64.b64decode(client_id), 48 | ) 49 | 50 | wvd_b64 = base64.b64encode(wvd.dumps()).decode() 51 | op.get_bind().execute( 52 | sa.text("UPDATE devices SET wvd = :wvd WHERE code = :code"), {"wvd": wvd_b64, "code": code} 53 | ) 54 | except Exception as e: 55 | logger.error(f"Failed to convert device {code} to WVD: {e}\nPK: {private_key}\nCID: {client_id}") 56 | # remove the device from the database 57 | op.get_bind().execute(sa.text("DELETE FROM devices WHERE code = :code"), {"code": code}) 58 | 59 | # make the wvd column non-nullable 60 | op.alter_column("devices", "wvd", existing_type=sa.Text(), nullable=False) 61 | 62 | # remove the old columns 63 | op.drop_column("devices", "device_private_key") 64 | op.drop_column("devices", "client_id_blob_filename") 65 | 66 | 67 | def downgrade() -> None: 68 | raise NotImplementedError("Downgrade is not supported for this migration.") 69 | -------------------------------------------------------------------------------- /alembic/versions/c8ce82d0c054_rename_cdm_table.py: -------------------------------------------------------------------------------- 1 | """rename_cdm_table 2 | 3 | Revision ID: c8ce82d0c054 4 | Revises: f3df682d6393 5 | Create Date: 2024-05-21 22:34:31.031258 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | 13 | from alembic import op 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "c8ce82d0c054" 17 | down_revision: Union[str, None] = "f3df682d6393" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = "f3df682d6393" 20 | 21 | 22 | def upgrade() -> None: 23 | op.rename_table("cdms", "devices") 24 | 25 | 26 | def downgrade() -> None: 27 | op.rename_table("devices", "cdms") 28 | -------------------------------------------------------------------------------- /alembic/versions/e1238372d8d1_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: e1238372d8d1 4 | Revises: 5 | Create Date: 2024-03-08 18:44:58.605453 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | 13 | from alembic import op 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "e1238372d8d1" 17 | down_revision: Union[str, None] = None 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | bind = op.get_bind() 24 | inspector = sa.Inspector.from_engine(bind) 25 | 26 | migrate_existing_apikeys = "apikeys" in inspector.get_table_names() 27 | migrate_existing_users = "users" in inspector.get_table_names() 28 | migrate_existing_cdms = "cdms" in inspector.get_table_names() 29 | migrate_existing_keys = "keys_" in inspector.get_table_names() 30 | 31 | if migrate_existing_apikeys: 32 | op.rename_table("apikeys", "apikeys_old") 33 | 34 | if migrate_existing_users: 35 | op.rename_table("users", "users_old") 36 | 37 | if migrate_existing_cdms: 38 | op.rename_table("cdms", "cdms_old") 39 | 40 | if migrate_existing_keys: 41 | op.rename_table("keys_", "keys__old") 42 | 43 | # create new tables 44 | op.create_table( 45 | "apikeys", 46 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 47 | sa.Column("created_at", sa.DateTime(), nullable=False), 48 | sa.Column("api_key", sa.String(length=255), nullable=False), 49 | sa.Column("user_id", sa.String(length=255), nullable=False), 50 | sa.PrimaryKeyConstraint("id"), 51 | sa.UniqueConstraint("id"), 52 | ) 53 | 54 | op.create_table( 55 | "users", 56 | sa.Column("id", sa.String(length=19), nullable=False), 57 | sa.Column("username", sa.String(length=255), nullable=False), 58 | sa.Column("discriminator", sa.String(length=255), nullable=False), 59 | sa.Column("avatar", sa.String(length=255), nullable=True), 60 | sa.Column("public_flags", sa.Integer(), nullable=False), 61 | sa.Column("api_key", sa.String(length=255), nullable=False), 62 | sa.Column("flags", sa.Integer(), nullable=False), 63 | sa.PrimaryKeyConstraint("id"), 64 | sa.UniqueConstraint("id"), 65 | ) 66 | 67 | op.create_table( 68 | "cdms", 69 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 70 | sa.Column("session_id_type", sa.String(length=255), nullable=False), 71 | sa.Column("security_level", sa.Integer(), nullable=False), 72 | sa.Column("client_id_blob_filename", sa.Text(), nullable=False), 73 | sa.Column("device_private_key", sa.Text(), nullable=False), 74 | sa.Column("code", sa.Text(), nullable=False), 75 | sa.Column("uploaded_by", sa.String(length=255), nullable=True), 76 | sa.ForeignKeyConstraint( 77 | ["uploaded_by"], 78 | ["users.id"], 79 | ), 80 | sa.PrimaryKeyConstraint("id"), 81 | ) 82 | 83 | op.create_table( 84 | "keys_", 85 | sa.Column("kid", sa.String(length=32), nullable=False), 86 | sa.Column("added_at", sa.Integer(), nullable=False), 87 | sa.Column("added_by", sa.String(length=19), nullable=True), 88 | sa.Column("license_url", sa.Text(), nullable=False), 89 | sa.Column("key_", sa.String(length=255), nullable=False), 90 | sa.ForeignKeyConstraint( 91 | ["added_by"], 92 | ["users.id"], 93 | ), 94 | sa.PrimaryKeyConstraint("kid"), 95 | ) 96 | 97 | if ( 98 | not migrate_existing_apikeys 99 | and not migrate_existing_users 100 | and not migrate_existing_cdms 101 | and not migrate_existing_keys 102 | ): 103 | return 104 | 105 | op.execute("SET FOREIGN_KEY_CHECKS=0;") # due to some added_by columns being null 106 | 107 | # copy data from old tables to new tables 108 | if migrate_existing_users: 109 | op.execute( 110 | "INSERT INTO users (id, username, discriminator, avatar, public_flags, api_key, flags) SELECT id, username, discriminator, avatar, public_flags, api_key, flags FROM users_old" 111 | ) 112 | 113 | if migrate_existing_cdms: 114 | op.execute( 115 | "INSERT INTO cdms (id, session_id_type, security_level, client_id_blob_filename, device_private_key, code, uploaded_by) SELECT id, session_id_type, security_level, client_id_blob_filename, device_private_key, code, uploaded_by FROM cdms_old" 116 | ) 117 | 118 | if migrate_existing_apikeys: 119 | op.execute( 120 | "INSERT INTO apikeys (id, created_at, api_key, user_id) SELECT id, created_at, api_key, user_id FROM apikeys_old" 121 | ) 122 | 123 | if migrate_existing_keys: 124 | op.execute( 125 | "INSERT INTO keys_ (kid, added_at, added_by, license_url, key_) SELECT kid, added_at, added_by, license_url, key_ FROM keys__old" 126 | ) 127 | 128 | # drop old tables 129 | if migrate_existing_apikeys: 130 | op.drop_table("apikeys_old") 131 | 132 | if migrate_existing_users: 133 | op.drop_table("users_old") 134 | 135 | if migrate_existing_cdms: 136 | op.drop_table("cdms_old") 137 | 138 | if migrate_existing_keys: 139 | op.drop_table("keys__old") 140 | 141 | op.execute("SET FOREIGN_KEY_CHECKS=1;") 142 | 143 | 144 | def downgrade() -> None: 145 | # ### commands auto generated by Alembic - please adjust! ### 146 | op.drop_table("keys_") 147 | op.drop_table("cdms") 148 | op.drop_table("users") 149 | op.drop_table("apikeys") 150 | # ### end Alembic commands ### 151 | -------------------------------------------------------------------------------- /alembic/versions/f194cc3e699f_create_system_user.py: -------------------------------------------------------------------------------- 1 | """create system user 2 | 3 | Revision ID: f194cc3e699f 4 | Revises: 8fbc59f03986 5 | Create Date: 2024-08-01 12:41:28.108426 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | 13 | from alembic import op 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "f194cc3e699f" 17 | down_revision: Union[str, None] = "8fbc59f03986" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | op.execute( 24 | """ 25 | INSERT INTO `users` (`id`, `username`, `discriminator`, `avatar`, `public_flags`, `api_key`, `flags`) VALUES ('0000000000000000000', 'System', '0', NULL, '0', '0', '64'); 26 | """ 27 | ) 28 | 29 | 30 | def downgrade() -> None: 31 | op.execute( 32 | """ 33 | DELETE FROM `users` WHERE `id` = '0000000000000000000'; 34 | """ 35 | ) 36 | -------------------------------------------------------------------------------- /alembic/versions/f3df682d6393_add_id_to_keys.py: -------------------------------------------------------------------------------- 1 | """add_id_to_keys 2 | 3 | Revision ID: f3df682d6393 4 | Revises: e1238372d8d1 5 | Create Date: 2024-03-08 18:45:36.917818 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | 13 | from alembic import op 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "f3df682d6393" 17 | down_revision: Union[str, None] = "e1238372d8d1" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = "e1238372d8d1" 20 | 21 | 22 | def upgrade() -> None: 23 | # Rename old table 24 | op.execute("ALTER TABLE keys_ RENAME TO keys_old") 25 | 26 | # Create new table with correct data types 27 | op.create_table( 28 | "keys_", 29 | sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, unique=True), 30 | sa.Column("kid", sa.String(length=32), nullable=False), 31 | sa.Column("added_at", sa.Integer(), nullable=False), 32 | sa.Column("added_by", sa.String(length=19), sa.ForeignKey("users.id"), nullable=True), 33 | sa.Column("license_url", sa.Text(), nullable=False), 34 | sa.Column("key_", sa.String(length=255), nullable=False), 35 | sa.PrimaryKeyConstraint("id"), 36 | ) 37 | 38 | # Copy data from the old table to the new table, setting added_by to NULL for invalid references, and strip the kid prefix 39 | op.execute( 40 | "INSERT INTO keys_ (kid, added_at, added_by, license_url, key_) " 41 | "SELECT kid, added_at, CASE WHEN added_by IN (SELECT id FROM users) THEN added_by ELSE NULL END, license_url, " 42 | "SUBSTRING_INDEX(SUBSTRING_INDEX(key_, ':', -2), ':', 1) FROM keys_old" 43 | ) 44 | 45 | # Drop the old table 46 | op.drop_table("keys_old") 47 | 48 | 49 | def downgrade() -> None: 50 | # throw an error if someone tries to downgrade 51 | raise NotImplementedError("Downgrade is not supported for this migration.") 52 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | # Auto generated on first run if empty 3 | secret_key = "" 4 | database_uri = "mysql+mysqlconnector://user:password@host/database" 5 | max_sessions = 60 6 | # List of device keys that are used in rotation 7 | default_devices = ["device code"] 8 | # List of devices that should use the blacklist, these are considered to be GetWVKeys System keys. 9 | system_devices = ["device code"] 10 | guild_id = "" 11 | verified_role_id = "" 12 | login_disabled = false 13 | registration_disabled = false 14 | log_format = "[%(asctime)s] [%(name)s] [%(funcName)s:%(lineno)d] %(levelname)s: %(message)s" 15 | log_date_format = "%I:%M:%S" 16 | redis_uri = "redis://localhost:6379/0" 17 | 18 | [api] 19 | host = "0.0.0.0" 20 | port = 8080 21 | base_url = "http://localhost:8080" 22 | 23 | ### 24 | # OAuth2 Configuration 25 | ### 26 | [oauth] 27 | # Discord OAuth Client ID 28 | client_id = "" 29 | # Discord OAuth Client Secret 30 | client_secret = "" 31 | # Discord OAuth Redirect URI 32 | redirect_url = "" 33 | 34 | [[url_blacklist]] 35 | url = ".*my\\.awesome\\.site\\.com.*" 36 | partial = true 37 | 38 | [[url_blacklist]] 39 | url = "https://example.com/some_page_to_block" 40 | partial = false 41 | 42 | [[external_build_info]] 43 | code = "device_code" 44 | url = "https://example.com/api" 45 | token = "s3cr$t" 46 | -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | getwvkeys: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | DEVELOPMENT: true 8 | restart: unless-stopped 9 | ports: 10 | - "8080:8080" 11 | volumes: 12 | - getwvkeys-storage:/exec/persistent/storage 13 | environment: 14 | DEVELOPMENT: true 15 | depends_on: 16 | db: 17 | condition: service_healthy 18 | db: 19 | image: mariadb 20 | restart: unless-stopped 21 | environment: 22 | - MARIADB_ROOT_PASSWORD=toor 23 | - MARIADB_DATABASE=getwvkeys 24 | - MARIADB_USER=getwvkeys 25 | - MARIADB_PASSWORD=toor 26 | ports: 27 | - "3306:3306" 28 | volumes: 29 | - db:/var/lib/mysql 30 | healthcheck: 31 | test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] 32 | start_period: 10s 33 | interval: 10s 34 | timeout: 5s 35 | retries: 3 36 | phpmyadmin: 37 | image: phpmyadmin 38 | restart: unless-stopped 39 | ports: 40 | - "8081:80" 41 | depends_on: 42 | - db 43 | environment: 44 | PMA_PORT: 3306 45 | PMA_HOST: db 46 | PMA_USER: root 47 | PMA_PASSWORD: toor 48 | PMA_PMADB: getwvkeys 49 | UPLOAD_LIMIT: 1000000000 50 | volumes: 51 | getwvkeys-storage: 52 | db: 53 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | getwvkeys: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | DEVELOPMENT: false 8 | restart: unless-stopped 9 | ports: 10 | - "8080:8080" 11 | volumes: 12 | - getwvkeys-storage:/exec/persistent/storage 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | db: 17 | image: mariadb 18 | restart: unless-stopped 19 | environment: 20 | - MARIADB_ROOT_PASSWORD=toor 21 | - MARIADB_DATABASE=getwvkeys 22 | - MARIADB_USER=getwvkeys 23 | - MARIADB_PASSWORD=toor 24 | ports: 25 | - "3306:3306" 26 | volumes: 27 | - db:/var/lib/mysql 28 | healthcheck: 29 | test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] 30 | start_period: 10s 31 | interval: 10s 32 | timeout: 5s 33 | retries: 3 34 | phpmyadmin: 35 | image: phpmyadmin 36 | restart: unless-stopped 37 | ports: 38 | - "8081:80" 39 | depends_on: 40 | - db 41 | environment: 42 | PMA_PORT: 3306 43 | PMA_HOST: db 44 | PMA_USER: root 45 | PMA_PASSWORD: toor 46 | PMA_PMADB: getwvkeys 47 | UPLOAD_LIMIT: 1000000000 48 | volumes: 49 | getwvkeys-storage: 50 | db: 51 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | # if [ -z "$CONFIG" ]; then 8 | # echo "Container failed to start, missing CONFIG environment variable." 9 | # exit 1 10 | # fi 11 | 12 | # set certain environment variables if DEVELOPMENT is set to true 13 | if [ "$DEVELOPMENT" = "true" ]; then 14 | export OAUTHLIB_INSECURE_TRANSPORT=1 15 | fi 16 | 17 | echo "Running migrations..." 18 | poetry run migrate 19 | 20 | echo "Starting server..." 21 | gunicorn -w 1 --bind :8080 getwvkeys.main:app 22 | exec "$@" -------------------------------------------------------------------------------- /getwvkeys/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | -------------------------------------------------------------------------------- /getwvkeys/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import base64 19 | import logging 20 | import os 21 | import pathlib 22 | import time 23 | 24 | import toml 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | IS_DEVELOPMENT = bool(os.environ.get("DEVELOPMENT", False)) 29 | IS_STAGING = bool(os.environ.get("STAGING", False)) 30 | CONFIG_ENV = os.environ.get("CONFIG", None) # used for docker, config is base64 encoded toml 31 | CONFIG_FILE = "config.dev.toml" if IS_DEVELOPMENT else "config.staging.toml" if IS_STAGING else "config.toml" 32 | CONFIG = toml.loads(base64.b64decode(CONFIG_ENV).decode()) if CONFIG_ENV else toml.load(CONFIG_FILE) 33 | 34 | SECRET_KEY = CONFIG["general"]["secret_key"] # Flask secret key 35 | 36 | # auto generate secret key if not set 37 | if not SECRET_KEY: 38 | logger.warning("No secret key found in config.toml, generating a new one.") 39 | SECRET_KEY = os.urandom(32).hex() 40 | CONFIG["general"]["secret_key"] = SECRET_KEY 41 | with open(CONFIG_FILE, "w") as f: 42 | toml.dump(CONFIG, f) 43 | 44 | OAUTH2_CLIENT_ID = CONFIG["oauth"]["client_id"] # Discord OAuth Client ID 45 | OAUTH2_CLIENT_SECRET = CONFIG["oauth"]["client_secret"] # Discord OAuth Client Secret 46 | OAUTH2_REDIRECT_URL = CONFIG["oauth"]["redirect_url"] # Discord OAuth Callback URL 47 | SQLALCHEMY_DATABASE_URI = CONFIG["general"]["database_uri"] # Database connection URI 48 | REDIS_URI = CONFIG["general"].get("redis_uri", None) # Redis connection URI 49 | 50 | if SQLALCHEMY_DATABASE_URI.startswith("sqlite"): 51 | raise Exception("SQLite is not supported, please use a different database.") 52 | 53 | API_HOST = CONFIG.get("api", {}).get("host", "0.0.0.0") 54 | API_PORT = int(CONFIG.get("api", {}).get("port", 8080)) 55 | API_URL = CONFIG.get("api", {}).get("base_url", "https://getwvkeys.cc") 56 | 57 | MAX_SESSIONS = CONFIG["general"].get("max_sessions", 60) 58 | DEFAULT_DEVICES = CONFIG["general"].get("default_devices", []) # list of build infos to use in key rotation 59 | GUILD_ID = CONFIG["general"]["guild_id"] # Discord Guild ID 60 | VERIFIED_ROLE_ID = CONFIG["general"]["verified_role_id"] # Discord Verified role ID 61 | LOGIN_DISABLED = CONFIG["general"].get("login_disabled", False) 62 | CONSOLE_LOG_LEVEL = logging.DEBUG 63 | FILE_LOG_LEVEL = logging.DEBUG 64 | LOG_FORMAT = CONFIG["general"].get( 65 | "log_format", 66 | "[%(asctime)s] [%(name)s] [%(funcName)s:%(lineno)d] %(levelname)s: %(message)s", 67 | ) 68 | LOG_DATE_FORMAT = CONFIG["general"].get("log_date_format", "%I:%M:%S") 69 | WVK_LOG_FILE_PATH = pathlib.Path(os.getcwd(), "logs", f"GWVK_{time.strftime('%Y-%m-%d')}.log") 70 | URL_BLACKLIST = CONFIG.get("url_blacklist", []) 71 | EXTERNAL_API_DEVICES = CONFIG.get("external_build_info", []) 72 | # List of device keys that should use the blacklist, these are considered to be GetWVKeys System keys. 73 | SYSTEM_DEVICES = CONFIG["general"].get("system_devices", []) 74 | -------------------------------------------------------------------------------- /getwvkeys/download/getwvkeys.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import argparse 19 | import base64 20 | import json 21 | import sys 22 | 23 | import requests 24 | 25 | # Version of the API the script is for. This should be changed when the API is updated. 26 | API_VERSION = "5" 27 | # Version of the individual script 28 | SCRIPT_VERSION = "5.2" 29 | # Dynamic injection of the API url 30 | API_URL = "__getwvkeys_api_url__" 31 | 32 | # Change your headers here 33 | headers = {"Connection": "keep-alive", "accept": "*/*"} 34 | 35 | 36 | # CHANGE THIS FUNCTION TO PARSE LICENSE URL RESPONSE 37 | def post_request(url, headers, challenge, verbose): 38 | r = requests.post(url, headers=headers, data=challenge, timeout=10) 39 | if not r.ok: 40 | print("[-] Failed to get license: [{}] {}".format(r.status_code, r.text)) 41 | exit(1) 42 | if verbose: 43 | # printing the raw license data can break terminals 44 | print("[+] License response:\n", base64.b64encode(r.content).decode("utf-8")) 45 | return r.content 46 | 47 | 48 | # Do Not Change Anything in this class 49 | class GetWVKeys: 50 | def __init__( 51 | self, 52 | url: str, 53 | pssh: str, 54 | auth: str, 55 | verbose: bool = False, 56 | force: bool = False, 57 | device_code: str = "", 58 | _headers: dict[str, str] = headers, 59 | **kwargs, 60 | ) -> None: 61 | # dynamic injection of the API url 62 | self.url = url 63 | self.pssh = pssh 64 | self.auth = auth 65 | self.verbose = verbose 66 | self.force = force 67 | self.device_code = device_code 68 | 69 | self.baseurl = "https://getwvkeys.cc" if API_URL == "__getwvkeys_api_url__" else API_URL 70 | self.api_url = self.baseurl + "/pywidevine" 71 | self.headers = _headers 72 | 73 | def generate_request(self): 74 | if self.verbose: 75 | print("[+] Generating License Request ") 76 | data = {"pssh": self.pssh, "device_code": self.device_code, "force": self.force, "license_url": self.url} 77 | header = {"X-API-Key": self.auth, "Content-Type": "application/json"} 78 | r = requests.post(self.api_url, json=data, headers=header) 79 | if not r.ok: 80 | if "error" in r.text: 81 | # parse the response as a json error 82 | error = json.loads(r.text) 83 | print("[-] Failed to generate license request: [{}] {}".format(error.get("code"), error.get("message"))) 84 | exit(1) 85 | print("[-] Failed to generate license request: [{}] {}".format(r.status_code, r.text)) 86 | exit(1) 87 | 88 | data = r.json() 89 | 90 | if "X-Cache" in r.headers: 91 | keys = data["keys"] 92 | return {"cache": True, "keys": keys} 93 | 94 | self.session_id = data["session_id"] 95 | challenge = data["challenge"] 96 | 97 | if self.verbose: 98 | print("[+] License Request Generated\n", challenge) 99 | print("[+] Session ID:", self.session_id) 100 | 101 | return {"cache": False, "challenge": base64.b64decode(challenge)} 102 | 103 | def decrypter(self, license_response): 104 | if self.verbose: 105 | print("[+] Decrypting with License Request and Response ") 106 | data = { 107 | "pssh": self.pssh, 108 | "response": license_response, 109 | "license_url": self.url, 110 | "headers": self.headers, 111 | "device_code": self.device_code, 112 | "force": self.force, 113 | "session_id": self.session_id, 114 | } 115 | header = {"X-API-Key": self.auth, "Content-Type": "application/json"} 116 | r = requests.post(self.api_url, json=data, headers=header) 117 | if not r.ok: 118 | if "error" in r.text: 119 | # parse the response as a json error 120 | error = json.loads(r.text) 121 | print("[-] Failed to decrypt license: [{}] {}".format(error.get("code"), error.get("message"))) 122 | exit(1) 123 | print("[-] Failed to decrypt license: [{}] {}".format(r.status_code, r.text)) 124 | exit(1) 125 | return r.json() 126 | 127 | def main(self): 128 | license_request = self.generate_request() 129 | if license_request["cache"] == True: 130 | if __name__ == "__main__": 131 | print("\n" * 5) 132 | print("[+] Keys (Cached):") 133 | keys = license_request["keys"] 134 | for k in keys: 135 | print("--key {}".format(k["key"])) 136 | return 137 | else: 138 | return license_request["keys"] 139 | if self.verbose: 140 | print("[+] Sending License URL Request") 141 | license_response = post_request(self.url, self.headers, license_request["challenge"], self.verbose) 142 | decrypt_response = self.decrypter(base64.b64encode(license_response).decode()) 143 | keys = decrypt_response["keys"] 144 | session_id = decrypt_response["session_id"] 145 | 146 | if self.verbose: 147 | print(json.dumps(decrypt_response, indent=4)) 148 | print("Decryption Session ID:", session_id) 149 | 150 | if __name__ == "__main__": 151 | print("\n" * 5) 152 | print("[+] Keys:") 153 | for k in keys: 154 | print("--key {}".format(k)) 155 | return 156 | else: 157 | return decrypt_response 158 | 159 | 160 | if __name__ == "__main__": 161 | getwvkeys_api_key = "__getwvkeys_api_key__" 162 | banner = """ 163 | ____ _ __ ____ ___ __ 164 | / ___| ___| |\\ \\ / /\\ \\ / / |/ /___ _ _ ___ 165 | | | _ / _ \\ __\\ \\ /\\ / / \\ \\ / /| ' // _ \\ | | / __| 166 | | |_| | __/ |_ \\ V V / \\ V / | . \\ __/ |_| \\__ \\ 167 | \\____|\\___|\\__| \\_/\\_/ \\_/ |_|\\_\\___|\\__, |___/ 168 | |___/ 169 | Script Version: {} 170 | API Version: {} 171 | """.format( 172 | SCRIPT_VERSION, API_VERSION 173 | ) 174 | print(banner) 175 | 176 | parser = argparse.ArgumentParser() 177 | parser.add_argument("url", help="License URL") 178 | parser.add_argument("pssh", help="PSSH") 179 | parser.add_argument( 180 | "--auth", "-api_key", help="GetWVKeys API Key" 181 | ) # auth is deprecated, use api_key instead. auth will be removed in the next major version 182 | parser.add_argument("--verbose", "-v", help="increase output verbosity", action="store_true") 183 | parser.add_argument( 184 | "--force", 185 | "-f", 186 | help="Force fetch, bypasses cache (You should only use this if the cached keys are not working). Default is OFF", 187 | default=False, 188 | action="store_true", 189 | ) 190 | parser.add_argument("--device_code", "-d", default="", help="Use custom device", required=False) 191 | parser.add_argument("--version", "-V", help="Print version and exit", action="store_true") 192 | 193 | args = parser.parse_args() 194 | args.auth = getwvkeys_api_key if getwvkeys_api_key != "__getwvkeys_api_key__" else args.auth 195 | args.headers = headers 196 | 197 | if args.version: 198 | print(f"GetWVKeys Generic v{SCRIPT_VERSION} for API Version {API_VERSION}") 199 | exit(0) 200 | 201 | while (args.url is None or args.pssh is None or args.auth is None) or ( 202 | args.url == "" or args.pssh == "" or args.auth == "" 203 | ): 204 | if not args.url: 205 | args.url = input("Enter License URL: ") 206 | if not args.pssh: 207 | args.pssh = input("Enter PSSH: ") 208 | if not args.auth: 209 | args.auth = input("Enter GetWVKeys API Key: ") 210 | 211 | if len(sys.argv) == 1: 212 | parser.print_help() 213 | print() 214 | args.device_code = "" 215 | args.verbose = False 216 | 217 | try: 218 | start = GetWVKeys(**vars(args)) 219 | start.main() 220 | except Exception as e: 221 | raise 222 | -------------------------------------------------------------------------------- /getwvkeys/formats/widevine_pssh_data.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Google Inc. All rights reserved. 2 | // 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file or at 5 | // https://developers.google.com/open-source/licenses/bsd 6 | // 7 | // This file defines Widevine Pssh Data proto format. 8 | 9 | syntax = "proto2"; 10 | 11 | package shaka.media; 12 | 13 | message WidevinePsshData { 14 | enum Algorithm { 15 | UNENCRYPTED = 0; 16 | AESCTR = 1; 17 | }; 18 | optional Algorithm algorithm = 1; 19 | repeated bytes key_id = 2; 20 | 21 | // Content provider name. 22 | optional string provider = 3; 23 | 24 | // A content identifier, specified by content provider. 25 | optional bytes content_id = 4; 26 | 27 | // The name of a registered policy to be used for this asset. 28 | optional string policy = 6; 29 | 30 | // Crypto period index, for media using key rotation. 31 | optional uint32 crypto_period_index = 7; 32 | 33 | // Optional protected context for group content. The grouped_license is a 34 | // serialized SignedMessage. 35 | optional bytes grouped_license = 8; 36 | 37 | // Protection scheme identifying the encryption algorithm. Represented as one 38 | // of the following 4CC values: 'cenc' (AES-CTR), 'cbc1' (AES-CBC), 39 | // 'cens' (AES-CTR subsample), 'cbcs' (AES-CBC subsample). 40 | optional uint32 protection_scheme = 9; 41 | } 42 | 43 | // Derived from WidevinePsshData. The JSON format of this proto is used in 44 | // Widevine HLS DRM signaling v1. 45 | // We cannot build JSON from WidevinePsshData as |key_id| is required to be in 46 | // hex format, while |bytes| type is translated to base64 by JSON formatter, so 47 | // we have to use |string| type and do hex conversion in the code. 48 | message WidevineHeader { 49 | repeated string key_ids = 2; 50 | 51 | // Content provider name. 52 | optional string provider = 3; 53 | 54 | // A content identifier, specified by content provider. 55 | optional bytes content_id = 4; 56 | } 57 | -------------------------------------------------------------------------------- /getwvkeys/formats/widevine_pssh_data_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: widevine_pssh_data.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18widevine_pssh_data.proto\x12\x0bshaka.media\"\x8f\x02\n\x10WidevinePsshData\x12:\n\talgorithm\x18\x01 \x01(\x0e\x32\'.shaka.media.WidevinePsshData.Algorithm\x12\x0e\n\x06key_id\x18\x02 \x03(\x0c\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c\x12\x0e\n\x06policy\x18\x06 \x01(\t\x12\x1b\n\x13\x63rypto_period_index\x18\x07 \x01(\r\x12\x17\n\x0fgrouped_license\x18\x08 \x01(\x0c\x12\x19\n\x11protection_scheme\x18\t \x01(\r\"(\n\tAlgorithm\x12\x0f\n\x0bUNENCRYPTED\x10\x00\x12\n\n\x06\x41\x45SCTR\x10\x01\"G\n\x0eWidevineHeader\x12\x0f\n\x07key_ids\x18\x02 \x03(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c') 18 | 19 | _globals = globals() 20 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 21 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'widevine_pssh_data_pb2', _globals) 22 | if _descriptor._USE_C_DESCRIPTORS == False: 23 | DESCRIPTOR._options = None 24 | _globals['_WIDEVINEPSSHDATA']._serialized_start=42 25 | _globals['_WIDEVINEPSSHDATA']._serialized_end=313 26 | _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_start=273 27 | _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_end=313 28 | _globals['_WIDEVINEHEADER']._serialized_start=315 29 | _globals['_WIDEVINEHEADER']._serialized_end=386 30 | # @@protoc_insertion_point(module_scope) 31 | -------------------------------------------------------------------------------- /getwvkeys/formats/wv_proto2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | // from x86 (partial), most of it from the ARM version: 4 | message ClientIdentification { 5 | enum TokenType { 6 | KEYBOX = 0; 7 | DEVICE_CERTIFICATE = 1; 8 | REMOTE_ATTESTATION_CERTIFICATE = 2; 9 | } 10 | message NameValue { 11 | required string Name = 1; 12 | required string Value = 2; 13 | } 14 | message ClientCapabilities { 15 | enum HdcpVersion { 16 | HDCP_NONE = 0; 17 | HDCP_V1 = 1; 18 | HDCP_V2 = 2; 19 | HDCP_V2_1 = 3; 20 | HDCP_V2_2 = 4; 21 | } 22 | optional uint32 ClientToken = 1; 23 | optional uint32 SessionToken = 2; 24 | optional uint32 VideoResolutionConstraints = 3; 25 | optional HdcpVersion MaxHdcpVersion = 4; 26 | optional uint32 OemCryptoApiVersion = 5; 27 | } 28 | required TokenType Type = 1; 29 | //optional bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one: 30 | optional SignedDeviceCertificate Token = 2; // use this when parsing, "bytes" when building a client id blob 31 | repeated NameValue ClientInfo = 3; 32 | optional bytes ProviderClientToken = 4; 33 | optional uint32 LicenseCounter = 5; 34 | optional ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later 35 | optional FileHashes _FileHashes = 7; // vmp blob goes here 36 | } 37 | 38 | message DeviceCertificate { 39 | enum CertificateType { 40 | ROOT = 0; 41 | INTERMEDIATE = 1; 42 | USER_DEVICE = 2; 43 | SERVICE = 3; 44 | } 45 | required CertificateType Type = 1; // the compiled code reused this as ProvisionedDeviceInfo.WvSecurityLevel, however that is incorrect (compiler aliased it as they're both identical as a structure) 46 | optional bytes SerialNumber = 2; 47 | optional uint32 CreationTimeSeconds = 3; 48 | optional bytes PublicKey = 4; 49 | optional uint32 SystemId = 5; 50 | optional uint32 TestDeviceDeprecated = 6; // is it bool or int? 51 | optional bytes ServiceId = 7; // service URL for service certificates 52 | } 53 | 54 | // missing some references, 55 | message DeviceCertificateStatus { 56 | enum CertificateStatus { 57 | VALID = 0; 58 | REVOKED = 1; 59 | } 60 | optional bytes SerialNumber = 1; 61 | optional CertificateStatus Status = 2; 62 | optional ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated? 63 | } 64 | 65 | message DeviceCertificateStatusList { 66 | optional uint32 CreationTimeSeconds = 1; 67 | repeated DeviceCertificateStatus CertificateStatus = 2; 68 | } 69 | 70 | message EncryptedClientIdentification { 71 | required string ServiceId = 1; 72 | optional bytes ServiceCertificateSerialNumber = 2; 73 | required bytes EncryptedClientId = 3; 74 | required bytes EncryptedClientIdIv = 4; 75 | required bytes EncryptedPrivacyKey = 5; 76 | } 77 | 78 | // todo: fill (for this top-level type, it might be impossible/difficult) 79 | enum LicenseType { 80 | ZERO = 0; 81 | DEFAULT = 1; // 1 is STREAMING/temporary license; on recent versions may go up to 3 (latest x86); it might be persist/don't persist type, unconfirmed 82 | OFFLINE = 2; 83 | } 84 | 85 | // todo: fill (for this top-level type, it might be impossible/difficult) 86 | // this is just a guess because these globals got lost, but really, do we need more? 87 | enum ProtocolVersion { 88 | CURRENT = 21; // don't have symbols for this 89 | } 90 | 91 | 92 | message LicenseIdentification { 93 | optional bytes RequestId = 1; 94 | optional bytes SessionId = 2; 95 | optional bytes PurchaseId = 3; 96 | optional LicenseType Type = 4; 97 | optional uint32 Version = 5; 98 | optional bytes ProviderSessionToken = 6; 99 | } 100 | 101 | 102 | message License { 103 | message Policy { 104 | optional bool CanPlay = 1; // changed from uint32 to bool 105 | optional bool CanPersist = 2; 106 | optional bool CanRenew = 3; 107 | optional uint32 RentalDurationSeconds = 4; 108 | optional uint32 PlaybackDurationSeconds = 5; 109 | optional uint32 LicenseDurationSeconds = 6; 110 | optional uint32 RenewalRecoveryDurationSeconds = 7; 111 | optional string RenewalServerUrl = 8; 112 | optional uint32 RenewalDelaySeconds = 9; 113 | optional uint32 RenewalRetryIntervalSeconds = 10; 114 | optional bool RenewWithUsage = 11; // was uint32 115 | } 116 | message KeyContainer { 117 | enum KeyType { 118 | SIGNING = 1; 119 | CONTENT = 2; 120 | KEY_CONTROL = 3; 121 | OPERATOR_SESSION = 4; 122 | } 123 | enum SecurityLevel { 124 | SW_SECURE_CRYPTO = 1; 125 | SW_SECURE_DECODE = 2; 126 | HW_SECURE_CRYPTO = 3; 127 | HW_SECURE_DECODE = 4; 128 | HW_SECURE_ALL = 5; 129 | } 130 | message OutputProtection { 131 | enum CGMS { 132 | COPY_FREE = 0; 133 | COPY_ONCE = 2; 134 | COPY_NEVER = 3; 135 | CGMS_NONE = 0x2A; // PC default! 136 | } 137 | optional ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away 138 | optional CGMS CgmsFlags = 2; 139 | } 140 | message KeyControl { 141 | required bytes KeyControlBlock = 1; // what is this? 142 | required bytes Iv = 2; 143 | } 144 | message OperatorSessionKeyPermissions { 145 | optional uint32 AllowEncrypt = 1; 146 | optional uint32 AllowDecrypt = 2; 147 | optional uint32 AllowSign = 3; 148 | optional uint32 AllowSignatureVerify = 4; 149 | } 150 | message VideoResolutionConstraint { 151 | optional uint32 MinResolutionPixels = 1; 152 | optional uint32 MaxResolutionPixels = 2; 153 | optional OutputProtection RequiredProtection = 3; 154 | } 155 | optional bytes Id = 1; 156 | optional bytes Iv = 2; 157 | optional bytes Key = 3; 158 | optional KeyType Type = 4; 159 | optional SecurityLevel Level = 5; 160 | optional OutputProtection RequiredProtection = 6; 161 | optional OutputProtection RequestedProtection = 7; 162 | optional KeyControl _KeyControl = 8; // duped names, etc 163 | optional OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc 164 | repeated VideoResolutionConstraint VideoResolutionConstraints = 10; 165 | } 166 | optional LicenseIdentification Id = 1; 167 | optional Policy _Policy = 2; // duped names, etc 168 | repeated KeyContainer Key = 3; 169 | optional uint32 LicenseStartTime = 4; 170 | optional uint32 RemoteAttestationVerified = 5; // bool? 171 | optional bytes ProviderClientToken = 6; 172 | // there might be more, check with newer versions (I see field 7-8 in a lic) 173 | // this appeared in latest x86: 174 | optional uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc) 175 | } 176 | 177 | message LicenseError { 178 | enum Error { 179 | INVALID_DEVICE_CERTIFICATE = 1; 180 | REVOKED_DEVICE_CERTIFICATE = 2; 181 | SERVICE_UNAVAILABLE = 3; 182 | } 183 | //LicenseRequest.RequestType ErrorCode; // clang mismatch 184 | optional Error ErrorCode = 1; 185 | } 186 | 187 | message LicenseRequest { 188 | message ContentIdentification { 189 | message CENC { 190 | //optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with: 191 | optional WidevineCencHeader Pssh = 1; 192 | optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!) 193 | optional bytes RequestId = 3; 194 | } 195 | message WebM { 196 | optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used 197 | optional LicenseType LicenseType = 2; 198 | optional bytes RequestId = 3; 199 | } 200 | message ExistingLicense { 201 | optional LicenseIdentification LicenseId = 1; 202 | optional uint32 SecondsSinceStarted = 2; 203 | optional uint32 SecondsSinceLastPlayed = 3; 204 | optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB! 205 | } 206 | optional CENC CencId = 1; 207 | optional WebM WebmId = 2; 208 | optional ExistingLicense License = 3; 209 | } 210 | enum RequestType { 211 | NEW = 1; 212 | RENEWAL = 2; 213 | RELEASE = 3; 214 | } 215 | optional ClientIdentification ClientId = 1; 216 | optional ContentIdentification ContentId = 2; 217 | optional RequestType Type = 3; 218 | optional uint32 RequestTime = 4; 219 | optional bytes KeyControlNonceDeprecated = 5; 220 | optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this 221 | optional uint32 KeyControlNonce = 7; 222 | optional EncryptedClientIdentification EncryptedClientId = 8; 223 | } 224 | 225 | // raw pssh hack 226 | message LicenseRequestRaw { 227 | message ContentIdentification { 228 | message CENC { 229 | optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with: 230 | //optional WidevineCencHeader Pssh = 1; 231 | optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!) 232 | optional bytes RequestId = 3; 233 | } 234 | message WebM { 235 | optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used 236 | optional LicenseType LicenseType = 2; 237 | optional bytes RequestId = 3; 238 | } 239 | message ExistingLicense { 240 | optional LicenseIdentification LicenseId = 1; 241 | optional uint32 SecondsSinceStarted = 2; 242 | optional uint32 SecondsSinceLastPlayed = 3; 243 | optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB! 244 | } 245 | optional CENC CencId = 1; 246 | optional WebM WebmId = 2; 247 | optional ExistingLicense License = 3; 248 | } 249 | enum RequestType { 250 | NEW = 1; 251 | RENEWAL = 2; 252 | RELEASE = 3; 253 | } 254 | optional ClientIdentification ClientId = 1; 255 | optional ContentIdentification ContentId = 2; 256 | optional RequestType Type = 3; 257 | optional uint32 RequestTime = 4; 258 | optional bytes KeyControlNonceDeprecated = 5; 259 | optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this 260 | optional uint32 KeyControlNonce = 7; 261 | optional EncryptedClientIdentification EncryptedClientId = 8; 262 | } 263 | 264 | 265 | message ProvisionedDeviceInfo { 266 | enum WvSecurityLevel { 267 | LEVEL_UNSPECIFIED = 0; 268 | LEVEL_1 = 1; 269 | LEVEL_2 = 2; 270 | LEVEL_3 = 3; 271 | } 272 | optional uint32 SystemId = 1; 273 | optional string Soc = 2; 274 | optional string Manufacturer = 3; 275 | optional string Model = 4; 276 | optional string DeviceType = 5; 277 | optional uint32 ModelYear = 6; 278 | optional WvSecurityLevel SecurityLevel = 7; 279 | optional uint32 TestDevice = 8; // bool? 280 | } 281 | 282 | 283 | // todo: fill 284 | message ProvisioningOptions { 285 | } 286 | 287 | // todo: fill 288 | message ProvisioningRequest { 289 | } 290 | 291 | // todo: fill 292 | message ProvisioningResponse { 293 | } 294 | 295 | message RemoteAttestation { 296 | optional EncryptedClientIdentification Certificate = 1; 297 | optional string Salt = 2; 298 | optional string Signature = 3; 299 | } 300 | 301 | // todo: fill 302 | message SessionInit { 303 | } 304 | 305 | // todo: fill 306 | message SessionState { 307 | } 308 | 309 | // todo: fill 310 | message SignedCertificateStatusList { 311 | } 312 | 313 | message SignedDeviceCertificate { 314 | 315 | //optional bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is: 316 | optional DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later 317 | optional bytes Signature = 2; 318 | optional SignedDeviceCertificate Signer = 3; 319 | } 320 | 321 | 322 | // todo: fill 323 | message SignedProvisioningMessage { 324 | } 325 | 326 | // the root of all messages, from either server or client 327 | message SignedMessage { 328 | enum MessageType { 329 | LICENSE_REQUEST = 1; 330 | LICENSE = 2; 331 | ERROR_RESPONSE = 3; 332 | SERVICE_CERTIFICATE_REQUEST = 4; 333 | SERVICE_CERTIFICATE = 5; 334 | } 335 | optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 336 | optional bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 337 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 338 | optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 339 | optional bytes SessionKey = 4; // often RSA wrapped for licenses 340 | optional RemoteAttestation RemoteAttestation = 5; 341 | } 342 | 343 | 344 | 345 | // This message is copied from google's docs, not reversed: 346 | message WidevineCencHeader { 347 | enum Algorithm { 348 | UNENCRYPTED = 0; 349 | AESCTR = 1; 350 | }; 351 | optional Algorithm algorithm = 1; 352 | repeated bytes key_id = 2; 353 | 354 | // Content provider name. 355 | optional string provider = 3; 356 | 357 | // A content identifier, specified by content provider. 358 | optional bytes content_id = 4; 359 | 360 | // Track type. Acceptable values are SD, HD and AUDIO. Used to 361 | // differentiate content keys used by an asset. 362 | optional string track_type_deprecated = 5; 363 | 364 | // The name of a registered policy to be used for this asset. 365 | optional string policy = 6; 366 | 367 | // Crypto period index, for media using key rotation. 368 | optional uint32 crypto_period_index = 7; 369 | 370 | // Optional protected context for group content. The grouped_license is a 371 | // serialized SignedMessage. 372 | optional bytes grouped_license = 8; 373 | 374 | // Protection scheme identifying the encryption algorithm. 375 | // Represented as one of the following 4CC values: 376 | // 'cenc' (AESCTR), 'cbc1' (AESCBC), 377 | // 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample). 378 | optional uint32 protection_scheme = 9; 379 | 380 | // Optional. For media using key rotation, this represents the duration 381 | // of each crypto period in seconds. 382 | optional uint32 crypto_period_seconds = 10; 383 | } 384 | 385 | 386 | // remove these when using it outside of protoc: 387 | 388 | // from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically 389 | message SignedLicenseRequest { 390 | enum MessageType { 391 | LICENSE_REQUEST = 1; 392 | LICENSE = 2; 393 | ERROR_RESPONSE = 3; 394 | SERVICE_CERTIFICATE_REQUEST = 4; 395 | SERVICE_CERTIFICATE = 5; 396 | } 397 | optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 398 | optional LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 399 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 400 | optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 401 | optional bytes SessionKey = 4; // often RSA wrapped for licenses 402 | optional RemoteAttestation RemoteAttestation = 5; 403 | } 404 | 405 | // hack 406 | message SignedLicenseRequestRaw { 407 | enum MessageType { 408 | LICENSE_REQUEST = 1; 409 | LICENSE = 2; 410 | ERROR_RESPONSE = 3; 411 | SERVICE_CERTIFICATE_REQUEST = 4; 412 | SERVICE_CERTIFICATE = 5; 413 | } 414 | optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 415 | optional LicenseRequestRaw Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 416 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 417 | optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 418 | optional bytes SessionKey = 4; // often RSA wrapped for licenses 419 | optional RemoteAttestation RemoteAttestation = 5; 420 | } 421 | 422 | 423 | message SignedLicense { 424 | enum MessageType { 425 | LICENSE_REQUEST = 1; 426 | LICENSE = 2; 427 | ERROR_RESPONSE = 3; 428 | SERVICE_CERTIFICATE_REQUEST = 4; 429 | SERVICE_CERTIFICATE = 5; 430 | } 431 | optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 432 | optional License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 433 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 434 | optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 435 | optional bytes SessionKey = 4; // often RSA wrapped for licenses 436 | optional RemoteAttestation RemoteAttestation = 5; 437 | } 438 | 439 | message SignedServiceCertificate { 440 | enum MessageType { 441 | LICENSE_REQUEST = 1; 442 | LICENSE = 2; 443 | ERROR_RESPONSE = 3; 444 | SERVICE_CERTIFICATE_REQUEST = 4; 445 | SERVICE_CERTIFICATE = 5; 446 | } 447 | optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 448 | optional SignedDeviceCertificate Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 449 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 450 | optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 451 | optional bytes SessionKey = 4; // often RSA wrapped for licenses 452 | optional RemoteAttestation RemoteAttestation = 5; 453 | } 454 | 455 | //vmp support 456 | message FileHashes { 457 | message Signature { 458 | optional string filename = 1; 459 | optional bool test_signing = 2; //0 - release, 1 - testing 460 | optional bytes SHA512Hash = 3; 461 | optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file 462 | optional bytes signature = 5; 463 | } 464 | optional bytes signer = 1; 465 | repeated Signature signatures = 2; 466 | } 467 | -------------------------------------------------------------------------------- /getwvkeys/formats/wv_proto3.proto: -------------------------------------------------------------------------------- 1 | // beware proto3 won't show missing fields it seems, need to change to "proto2" and add "optional" before every field, and remove all the dummy enum members I added: 2 | syntax = "proto3"; 3 | 4 | // from x86 (partial), most of it from the ARM version: 5 | message ClientIdentification { 6 | enum TokenType { 7 | KEYBOX = 0; 8 | DEVICE_CERTIFICATE = 1; 9 | REMOTE_ATTESTATION_CERTIFICATE = 2; 10 | } 11 | message NameValue { 12 | string Name = 1; 13 | string Value = 2; 14 | } 15 | message ClientCapabilities { 16 | enum HdcpVersion { 17 | HDCP_NONE = 0; 18 | HDCP_V1 = 1; 19 | HDCP_V2 = 2; 20 | HDCP_V2_1 = 3; 21 | HDCP_V2_2 = 4; 22 | } 23 | uint32 ClientToken = 1; 24 | uint32 SessionToken = 2; 25 | uint32 VideoResolutionConstraints = 3; 26 | HdcpVersion MaxHdcpVersion = 4; 27 | uint32 OemCryptoApiVersion = 5; 28 | } 29 | TokenType Type = 1; 30 | //bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one: 31 | SignedDeviceCertificate Token = 2; 32 | repeated NameValue ClientInfo = 3; 33 | bytes ProviderClientToken = 4; 34 | uint32 LicenseCounter = 5; 35 | ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later 36 | } 37 | 38 | message DeviceCertificate { 39 | enum CertificateType { 40 | ROOT = 0; 41 | INTERMEDIATE = 1; 42 | USER_DEVICE = 2; 43 | SERVICE = 3; 44 | } 45 | //ProvisionedDeviceInfo.WvSecurityLevel Type = 1; // is this how one is supposed to call it? (it's an enum) there might be a bug here, with CertificateType getting confused with WvSecurityLevel, for now renaming it (verify against other binaries) 46 | CertificateType Type = 1; 47 | bytes SerialNumber = 2; 48 | uint32 CreationTimeSeconds = 3; 49 | bytes PublicKey = 4; 50 | uint32 SystemId = 5; 51 | uint32 TestDeviceDeprecated = 6; // is it bool or int? 52 | bytes ServiceId = 7; // service URL for service certificates 53 | } 54 | 55 | // missing some references, 56 | message DeviceCertificateStatus { 57 | enum CertificateStatus { 58 | VALID = 0; 59 | REVOKED = 1; 60 | } 61 | bytes SerialNumber = 1; 62 | CertificateStatus Status = 2; 63 | ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated? 64 | } 65 | 66 | message DeviceCertificateStatusList { 67 | uint32 CreationTimeSeconds = 1; 68 | repeated DeviceCertificateStatus CertificateStatus = 2; 69 | } 70 | 71 | message EncryptedClientIdentification { 72 | string ServiceId = 1; 73 | bytes ServiceCertificateSerialNumber = 2; 74 | bytes EncryptedClientId = 3; 75 | bytes EncryptedClientIdIv = 4; 76 | bytes EncryptedPrivacyKey = 5; 77 | } 78 | 79 | // todo: fill (for this top-level type, it might be impossible/difficult) 80 | enum LicenseType { 81 | ZERO = 0; 82 | DEFAULT = 1; // do not know what this is either, but should be 1; on recent versions may go up to 3 (latest x86) 83 | } 84 | 85 | // todo: fill (for this top-level type, it might be impossible/difficult) 86 | // this is just a guess because these globals got lost, but really, do we need more? 87 | enum ProtocolVersion { 88 | DUMMY = 0; 89 | CURRENT = 21; // don't have symbols for this 90 | } 91 | 92 | 93 | message LicenseIdentification { 94 | bytes RequestId = 1; 95 | bytes SessionId = 2; 96 | bytes PurchaseId = 3; 97 | LicenseType Type = 4; 98 | uint32 Version = 5; 99 | bytes ProviderSessionToken = 6; 100 | } 101 | 102 | 103 | message License { 104 | message Policy { 105 | uint32 CanPlay = 1; 106 | uint32 CanPersist = 2; 107 | uint32 CanRenew = 3; 108 | uint32 RentalDurationSeconds = 4; 109 | uint32 PlaybackDurationSeconds = 5; 110 | uint32 LicenseDurationSeconds = 6; 111 | uint32 RenewalRecoveryDurationSeconds = 7; 112 | string RenewalServerUrl = 8; 113 | uint32 RenewalDelaySeconds = 9; 114 | uint32 RenewalRetryIntervalSeconds = 10; 115 | uint32 RenewWithUsage = 11; 116 | uint32 UnknownPolicy12 = 12; 117 | } 118 | message KeyContainer { 119 | enum KeyType { 120 | _NOKEYTYPE = 0; // dummy, added to satisfy proto3, not present in original 121 | SIGNING = 1; 122 | CONTENT = 2; 123 | KEY_CONTROL = 3; 124 | OPERATOR_SESSION = 4; 125 | } 126 | enum SecurityLevel { 127 | _NOSECLEVEL = 0; // dummy, added to satisfy proto3, not present in original 128 | SW_SECURE_CRYPTO = 1; 129 | SW_SECURE_DECODE = 2; 130 | HW_SECURE_CRYPTO = 3; 131 | HW_SECURE_DECODE = 4; 132 | HW_SECURE_ALL = 5; 133 | } 134 | message OutputProtection { 135 | enum CGMS { 136 | COPY_FREE = 0; 137 | COPY_ONCE = 2; 138 | COPY_NEVER = 3; 139 | CGMS_NONE = 0x2A; // PC default! 140 | } 141 | ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away 142 | CGMS CgmsFlags = 2; 143 | } 144 | message KeyControl { 145 | bytes KeyControlBlock = 1; // what is this? 146 | bytes Iv = 2; 147 | } 148 | message OperatorSessionKeyPermissions { 149 | uint32 AllowEncrypt = 1; 150 | uint32 AllowDecrypt = 2; 151 | uint32 AllowSign = 3; 152 | uint32 AllowSignatureVerify = 4; 153 | } 154 | message VideoResolutionConstraint { 155 | uint32 MinResolutionPixels = 1; 156 | uint32 MaxResolutionPixels = 2; 157 | OutputProtection RequiredProtection = 3; 158 | } 159 | bytes Id = 1; 160 | bytes Iv = 2; 161 | bytes Key = 3; 162 | KeyType Type = 4; 163 | SecurityLevel Level = 5; 164 | OutputProtection RequiredProtection = 6; 165 | OutputProtection RequestedProtection = 7; 166 | KeyControl _KeyControl = 8; // duped names, etc 167 | OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc 168 | repeated VideoResolutionConstraint VideoResolutionConstraints = 10; 169 | } 170 | LicenseIdentification Id = 1; 171 | Policy _Policy = 2; // duped names, etc 172 | repeated KeyContainer Key = 3; 173 | uint32 LicenseStartTime = 4; 174 | uint32 RemoteAttestationVerified = 5; // bool? 175 | bytes ProviderClientToken = 6; 176 | // there might be more, check with newer versions (I see field 7-8 in a lic) 177 | // this appeared in latest x86: 178 | uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc) 179 | bytes UnknownHdcpDataField = 8; 180 | } 181 | 182 | message LicenseError { 183 | enum Error { 184 | DUMMY_NO_ERROR = 0; // dummy, added to satisfy proto3 185 | INVALID_DEVICE_CERTIFICATE = 1; 186 | REVOKED_DEVICE_CERTIFICATE = 2; 187 | SERVICE_UNAVAILABLE = 3; 188 | } 189 | //LicenseRequest.RequestType ErrorCode; // clang mismatch 190 | Error ErrorCode = 1; 191 | } 192 | 193 | message LicenseRequest { 194 | message ContentIdentification { 195 | message CENC { 196 | // bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with: 197 | WidevineCencHeader Pssh = 1; 198 | LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 199 | bytes RequestId = 3; 200 | } 201 | message WebM { 202 | bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used 203 | LicenseType LicenseType = 2; 204 | bytes RequestId = 3; 205 | } 206 | message ExistingLicense { 207 | LicenseIdentification LicenseId = 1; 208 | uint32 SecondsSinceStarted = 2; 209 | uint32 SecondsSinceLastPlayed = 3; 210 | bytes SessionUsageTableEntry = 4; 211 | } 212 | CENC CencId = 1; 213 | WebM WebmId = 2; 214 | ExistingLicense License = 3; 215 | } 216 | enum RequestType { 217 | DUMMY_REQ_TYPE = 0; // dummy, added to satisfy proto3 218 | NEW = 1; 219 | RENEWAL = 2; 220 | RELEASE = 3; 221 | } 222 | ClientIdentification ClientId = 1; 223 | ContentIdentification ContentId = 2; 224 | RequestType Type = 3; 225 | uint32 RequestTime = 4; 226 | bytes KeyControlNonceDeprecated = 5; 227 | ProtocolVersion ProtocolVersion = 6; // lacking symbols for this 228 | uint32 KeyControlNonce = 7; 229 | EncryptedClientIdentification EncryptedClientId = 8; 230 | } 231 | 232 | message ProvisionedDeviceInfo { 233 | enum WvSecurityLevel { 234 | LEVEL_UNSPECIFIED = 0; 235 | LEVEL_1 = 1; 236 | LEVEL_2 = 2; 237 | LEVEL_3 = 3; 238 | } 239 | uint32 SystemId = 1; 240 | string Soc = 2; 241 | string Manufacturer = 3; 242 | string Model = 4; 243 | string DeviceType = 5; 244 | uint32 ModelYear = 6; 245 | WvSecurityLevel SecurityLevel = 7; 246 | uint32 TestDevice = 8; // bool? 247 | } 248 | 249 | 250 | // todo: fill 251 | message ProvisioningOptions { 252 | } 253 | 254 | // todo: fill 255 | message ProvisioningRequest { 256 | } 257 | 258 | // todo: fill 259 | message ProvisioningResponse { 260 | } 261 | 262 | message RemoteAttestation { 263 | EncryptedClientIdentification Certificate = 1; 264 | string Salt = 2; 265 | string Signature = 3; 266 | } 267 | 268 | // todo: fill 269 | message SessionInit { 270 | } 271 | 272 | // todo: fill 273 | message SessionState { 274 | } 275 | 276 | // todo: fill 277 | message SignedCertificateStatusList { 278 | } 279 | 280 | message SignedDeviceCertificate { 281 | 282 | //bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is: 283 | DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later 284 | bytes Signature = 2; 285 | SignedDeviceCertificate Signer = 3; 286 | } 287 | 288 | 289 | // todo: fill 290 | message SignedProvisioningMessage { 291 | } 292 | 293 | // the root of all messages, from either server or client 294 | message SignedMessage { 295 | enum MessageType { 296 | DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3 297 | LICENSE_REQUEST = 1; 298 | LICENSE = 2; 299 | ERROR_RESPONSE = 3; 300 | SERVICE_CERTIFICATE_REQUEST = 4; 301 | SERVICE_CERTIFICATE = 5; 302 | } 303 | MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 304 | bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 305 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 306 | bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 307 | bytes SessionKey = 4; // often RSA wrapped for licenses 308 | RemoteAttestation RemoteAttestation = 5; 309 | } 310 | 311 | 312 | 313 | // This message is copied from google's docs, not reversed: 314 | message WidevineCencHeader { 315 | enum Algorithm { 316 | UNENCRYPTED = 0; 317 | AESCTR = 1; 318 | }; 319 | Algorithm algorithm = 1; 320 | repeated bytes key_id = 2; 321 | 322 | // Content provider name. 323 | string provider = 3; 324 | 325 | // A content identifier, specified by content provider. 326 | bytes content_id = 4; 327 | 328 | // Track type. Acceptable values are SD, HD and AUDIO. Used to 329 | // differentiate content keys used by an asset. 330 | string track_type_deprecated = 5; 331 | 332 | // The name of a registered policy to be used for this asset. 333 | string policy = 6; 334 | 335 | // Crypto period index, for media using key rotation. 336 | uint32 crypto_period_index = 7; 337 | 338 | // Optional protected context for group content. The grouped_license is a 339 | // serialized SignedMessage. 340 | bytes grouped_license = 8; 341 | 342 | // Protection scheme identifying the encryption algorithm. 343 | // Represented as one of the following 4CC values: 344 | // 'cenc' (AESCTR), 'cbc1' (AESCBC), 345 | // 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample). 346 | uint32 protection_scheme = 9; 347 | 348 | // Optional. For media using key rotation, this represents the duration 349 | // of each crypto period in seconds. 350 | uint32 crypto_period_seconds = 10; 351 | } 352 | 353 | 354 | 355 | 356 | // from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically 357 | message SignedLicenseRequest { 358 | enum MessageType { 359 | DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3 360 | LICENSE_REQUEST = 1; 361 | LICENSE = 2; 362 | ERROR_RESPONSE = 3; 363 | SERVICE_CERTIFICATE_REQUEST = 4; 364 | SERVICE_CERTIFICATE = 5; 365 | } 366 | MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 367 | LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 368 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 369 | bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 370 | bytes SessionKey = 4; // often RSA wrapped for licenses 371 | RemoteAttestation RemoteAttestation = 5; 372 | } 373 | 374 | message SignedLicense { 375 | enum MessageType { 376 | DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3 377 | LICENSE_REQUEST = 1; 378 | LICENSE = 2; 379 | ERROR_RESPONSE = 3; 380 | SERVICE_CERTIFICATE_REQUEST = 4; 381 | SERVICE_CERTIFICATE = 5; 382 | } 383 | MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel 384 | License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present 385 | // for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE 386 | bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now) 387 | bytes SessionKey = 4; // often RSA wrapped for licenses 388 | RemoteAttestation RemoteAttestation = 5; 389 | } -------------------------------------------------------------------------------- /getwvkeys/formats/wv_proto3_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: wv_proto3.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fwv_proto3.proto\"\xc5\x05\n\x14\x43lientIdentification\x12-\n\x04Type\x18\x01 \x01(\x0e\x32\x1f.ClientIdentification.TokenType\x12\'\n\x05Token\x18\x02 \x01(\x0b\x32\x18.SignedDeviceCertificate\x12\x33\n\nClientInfo\x18\x03 \x03(\x0b\x32\x1f.ClientIdentification.NameValue\x12\x1b\n\x13ProviderClientToken\x18\x04 \x01(\x0c\x12\x16\n\x0eLicenseCounter\x18\x05 \x01(\r\x12\x45\n\x13_ClientCapabilities\x18\x06 \x01(\x0b\x32(.ClientIdentification.ClientCapabilities\x1a(\n\tNameValue\x12\x0c\n\x04Name\x18\x01 \x01(\t\x12\r\n\x05Value\x18\x02 \x01(\t\x1a\xa4\x02\n\x12\x43lientCapabilities\x12\x13\n\x0b\x43lientToken\x18\x01 \x01(\r\x12\x14\n\x0cSessionToken\x18\x02 \x01(\r\x12\"\n\x1aVideoResolutionConstraints\x18\x03 \x01(\r\x12L\n\x0eMaxHdcpVersion\x18\x04 \x01(\x0e\x32\x34.ClientIdentification.ClientCapabilities.HdcpVersion\x12\x1b\n\x13OemCryptoApiVersion\x18\x05 \x01(\r\"T\n\x0bHdcpVersion\x12\r\n\tHDCP_NONE\x10\x00\x12\x0b\n\x07HDCP_V1\x10\x01\x12\x0b\n\x07HDCP_V2\x10\x02\x12\r\n\tHDCP_V2_1\x10\x03\x12\r\n\tHDCP_V2_2\x10\x04\"S\n\tTokenType\x12\n\n\x06KEYBOX\x10\x00\x12\x16\n\x12\x44\x45VICE_CERTIFICATE\x10\x01\x12\"\n\x1eREMOTE_ATTESTATION_CERTIFICATE\x10\x02\"\x9b\x02\n\x11\x44\x65viceCertificate\x12\x30\n\x04Type\x18\x01 \x01(\x0e\x32\".DeviceCertificate.CertificateType\x12\x14\n\x0cSerialNumber\x18\x02 \x01(\x0c\x12\x1b\n\x13\x43reationTimeSeconds\x18\x03 \x01(\r\x12\x11\n\tPublicKey\x18\x04 \x01(\x0c\x12\x10\n\x08SystemId\x18\x05 \x01(\r\x12\x1c\n\x14TestDeviceDeprecated\x18\x06 \x01(\r\x12\x11\n\tServiceId\x18\x07 \x01(\x0c\"K\n\x0f\x43\x65rtificateType\x12\x08\n\x04ROOT\x10\x00\x12\x10\n\x0cINTERMEDIATE\x10\x01\x12\x0f\n\x0bUSER_DEVICE\x10\x02\x12\x0b\n\x07SERVICE\x10\x03\"\xc4\x01\n\x17\x44\x65viceCertificateStatus\x12\x14\n\x0cSerialNumber\x18\x01 \x01(\x0c\x12:\n\x06Status\x18\x02 \x01(\x0e\x32*.DeviceCertificateStatus.CertificateStatus\x12*\n\nDeviceInfo\x18\x04 \x01(\x0b\x32\x16.ProvisionedDeviceInfo\"+\n\x11\x43\x65rtificateStatus\x12\t\n\x05VALID\x10\x00\x12\x0b\n\x07REVOKED\x10\x01\"o\n\x1b\x44\x65viceCertificateStatusList\x12\x1b\n\x13\x43reationTimeSeconds\x18\x01 \x01(\r\x12\x33\n\x11\x43\x65rtificateStatus\x18\x02 \x03(\x0b\x32\x18.DeviceCertificateStatus\"\xaf\x01\n\x1d\x45ncryptedClientIdentification\x12\x11\n\tServiceId\x18\x01 \x01(\t\x12&\n\x1eServiceCertificateSerialNumber\x18\x02 \x01(\x0c\x12\x19\n\x11\x45ncryptedClientId\x18\x03 \x01(\x0c\x12\x1b\n\x13\x45ncryptedClientIdIv\x18\x04 \x01(\x0c\x12\x1b\n\x13\x45ncryptedPrivacyKey\x18\x05 \x01(\x0c\"\x9c\x01\n\x15LicenseIdentification\x12\x11\n\tRequestId\x18\x01 \x01(\x0c\x12\x11\n\tSessionId\x18\x02 \x01(\x0c\x12\x12\n\nPurchaseId\x18\x03 \x01(\x0c\x12\x1a\n\x04Type\x18\x04 \x01(\x0e\x32\x0c.LicenseType\x12\x0f\n\x07Version\x18\x05 \x01(\r\x12\x1c\n\x14ProviderSessionToken\x18\x06 \x01(\x0c\"\xfa\x0e\n\x07License\x12\"\n\x02Id\x18\x01 \x01(\x0b\x32\x16.LicenseIdentification\x12 \n\x07_Policy\x18\x02 \x01(\x0b\x32\x0f.License.Policy\x12\"\n\x03Key\x18\x03 \x03(\x0b\x32\x15.License.KeyContainer\x12\x18\n\x10LicenseStartTime\x18\x04 \x01(\r\x12!\n\x19RemoteAttestationVerified\x18\x05 \x01(\r\x12\x1b\n\x13ProviderClientToken\x18\x06 \x01(\x0c\x12\x18\n\x10ProtectionScheme\x18\x07 \x01(\r\x12\x1c\n\x14UnknownHdcpDataField\x18\x08 \x01(\x0c\x1a\xd4\x02\n\x06Policy\x12\x0f\n\x07\x43\x61nPlay\x18\x01 \x01(\r\x12\x12\n\nCanPersist\x18\x02 \x01(\r\x12\x10\n\x08\x43\x61nRenew\x18\x03 \x01(\r\x12\x1d\n\x15RentalDurationSeconds\x18\x04 \x01(\r\x12\x1f\n\x17PlaybackDurationSeconds\x18\x05 \x01(\r\x12\x1e\n\x16LicenseDurationSeconds\x18\x06 \x01(\r\x12&\n\x1eRenewalRecoveryDurationSeconds\x18\x07 \x01(\r\x12\x18\n\x10RenewalServerUrl\x18\x08 \x01(\t\x12\x1b\n\x13RenewalDelaySeconds\x18\t \x01(\r\x12#\n\x1bRenewalRetryIntervalSeconds\x18\n \x01(\r\x12\x16\n\x0eRenewWithUsage\x18\x0b \x01(\r\x12\x17\n\x0fUnknownPolicy12\x18\x0c \x01(\r\x1a\x9b\n\n\x0cKeyContainer\x12\n\n\x02Id\x18\x01 \x01(\x0c\x12\n\n\x02Iv\x18\x02 \x01(\x0c\x12\x0b\n\x03Key\x18\x03 \x01(\x0c\x12+\n\x04Type\x18\x04 \x01(\x0e\x32\x1d.License.KeyContainer.KeyType\x12\x32\n\x05Level\x18\x05 \x01(\x0e\x32#.License.KeyContainer.SecurityLevel\x12\x42\n\x12RequiredProtection\x18\x06 \x01(\x0b\x32&.License.KeyContainer.OutputProtection\x12\x43\n\x13RequestedProtection\x18\x07 \x01(\x0b\x32&.License.KeyContainer.OutputProtection\x12\x35\n\x0b_KeyControl\x18\x08 \x01(\x0b\x32 .License.KeyContainer.KeyControl\x12[\n\x1e_OperatorSessionKeyPermissions\x18\t \x01(\x0b\x32\x33.License.KeyContainer.OperatorSessionKeyPermissions\x12S\n\x1aVideoResolutionConstraints\x18\n \x03(\x0b\x32/.License.KeyContainer.VideoResolutionConstraint\x1a\xdb\x01\n\x10OutputProtection\x12\x42\n\x04Hdcp\x18\x01 \x01(\x0e\x32\x34.ClientIdentification.ClientCapabilities.HdcpVersion\x12>\n\tCgmsFlags\x18\x02 \x01(\x0e\x32+.License.KeyContainer.OutputProtection.CGMS\"C\n\x04\x43GMS\x12\r\n\tCOPY_FREE\x10\x00\x12\r\n\tCOPY_ONCE\x10\x02\x12\x0e\n\nCOPY_NEVER\x10\x03\x12\r\n\tCGMS_NONE\x10*\x1a\x31\n\nKeyControl\x12\x17\n\x0fKeyControlBlock\x18\x01 \x01(\x0c\x12\n\n\x02Iv\x18\x02 \x01(\x0c\x1a|\n\x1dOperatorSessionKeyPermissions\x12\x14\n\x0c\x41llowEncrypt\x18\x01 \x01(\r\x12\x14\n\x0c\x41llowDecrypt\x18\x02 \x01(\r\x12\x11\n\tAllowSign\x18\x03 \x01(\r\x12\x1c\n\x14\x41llowSignatureVerify\x18\x04 \x01(\r\x1a\x99\x01\n\x19VideoResolutionConstraint\x12\x1b\n\x13MinResolutionPixels\x18\x01 \x01(\r\x12\x1b\n\x13MaxResolutionPixels\x18\x02 \x01(\r\x12\x42\n\x12RequiredProtection\x18\x03 \x01(\x0b\x32&.License.KeyContainer.OutputProtection\"Z\n\x07KeyType\x12\x0e\n\n_NOKEYTYPE\x10\x00\x12\x0b\n\x07SIGNING\x10\x01\x12\x0b\n\x07\x43ONTENT\x10\x02\x12\x0f\n\x0bKEY_CONTROL\x10\x03\x12\x14\n\x10OPERATOR_SESSION\x10\x04\"\x8b\x01\n\rSecurityLevel\x12\x0f\n\x0b_NOSECLEVEL\x10\x00\x12\x14\n\x10SW_SECURE_CRYPTO\x10\x01\x12\x14\n\x10SW_SECURE_DECODE\x10\x02\x12\x14\n\x10HW_SECURE_CRYPTO\x10\x03\x12\x14\n\x10HW_SECURE_DECODE\x10\x04\x12\x11\n\rHW_SECURE_ALL\x10\x05\"\xac\x01\n\x0cLicenseError\x12&\n\tErrorCode\x18\x01 \x01(\x0e\x32\x13.LicenseError.Error\"t\n\x05\x45rror\x12\x12\n\x0e\x44UMMY_NO_ERROR\x10\x00\x12\x1e\n\x1aINVALID_DEVICE_CERTIFICATE\x10\x01\x12\x1e\n\x1aREVOKED_DEVICE_CERTIFICATE\x10\x02\x12\x17\n\x13SERVICE_UNAVAILABLE\x10\x03\"\xc0\x07\n\x0eLicenseRequest\x12\'\n\x08\x43lientId\x18\x01 \x01(\x0b\x32\x15.ClientIdentification\x12\x38\n\tContentId\x18\x02 \x01(\x0b\x32%.LicenseRequest.ContentIdentification\x12)\n\x04Type\x18\x03 \x01(\x0e\x32\x1b.LicenseRequest.RequestType\x12\x13\n\x0bRequestTime\x18\x04 \x01(\r\x12!\n\x19KeyControlNonceDeprecated\x18\x05 \x01(\x0c\x12)\n\x0fProtocolVersion\x18\x06 \x01(\x0e\x32\x10.ProtocolVersion\x12\x17\n\x0fKeyControlNonce\x18\x07 \x01(\r\x12\x39\n\x11\x45ncryptedClientId\x18\x08 \x01(\x0b\x32\x1e.EncryptedClientIdentification\x1a\xa2\x04\n\x15\x43ontentIdentification\x12:\n\x06\x43\x65ncId\x18\x01 \x01(\x0b\x32*.LicenseRequest.ContentIdentification.CENC\x12:\n\x06WebmId\x18\x02 \x01(\x0b\x32*.LicenseRequest.ContentIdentification.WebM\x12\x46\n\x07License\x18\x03 \x01(\x0b\x32\x35.LicenseRequest.ContentIdentification.ExistingLicense\x1a_\n\x04\x43\x45NC\x12!\n\x04Pssh\x18\x01 \x01(\x0b\x32\x13.WidevineCencHeader\x12!\n\x0bLicenseType\x18\x02 \x01(\x0e\x32\x0c.LicenseType\x12\x11\n\tRequestId\x18\x03 \x01(\x0c\x1aL\n\x04WebM\x12\x0e\n\x06Header\x18\x01 \x01(\x0c\x12!\n\x0bLicenseType\x18\x02 \x01(\x0e\x32\x0c.LicenseType\x12\x11\n\tRequestId\x18\x03 \x01(\x0c\x1a\x99\x01\n\x0f\x45xistingLicense\x12)\n\tLicenseId\x18\x01 \x01(\x0b\x32\x16.LicenseIdentification\x12\x1b\n\x13SecondsSinceStarted\x18\x02 \x01(\r\x12\x1e\n\x16SecondsSinceLastPlayed\x18\x03 \x01(\r\x12\x1e\n\x16SessionUsageTableEntry\x18\x04 \x01(\x0c\"D\n\x0bRequestType\x12\x12\n\x0e\x44UMMY_REQ_TYPE\x10\x00\x12\x07\n\x03NEW\x10\x01\x12\x0b\n\x07RENEWAL\x10\x02\x12\x0b\n\x07RELEASE\x10\x03\"\xa6\x02\n\x15ProvisionedDeviceInfo\x12\x10\n\x08SystemId\x18\x01 \x01(\r\x12\x0b\n\x03Soc\x18\x02 \x01(\t\x12\x14\n\x0cManufacturer\x18\x03 \x01(\t\x12\r\n\x05Model\x18\x04 \x01(\t\x12\x12\n\nDeviceType\x18\x05 \x01(\t\x12\x11\n\tModelYear\x18\x06 \x01(\r\x12=\n\rSecurityLevel\x18\x07 \x01(\x0e\x32&.ProvisionedDeviceInfo.WvSecurityLevel\x12\x12\n\nTestDevice\x18\x08 \x01(\r\"O\n\x0fWvSecurityLevel\x12\x15\n\x11LEVEL_UNSPECIFIED\x10\x00\x12\x0b\n\x07LEVEL_1\x10\x01\x12\x0b\n\x07LEVEL_2\x10\x02\x12\x0b\n\x07LEVEL_3\x10\x03\"\x15\n\x13ProvisioningOptions\"\x15\n\x13ProvisioningRequest\"\x16\n\x14ProvisioningResponse\"i\n\x11RemoteAttestation\x12\x33\n\x0b\x43\x65rtificate\x18\x01 \x01(\x0b\x32\x1e.EncryptedClientIdentification\x12\x0c\n\x04Salt\x18\x02 \x01(\t\x12\x11\n\tSignature\x18\x03 \x01(\t\"\r\n\x0bSessionInit\"\x0e\n\x0cSessionState\"\x1d\n\x1bSignedCertificateStatusList\"\x86\x01\n\x17SignedDeviceCertificate\x12.\n\x12_DeviceCertificate\x18\x01 \x01(\x0b\x32\x12.DeviceCertificate\x12\x11\n\tSignature\x18\x02 \x01(\x0c\x12(\n\x06Signer\x18\x03 \x01(\x0b\x32\x18.SignedDeviceCertificate\"\x1b\n\x19SignedProvisioningMessage\"\xb0\x02\n\rSignedMessage\x12(\n\x04Type\x18\x01 \x01(\x0e\x32\x1a.SignedMessage.MessageType\x12\x0b\n\x03Msg\x18\x02 \x01(\x0c\x12\x11\n\tSignature\x18\x03 \x01(\x0c\x12\x12\n\nSessionKey\x18\x04 \x01(\x0c\x12-\n\x11RemoteAttestation\x18\x05 \x01(\x0b\x32\x12.RemoteAttestation\"\x91\x01\n\x0bMessageType\x12\x12\n\x0e\x44UMMY_MSG_TYPE\x10\x00\x12\x13\n\x0fLICENSE_REQUEST\x10\x01\x12\x0b\n\x07LICENSE\x10\x02\x12\x12\n\x0e\x45RROR_RESPONSE\x10\x03\x12\x1f\n\x1bSERVICE_CERTIFICATE_REQUEST\x10\x04\x12\x17\n\x13SERVICE_CERTIFICATE\x10\x05\"\xc5\x02\n\x12WidevineCencHeader\x12\x30\n\talgorithm\x18\x01 \x01(\x0e\x32\x1d.WidevineCencHeader.Algorithm\x12\x0e\n\x06key_id\x18\x02 \x03(\x0c\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c\x12\x1d\n\x15track_type_deprecated\x18\x05 \x01(\t\x12\x0e\n\x06policy\x18\x06 \x01(\t\x12\x1b\n\x13\x63rypto_period_index\x18\x07 \x01(\r\x12\x17\n\x0fgrouped_license\x18\x08 \x01(\x0c\x12\x19\n\x11protection_scheme\x18\t \x01(\r\x12\x1d\n\x15\x63rypto_period_seconds\x18\n \x01(\r\"(\n\tAlgorithm\x12\x0f\n\x0bUNENCRYPTED\x10\x00\x12\n\n\x06\x41\x45SCTR\x10\x01\"\xcf\x02\n\x14SignedLicenseRequest\x12/\n\x04Type\x18\x01 \x01(\x0e\x32!.SignedLicenseRequest.MessageType\x12\x1c\n\x03Msg\x18\x02 \x01(\x0b\x32\x0f.LicenseRequest\x12\x11\n\tSignature\x18\x03 \x01(\x0c\x12\x12\n\nSessionKey\x18\x04 \x01(\x0c\x12-\n\x11RemoteAttestation\x18\x05 \x01(\x0b\x32\x12.RemoteAttestation\"\x91\x01\n\x0bMessageType\x12\x12\n\x0e\x44UMMY_MSG_TYPE\x10\x00\x12\x13\n\x0fLICENSE_REQUEST\x10\x01\x12\x0b\n\x07LICENSE\x10\x02\x12\x12\n\x0e\x45RROR_RESPONSE\x10\x03\x12\x1f\n\x1bSERVICE_CERTIFICATE_REQUEST\x10\x04\x12\x17\n\x13SERVICE_CERTIFICATE\x10\x05\"\xba\x02\n\rSignedLicense\x12(\n\x04Type\x18\x01 \x01(\x0e\x32\x1a.SignedLicense.MessageType\x12\x15\n\x03Msg\x18\x02 \x01(\x0b\x32\x08.License\x12\x11\n\tSignature\x18\x03 \x01(\x0c\x12\x12\n\nSessionKey\x18\x04 \x01(\x0c\x12-\n\x11RemoteAttestation\x18\x05 \x01(\x0b\x32\x12.RemoteAttestation\"\x91\x01\n\x0bMessageType\x12\x12\n\x0e\x44UMMY_MSG_TYPE\x10\x00\x12\x13\n\x0fLICENSE_REQUEST\x10\x01\x12\x0b\n\x07LICENSE\x10\x02\x12\x12\n\x0e\x45RROR_RESPONSE\x10\x03\x12\x1f\n\x1bSERVICE_CERTIFICATE_REQUEST\x10\x04\x12\x17\n\x13SERVICE_CERTIFICATE\x10\x05*$\n\x0bLicenseType\x12\x08\n\x04ZERO\x10\x00\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x01*)\n\x0fProtocolVersion\x12\t\n\x05\x44UMMY\x10\x00\x12\x0b\n\x07\x43URRENT\x10\x15\x62\x06proto3') 18 | 19 | _globals = globals() 20 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 21 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'wv_proto3_pb2', _globals) 22 | if _descriptor._USE_C_DESCRIPTORS == False: 23 | DESCRIPTOR._options = None 24 | _globals['_LICENSETYPE']._serialized_start=6713 25 | _globals['_LICENSETYPE']._serialized_end=6749 26 | _globals['_PROTOCOLVERSION']._serialized_start=6751 27 | _globals['_PROTOCOLVERSION']._serialized_end=6792 28 | _globals['_CLIENTIDENTIFICATION']._serialized_start=20 29 | _globals['_CLIENTIDENTIFICATION']._serialized_end=729 30 | _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_start=309 31 | _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_end=349 32 | _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_start=352 33 | _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_end=644 34 | _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_start=560 35 | _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_end=644 36 | _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_start=646 37 | _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_end=729 38 | _globals['_DEVICECERTIFICATE']._serialized_start=732 39 | _globals['_DEVICECERTIFICATE']._serialized_end=1015 40 | _globals['_DEVICECERTIFICATE_CERTIFICATETYPE']._serialized_start=940 41 | _globals['_DEVICECERTIFICATE_CERTIFICATETYPE']._serialized_end=1015 42 | _globals['_DEVICECERTIFICATESTATUS']._serialized_start=1018 43 | _globals['_DEVICECERTIFICATESTATUS']._serialized_end=1214 44 | _globals['_DEVICECERTIFICATESTATUS_CERTIFICATESTATUS']._serialized_start=1171 45 | _globals['_DEVICECERTIFICATESTATUS_CERTIFICATESTATUS']._serialized_end=1214 46 | _globals['_DEVICECERTIFICATESTATUSLIST']._serialized_start=1216 47 | _globals['_DEVICECERTIFICATESTATUSLIST']._serialized_end=1327 48 | _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_start=1330 49 | _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_end=1505 50 | _globals['_LICENSEIDENTIFICATION']._serialized_start=1508 51 | _globals['_LICENSEIDENTIFICATION']._serialized_end=1664 52 | _globals['_LICENSE']._serialized_start=1667 53 | _globals['_LICENSE']._serialized_end=3581 54 | _globals['_LICENSE_POLICY']._serialized_start=1931 55 | _globals['_LICENSE_POLICY']._serialized_end=2271 56 | _globals['_LICENSE_KEYCONTAINER']._serialized_start=2274 57 | _globals['_LICENSE_KEYCONTAINER']._serialized_end=3581 58 | _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_start=2795 59 | _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_end=3014 60 | _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_start=2947 61 | _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_end=3014 62 | _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_start=3016 63 | _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_end=3065 64 | _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_start=3067 65 | _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_end=3191 66 | _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_start=3194 67 | _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_end=3347 68 | _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_start=3349 69 | _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_end=3439 70 | _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_start=3442 71 | _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_end=3581 72 | _globals['_LICENSEERROR']._serialized_start=3584 73 | _globals['_LICENSEERROR']._serialized_end=3756 74 | _globals['_LICENSEERROR_ERROR']._serialized_start=3640 75 | _globals['_LICENSEERROR_ERROR']._serialized_end=3756 76 | _globals['_LICENSEREQUEST']._serialized_start=3759 77 | _globals['_LICENSEREQUEST']._serialized_end=4719 78 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_start=4103 79 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_end=4649 80 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_CENC']._serialized_start=4320 81 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_CENC']._serialized_end=4415 82 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBM']._serialized_start=4417 83 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBM']._serialized_end=4493 84 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_start=4496 85 | _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_end=4649 86 | _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_start=4651 87 | _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_end=4719 88 | _globals['_PROVISIONEDDEVICEINFO']._serialized_start=4722 89 | _globals['_PROVISIONEDDEVICEINFO']._serialized_end=5016 90 | _globals['_PROVISIONEDDEVICEINFO_WVSECURITYLEVEL']._serialized_start=4937 91 | _globals['_PROVISIONEDDEVICEINFO_WVSECURITYLEVEL']._serialized_end=5016 92 | _globals['_PROVISIONINGOPTIONS']._serialized_start=5018 93 | _globals['_PROVISIONINGOPTIONS']._serialized_end=5039 94 | _globals['_PROVISIONINGREQUEST']._serialized_start=5041 95 | _globals['_PROVISIONINGREQUEST']._serialized_end=5062 96 | _globals['_PROVISIONINGRESPONSE']._serialized_start=5064 97 | _globals['_PROVISIONINGRESPONSE']._serialized_end=5086 98 | _globals['_REMOTEATTESTATION']._serialized_start=5088 99 | _globals['_REMOTEATTESTATION']._serialized_end=5193 100 | _globals['_SESSIONINIT']._serialized_start=5195 101 | _globals['_SESSIONINIT']._serialized_end=5208 102 | _globals['_SESSIONSTATE']._serialized_start=5210 103 | _globals['_SESSIONSTATE']._serialized_end=5224 104 | _globals['_SIGNEDCERTIFICATESTATUSLIST']._serialized_start=5226 105 | _globals['_SIGNEDCERTIFICATESTATUSLIST']._serialized_end=5255 106 | _globals['_SIGNEDDEVICECERTIFICATE']._serialized_start=5258 107 | _globals['_SIGNEDDEVICECERTIFICATE']._serialized_end=5392 108 | _globals['_SIGNEDPROVISIONINGMESSAGE']._serialized_start=5394 109 | _globals['_SIGNEDPROVISIONINGMESSAGE']._serialized_end=5421 110 | _globals['_SIGNEDMESSAGE']._serialized_start=5424 111 | _globals['_SIGNEDMESSAGE']._serialized_end=5728 112 | _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_start=5583 113 | _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_end=5728 114 | _globals['_WIDEVINECENCHEADER']._serialized_start=5731 115 | _globals['_WIDEVINECENCHEADER']._serialized_end=6056 116 | _globals['_WIDEVINECENCHEADER_ALGORITHM']._serialized_start=6016 117 | _globals['_WIDEVINECENCHEADER_ALGORITHM']._serialized_end=6056 118 | _globals['_SIGNEDLICENSEREQUEST']._serialized_start=6059 119 | _globals['_SIGNEDLICENSEREQUEST']._serialized_end=6394 120 | _globals['_SIGNEDLICENSEREQUEST_MESSAGETYPE']._serialized_start=5583 121 | _globals['_SIGNEDLICENSEREQUEST_MESSAGETYPE']._serialized_end=5728 122 | _globals['_SIGNEDLICENSE']._serialized_start=6397 123 | _globals['_SIGNEDLICENSE']._serialized_end=6711 124 | _globals['_SIGNEDLICENSE_MESSAGETYPE']._serialized_start=5583 125 | _globals['_SIGNEDLICENSE_MESSAGETYPE']._serialized_end=5728 126 | # @@protoc_insertion_point(module_scope) 127 | -------------------------------------------------------------------------------- /getwvkeys/models/APIKey.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from sqlalchemy import Column, DateTime, Integer, String, func 19 | 20 | from getwvkeys.models.Base import Base 21 | 22 | 23 | class APIKey(Base): 24 | __tablename__ = "apikeys" 25 | id = Column(Integer, primary_key=True, nullable=False, unique=True, autoincrement=True) 26 | created_at = Column(DateTime, nullable=False, default=func.now()) 27 | api_key = Column(String(255), nullable=False) 28 | user_id = Column(String(255), nullable=False) 29 | -------------------------------------------------------------------------------- /getwvkeys/models/Base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import MetaData 2 | from sqlalchemy.ext.declarative import declarative_base 3 | 4 | metadata = MetaData() 5 | Base = declarative_base(metadata=metadata) 6 | -------------------------------------------------------------------------------- /getwvkeys/models/Device.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import hashlib 19 | 20 | from sqlalchemy import Column, ForeignKey, Integer, String, Text 21 | from sqlalchemy.orm import relationship 22 | 23 | from getwvkeys.models.Base import Base 24 | from getwvkeys.models.UserDevice import user_device_association 25 | 26 | 27 | def generate_device_code(client_id_blob_filename: str, device_private_key: str) -> str: 28 | # get sha of client_id_blob_filename 29 | client_id_blob_filename_sha = hashlib.sha256(client_id_blob_filename.encode()).hexdigest() 30 | # get sha of device_private_key 31 | device_private_key_sha = hashlib.sha256(device_private_key.encode()).hexdigest() 32 | # get final hash 33 | return hashlib.sha256(f"{client_id_blob_filename_sha}:{device_private_key_sha}".encode()).hexdigest() 34 | 35 | 36 | class Device(Base): 37 | __tablename__ = "devices" 38 | id = Column(Integer, primary_key=True, autoincrement=True) 39 | code = Column(String(255), unique=True, nullable=False) 40 | wvd = Column(Text, nullable=False) 41 | uploaded_by = Column(String(255), ForeignKey("users.id"), nullable=False) 42 | info = Column(String(255), nullable=False) 43 | users = relationship("User", secondary=user_device_association, back_populates="devices") 44 | 45 | def to_json(self): 46 | return { 47 | "id": self.id, 48 | "wvd": self.wvd, 49 | "uploaded_by": self.uploaded_by, 50 | "code": self.code, 51 | "info": self.info, 52 | } 53 | -------------------------------------------------------------------------------- /getwvkeys/models/Key.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import time 19 | 20 | from sqlalchemy import VARCHAR, Column, ForeignKey, Integer, String, Text 21 | 22 | from getwvkeys.models.Base import Base 23 | 24 | 25 | class Key(Base): 26 | __tablename__ = "keys_" 27 | id = Column(Integer, primary_key=True, unique=True, autoincrement=True) 28 | kid = Column( 29 | String(32), 30 | nullable=False, 31 | ) 32 | added_at = Column(Integer, nullable=False, default=int(time.time())) 33 | added_by = Column(VARCHAR(19), ForeignKey("users.id"), nullable=True) 34 | license_url = Column(Text, nullable=False) 35 | key_ = Column(String(255), nullable=False) 36 | -------------------------------------------------------------------------------- /getwvkeys/models/Shared.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from flask_sqlalchemy import SQLAlchemy 19 | 20 | from getwvkeys.models.Base import Base 21 | 22 | db = SQLAlchemy(model_class=Base) 23 | -------------------------------------------------------------------------------- /getwvkeys/models/User.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from sqlalchemy import VARCHAR, Column, Integer, String 19 | from sqlalchemy.orm import relationship 20 | 21 | from getwvkeys.models.Base import Base 22 | from getwvkeys.models.UserDevice import user_device_association 23 | 24 | 25 | class User(Base): 26 | __tablename__ = "users" 27 | id = Column(VARCHAR(19), primary_key=True, nullable=False, unique=True) 28 | username = Column(String(255), nullable=False) 29 | discriminator = Column(String(255), nullable=False) 30 | avatar = Column(String(255), nullable=True) 31 | public_flags = Column(Integer, nullable=False) 32 | api_key = Column(String(255), nullable=False) 33 | flags = Column(Integer, default=0, nullable=False) 34 | devices = relationship("Device", secondary=user_device_association, back_populates="users") 35 | -------------------------------------------------------------------------------- /getwvkeys/models/UserDevice.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String, Table 2 | 3 | from getwvkeys.models.Base import Base 4 | 5 | user_device_association = Table( 6 | "user_device", 7 | Base.metadata, 8 | Column("user_id", String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False), 9 | Column("device_code", String(255), ForeignKey("devices.code", ondelete="CASCADE"), nullable=False), 10 | ) 11 | -------------------------------------------------------------------------------- /getwvkeys/pssh_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | The code in this file has been adapted from: https://github.com/google/shaka-packager/blob/master/packager/tools/pssh/pssh-box.py 4 | 5 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU Affero General Public License as published 9 | by the Free Software Foundation, version 3 of the License. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU Affero General Public License for more details. 15 | 16 | You should have received a copy of the GNU Affero General Public License 17 | along with this program. If not, see . 18 | """ 19 | 20 | import base64 21 | import struct 22 | 23 | from getwvkeys.formats.widevine_pssh_data_pb2 import WidevinePsshData 24 | 25 | WIDEVINE_SYSTEM_ID = base64.b16decode("EDEF8BA979D64ACEA3C827DCD51D21ED") 26 | 27 | 28 | def int_to_bytes(x: int) -> bytes: 29 | return x.to_bytes((x.bit_length() + 7) // 8, "big") 30 | 31 | 32 | def parse_pssh(pssh_b64): 33 | """Parses a PSSH box in base 64""" 34 | data = base64.b64decode(pssh_b64) 35 | reader = BinaryReader(data, little_endian=False) 36 | while reader.has_data(): 37 | start = reader.position 38 | size = reader.read_int(4) 39 | box_type = reader.read_bytes(4) 40 | if box_type != b"pssh": 41 | raise Exception("Invalid box type 0x%s, not 'pssh'" % box_type.hex()) 42 | version_and_flags = reader.read_int(4) 43 | version = version_and_flags >> 24 44 | if version > 1: 45 | raise Exception("Invalid pssh version %d" % version) 46 | 47 | system_id = reader.read_bytes(16) 48 | key_ids = [] 49 | if version == 1: 50 | count = reader.read_int(4) 51 | while count > 0: 52 | key = reader.read_bytes(16) 53 | key_ids.append(key) 54 | count -= 1 55 | 56 | pssh_data_size = reader.read_int(4) 57 | pssh_data = reader.read_bytes(pssh_data_size) 58 | 59 | if start + size != reader.position: 60 | raise Exception("Box does not match size of data") 61 | 62 | pssh = Pssh(version, system_id, key_ids, pssh_data) 63 | return pssh 64 | 65 | 66 | def _create_bin_int(value): 67 | """Creates a binary string as 4-byte array from the given integer.""" 68 | return struct.pack(">i", value) 69 | 70 | 71 | def _to_hex(data): 72 | return base64.b16encode(data).decode().lower() 73 | 74 | 75 | def _create_uuid_from_hex(ret): 76 | """Creates a human readable UUID string from the given hex string.""" 77 | return ret[:8] + "-" + ret[8:12] + "-" + ret[12:16] + "-" + ret[16:20] + "-" + ret[20:] 78 | 79 | 80 | def _create_uuid(data): 81 | """Creates a human readable UUID string from the given binary string.""" 82 | ret = base64.b16encode(data).decode().lower() 83 | return ret[:8] + "-" + ret[8:12] + "-" + ret[12:16] + "-" + ret[16:20] + "-" + ret[20:] 84 | 85 | 86 | def _parse_widevine_data(data): 87 | """Parses Widevine PSSH box from the given binary string.""" 88 | wv = WidevinePsshData() 89 | wv.ParseFromString(data) 90 | 91 | key_ids = [] 92 | provider = None 93 | content_id = None 94 | policy = None 95 | crypto_index_period = None 96 | protection_scheme = None 97 | 98 | if wv.key_id: 99 | key_ids = wv.key_id 100 | 101 | if wv.HasField("provider"): 102 | provider = wv.provider 103 | if wv.HasField("content_id"): 104 | content_id = base64.b16encode(wv.content_id).decode() 105 | if wv.HasField("policy"): 106 | policy = wv.policy 107 | if wv.HasField("crypto_period_index"): 108 | crypto_index_period = wv.crypto_period_index 109 | if wv.HasField("protection_scheme"): 110 | protection_scheme = struct.pack(">L", wv.protection_scheme) 111 | 112 | return PsshData(key_ids, provider, content_id, policy, crypto_index_period, protection_scheme) 113 | 114 | 115 | def _generate_widevine_data(key_ids, protection_scheme): 116 | """Generate widevine pssh data.""" 117 | wv = WidevinePsshData() 118 | wv.key_id.extend(key_ids) 119 | # 'cenc' is the default, so omitted to save bytes. 120 | if protection_scheme: 121 | wv.protection_scheme = struct.unpack(">L", protection_scheme.encode())[0] 122 | return wv.SerializeToString() 123 | 124 | 125 | class PsshData(object): 126 | def __init__(self, key_ids, provider, content_id, policy, crypto_period_index, protection_scheme): 127 | self.key_ids = [_to_hex(x) for x in key_ids] 128 | self.provider = provider 129 | self.content_id = content_id 130 | self.policy = policy 131 | self.crypto_period_index = crypto_period_index 132 | self.protection_scheme = protection_scheme 133 | 134 | def __repr__(self): 135 | lines = [] 136 | try: 137 | extra = self.humanize() 138 | lines.extend([" " + x for x in extra]) 139 | # pylint: disable=broad-except 140 | except Exception as e: 141 | lines.append(" ERROR: " + str(e)) 142 | 143 | return "\n".join(lines) 144 | 145 | def humanize(self): 146 | ret = [] 147 | if self.key_ids: 148 | ret.append("Key IDs (%d):" % len(self.key_ids)) 149 | ret.extend([" " + _create_uuid_from_hex(x) for x in self.key_ids]) 150 | 151 | if self.provider: 152 | ret.append("Provider: " + self.provider) 153 | if self.content_id: 154 | ret.append("Content ID: " + self.content_id) 155 | if self.policy: 156 | ret.append("Policy: " + self.policy) 157 | if self.crypto_period_index: 158 | ret.append("Crypto Period Index: %d" % self.crypto_period_index) 159 | if self.protection_scheme: 160 | ret.append("Protection Scheme: %s" % self.protection_scheme) 161 | 162 | return ret 163 | 164 | 165 | class Pssh(object): 166 | """Defines a PSSH box and related functions.""" 167 | 168 | def __init__(self, version, system_id, key_ids, pssh_data): 169 | """Parses a PSSH box from the given data. 170 | Args: 171 | version: The version number of the box 172 | system_id: A binary string of the System ID 173 | key_ids: An array of binary strings for the key IDs 174 | pssh_data: A binary string of the PSSH data 175 | """ 176 | self.version = version 177 | self.system_id = system_id 178 | self.key_ids = key_ids or [] 179 | self.pssh_data = pssh_data or b"" 180 | self.data = _parse_widevine_data(self.pssh_data) 181 | 182 | def binary_string(self): 183 | """Converts the PSSH box to a binary string.""" 184 | ret = b"pssh" + _create_bin_int(self.version << 24) 185 | ret += self.system_id 186 | if self.version == 1: 187 | ret += _create_bin_int(len(self.key_ids)) 188 | for key in self.key_ids: 189 | ret += key 190 | ret += _create_bin_int(len(self.pssh_data)) 191 | ret += self.pssh_data 192 | return _create_bin_int(len(ret) + 4) + ret 193 | 194 | def __repr__(self): 195 | """Converts the PSSH box to a human readable string.""" 196 | system_name = "" 197 | convert_data = self.data.humanize 198 | if self.system_id == WIDEVINE_SYSTEM_ID: 199 | system_name = "Widevine" 200 | 201 | lines = ["PSSH Box v%d" % self.version, " System ID: %s %s" % (system_name, _create_uuid(self.system_id))] 202 | if self.version == 1: 203 | lines.append(" Key IDs (%d):" % len(self.key_ids)) 204 | lines.extend([" " + _create_uuid(key) for key in self.key_ids]) 205 | 206 | lines.append(" PSSH Data (size: %d):" % len(self.pssh_data)) 207 | if self.pssh_data: 208 | if convert_data: 209 | lines.append(" " + system_name + " Data:") 210 | try: 211 | extra = convert_data() 212 | lines.extend([" " + x for x in extra]) 213 | # pylint: disable=broad-except 214 | except Exception as e: 215 | lines.append(" ERROR: " + str(e)) 216 | else: 217 | lines.extend([" Raw Data (base64):", " " + base64.b64encode(self.pssh_data).decode("utf8")]) 218 | 219 | return "\n".join(lines) 220 | 221 | 222 | class BinaryReader(object): 223 | """A helper class used to read binary data from an binary string.""" 224 | 225 | def __init__(self, data, little_endian): 226 | self.data = data 227 | self.little_endian = little_endian 228 | self.position = 0 229 | 230 | def has_data(self): 231 | """Returns whether the reader has any data left to read.""" 232 | return self.position < len(self.data) 233 | 234 | def read_bytes(self, count): 235 | """Reads the given number of bytes into an array.""" 236 | if len(self.data) < self.position + count: 237 | raise Exception("Invalid PSSH box, not enough data") 238 | ret = self.data[self.position : self.position + count] 239 | self.position += count 240 | return ret 241 | 242 | def read_int(self, size): 243 | """Reads an integer of the given size (in bytes).""" 244 | data = self.read_bytes(size) 245 | ret = 0 246 | for i in range(0, size): 247 | if self.little_endian: 248 | ret |= data[i] << (8 * i) 249 | else: 250 | ret |= data[i] << (8 * (size - i - 1)) 251 | return ret 252 | -------------------------------------------------------------------------------- /getwvkeys/redis.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import json 19 | 20 | import redis 21 | 22 | from getwvkeys import config, libraries 23 | from getwvkeys.models.Shared import db 24 | from getwvkeys.utils import OPCode, search_res_to_dict 25 | 26 | 27 | class Redis: 28 | def __init__(self, app, library: libraries.GetWVKeys) -> None: 29 | self.app = app 30 | self.library = library 31 | self.redis = redis.Redis.from_url(config.REDIS_URI, decode_responses=True, encoding="utf8") 32 | self.p = self.redis.pubsub(ignore_subscribe_messages=True) 33 | self.p.subscribe(**{"api": self.redis_message_handler}) 34 | self.redis_thread = self.p.run_in_thread(daemon=True) 35 | 36 | def publish_error(self, reply_address, e): 37 | payload = {"op": -1, "d": {"error": True, "message": e}} 38 | self.redis.publish(reply_address, json.dumps(payload)) 39 | 40 | def publish_response(self, reply_address, msg=None): 41 | payload = {"op": OPCode.REPLY.value, "d": {"error": False, "message": msg}} 42 | self.redis.publish(reply_address, json.dumps(payload)) 43 | 44 | def redis_message_handler(self, msg): 45 | try: 46 | data = json.loads(msg.get("data")) 47 | op = data.get("op") 48 | d = data.get("d") 49 | reply_to = data.get("reply_to") 50 | 51 | print("OPCode {}; d: {}".format(op, json.dumps(d))) 52 | 53 | if op == OPCode.DISABLE_USER.value: 54 | user_id = d.get("user_id") 55 | if not user_id: 56 | self.publish_error(reply_to, "No user_id found in message") 57 | return 58 | with self.app.app_context(): 59 | try: 60 | libraries.User.disable_user(db, user_id) 61 | self.publish_response(reply_to) 62 | except Exception as e: 63 | self.publish_error(reply_to, "Error disablng user {}: {}".format(user_id, e)) 64 | elif op == OPCode.DISABLE_USER_BULK.value: 65 | user_ids = d.get("user_ids") 66 | if not user_ids: 67 | self.publish_error(reply_to, "No user_ids found in message") 68 | return 69 | with self.app.app_context(): 70 | try: 71 | libraries.User.disable_users(db, user_ids) 72 | self.publish_response( 73 | reply_to, 74 | ) 75 | except Exception as e: 76 | self.publish_error(reply_to, "Error disablng users: {}".format(e)) 77 | elif op == OPCode.ENABLE_USER.value: 78 | user_id = d.get("user_id") 79 | if not user_id: 80 | self.publish_error(reply_to, "No user_id found in message") 81 | return 82 | with self.app.app_context(): 83 | try: 84 | libraries.User.enable_user(db, user_id) 85 | self.publish_response( 86 | reply_to, 87 | ) 88 | except Exception as e: 89 | self.publish_error(reply_to, "Error enabling user {}: {}".format(user_id, e)) 90 | elif op == OPCode.KEY_COUNT.value: 91 | with self.app.app_context(): 92 | self.publish_response(reply_to, self.library.get_keycount()) 93 | elif op == OPCode.USER_COUNT.value: 94 | with self.app.app_context(): 95 | self.publish_response(reply_to, libraries.User.get_user_count()) 96 | elif op == OPCode.SEARCH.value: 97 | query = d.get("query") 98 | if not query: 99 | self.publish_error(reply_to, "No query found in message") 100 | return 101 | with self.app.app_context(): 102 | try: 103 | results = self.library.search(query) 104 | results = search_res_to_dict(query, results) 105 | self.publish_response(reply_to, results) 106 | except Exception as e: 107 | print(e) 108 | self.publish_error(reply_to, "Error searching: {}".format(e)) 109 | elif op == OPCode.UPDATE_PERMISSIONS.value: 110 | user_id = d.get("user_id") 111 | permissions = d.get("permissions") 112 | permission_action = d.get("permission_action") 113 | if not user_id or not permissions: 114 | self.publish_error(reply_to, "No user_id or permissions found in message") 115 | return 116 | with self.app.app_context(): 117 | try: 118 | user = libraries.User.get(db, user_id) 119 | if not user: 120 | self.publish_error(reply_to, "User not found") 121 | return 122 | 123 | print("Old flags: ", user.flags_raw) 124 | user = user.update_flags(permissions, permission_action) 125 | print("New flags: ", user.flags_raw) 126 | self.publish_response( 127 | reply_to, 128 | ) 129 | except Exception as e: 130 | self.publish_error(reply_to, "Error updating permissions for {}: {}".format(user_id, e)) 131 | elif op == OPCode.QUARANTINE.value: 132 | # TODO: Implement 133 | self.publish_error(reply_to, "Not implemented") 134 | elif op == OPCode.RESET_API_KEY.value: 135 | user_id = d.get("user_id") 136 | with self.app.app_context(): 137 | user = libraries.User.get(db, user_id) 138 | if not user: 139 | self.publish_error(reply_to, "User not found") 140 | return 141 | try: 142 | user.reset_api_key() 143 | self.publish_response(reply_to, "API Key has been reset for user {}".format(user.username)) 144 | except Exception as e: 145 | self.publish_error(reply_to, "Error resetting API Key for {}: {}".format(user.username, str(e))) 146 | else: 147 | self.publish_error(reply_to, "Unknown OPCode {}".format(op)) 148 | except json.JSONDecodeError: 149 | self.publish_error(reply_to, "Invalid JSON") 150 | -------------------------------------------------------------------------------- /getwvkeys/scripts.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from pathlib import Path 4 | from typing import List 5 | 6 | import click 7 | from pywidevine import Device, DeviceTypes 8 | from sqlalchemy import create_engine, text 9 | from sqlalchemy.orm import Session 10 | 11 | from getwvkeys import config 12 | from getwvkeys.models.Device import generate_device_code 13 | from getwvkeys.utils import get_blob_id 14 | 15 | engine = create_engine(config.SQLALCHEMY_DATABASE_URI) 16 | session = Session(engine) 17 | 18 | 19 | @click.group() 20 | def cli(): 21 | pass 22 | 23 | 24 | # seed system devices, requires migration f194cc3e699f 25 | @cli.command() 26 | @click.argument("folder", type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True)) 27 | def seed_devices(folder: click.Path): 28 | cfg_out = [] 29 | 30 | system_user = session.execute(text("SELECT * FROM users WHERE id = '0000000000000000000';")).first() 31 | if system_user is None: 32 | raise Exception("Cannot find system user, please run the alembic migration f194cc3e699f") 33 | else: 34 | print("Found system user") 35 | 36 | # check for a manifest in the folder 37 | manifest_path = Path(folder) / "manifest.json" 38 | if not manifest_path.exists(): 39 | print("ERR: Missing manifest.json") 40 | exit(1) 41 | 42 | # load the manifest 43 | with open(manifest_path, "r") as f: 44 | manifest: List[List[str]] = json.load(f) 45 | for device in manifest: 46 | client_id = device[0] 47 | private_key = device[1] 48 | 49 | client_id_blob = open(Path(folder) / client_id, "rb").read() 50 | device_private_key = open(Path(folder) / private_key, "r").read() 51 | 52 | client_id_b64 = base64.b64encode(client_id_blob).decode() 53 | private_key_b64 = base64.b64encode(device_private_key.encode()).decode() 54 | 55 | code = generate_device_code(client_id_b64, private_key_b64) 56 | 57 | # check if device is already in the database 58 | device_exists = session.execute(text("SELECT * FROM devices WHERE code = :code;"), {"code": code}).first() 59 | if device_exists is not None: 60 | print(f"Device {code} already exists") 61 | cfg_out.append(code) 62 | else: 63 | info = get_blob_id(client_id_b64) 64 | wvd = Device( 65 | type_=DeviceTypes.ANDROID, 66 | security_level=3, # TODO: let user specify? 67 | flags=None, 68 | private_key=device_private_key, 69 | client_id=client_id_blob, 70 | ) 71 | 72 | # insert 73 | result = session.execute( 74 | text( 75 | "INSERT INTO devices (code, wvd, uploaded_by, info) VALUES (:code, :wvd, :uploaded_by, :info);" 76 | ), 77 | { 78 | "code": code, 79 | "wvd": base64.b64encode(wvd.dumps()).decode(), 80 | "uploaded_by": system_user.id, 81 | "info": info, 82 | }, 83 | ) 84 | 85 | if result.rowcount == 1: 86 | print(f"Device {code} created") 87 | cfg_out.append(code) 88 | else: 89 | print(f"ERR: Failed to create device {code}") 90 | 91 | session.commit() 92 | 93 | # close 94 | session.close() 95 | engine.dispose() 96 | 97 | # print json array 98 | print(json.dumps(cfg_out, indent=4)) 99 | 100 | 101 | def main(): 102 | cli() 103 | -------------------------------------------------------------------------------- /getwvkeys/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetWVKeys/getwvkeys/54bbf73f87737fc350491720b56362eedeb5e5ed/getwvkeys/static/favicon.ico -------------------------------------------------------------------------------- /getwvkeys/static/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | * Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published 7 | * by the Free Software Foundation, version 3 of the License. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | ::-moz-selection { 19 | color: #fffffe; 20 | background-color: #7f5af0; 21 | } 22 | 23 | ::selection { 24 | color: #fffffe; 25 | background-color: #7f5af0; 26 | } 27 | 28 | *, 29 | *::before, 30 | *::after { 31 | box-sizing: border-box; 32 | } 33 | * { 34 | margin: 0; 35 | } 36 | html, 37 | body { 38 | height: 100%; 39 | font-size: 16px; 40 | } 41 | body { 42 | display: grid; 43 | grid-template-rows: auto 1fr auto; 44 | background-color: #16161a; 45 | color: #fffffe; 46 | font-family: Verdana, Geneva, Tahoma, sans-serif; 47 | -webkit-font-smoothing: antialiased; 48 | } 49 | img, 50 | picture, 51 | video, 52 | canvas, 53 | svg { 54 | display: block; 55 | max-width: 100%; 56 | } 57 | input, 58 | button, 59 | textarea, 60 | select { 61 | font: inherit; 62 | } 63 | 64 | input:focus, 65 | textarea:focus { 66 | outline: 2px solid #7f5af0; 67 | } 68 | p, 69 | h1, 70 | h2, 71 | h3, 72 | h4, 73 | h5, 74 | h6 { 75 | overflow-wrap: break-word; 76 | } 77 | 78 | a, 79 | a:visited { 80 | color: skyblue; 81 | } 82 | 83 | .custom-anchor > a { 84 | text-decoration: none; 85 | color: #fffffe; 86 | } 87 | 88 | .custom-anchor > a:focus { 89 | outline: 2px solid #7f5af0; 90 | outline-offset: 4px; 91 | } 92 | 93 | nav { 94 | background-color: #36333c; 95 | } 96 | 97 | main { 98 | margin-top: 36px; 99 | } 100 | 101 | pre.codeblock { 102 | background-color: #242629; 103 | padding: 5px; 104 | overflow-y: scroll; 105 | margin-left: 30px; 106 | } 107 | 108 | .section-title { 109 | text-align: center; 110 | } 111 | .nav-list { 112 | padding: 24px 36px; 113 | max-width: 1920px; 114 | margin: 0 auto; 115 | display: flex; 116 | align-items: center; 117 | justify-content: space-around; 118 | } 119 | .nav-item { 120 | font-weight: 600; 121 | font-size: 1.125rem; 122 | margin-right: 24px; 123 | list-style-type: none; 124 | border-bottom: 1px solid transparent; 125 | } 126 | .nav-item:hover { 127 | border-bottom: 1px solid #fffffe; 128 | } 129 | 130 | .form-container { 131 | display: flex; 132 | margin: 64px 36px 0 36px; 133 | } 134 | .form-container > form { 135 | display: flex; 136 | flex-grow: 1; 137 | flex-direction: column; 138 | margin-left: auto; 139 | margin-right: auto; 140 | max-width: 1080px; 141 | } 142 | 143 | .form-item { 144 | display: flex; 145 | align-items: baseline; 146 | } 147 | 148 | .form-item-items { 149 | display: flex; 150 | flex-direction: column; 151 | margin-left: 24px; 152 | flex: 1; 153 | width: 100%; 154 | } 155 | 156 | .form-item-items > label { 157 | color: gray; 158 | font-size: 0.875rem; 159 | } 160 | 161 | .form-item:not(:first-child) { 162 | margin-top: 16px; 163 | } 164 | 165 | .form-item > span { 166 | min-width: 120px; 167 | font-weight: 600; 168 | } 169 | 170 | input[type="text"] { 171 | border-radius: 4px; 172 | border: 0; 173 | padding: 12px 16px; 174 | color: #fffffe; 175 | background-color: #242629; 176 | width: 100%; 177 | } 178 | 179 | input[type="password"] { 180 | border-radius: 4px; 181 | border: 0; 182 | padding: 12px 16px; 183 | color: #fffffe; 184 | background-color: #242629; 185 | width: 100%; 186 | } 187 | 188 | textarea { 189 | border-radius: 4px; 190 | border: 0; 191 | padding: 12px 16px; 192 | color: #fffffe; 193 | background-color: #242629; 194 | width: 100%; 195 | } 196 | 197 | input[type="checkbox"] { 198 | margin-right: auto; 199 | width: 16px; 200 | height: 16px; 201 | } 202 | .form-item-items > label[for="cache"], 203 | .form-item > label[for="cache"] { 204 | min-width: 0; 205 | } 206 | 207 | input[type="submit"] { 208 | border-radius: 4px; 209 | color: #fffffe; 210 | font-size: 1.125rem; 211 | background-color: #7f5af0; 212 | border: 0; 213 | margin-top: 24px; 214 | padding: 12px 48px; 215 | margin-left: auto; 216 | margin-right: auto; 217 | transition: transform 0.2s ease-in; 218 | } 219 | input[type="submit"]:disabled { 220 | background-color: #6146b1; 221 | } 222 | input[type="submit"]:hover { 223 | transform: scale(1.02); 224 | cursor: pointer; 225 | background-color: #6146b1; 226 | } 227 | input[type="submit"]:focus { 228 | transform: scale(1.05); 229 | outline: 1px solid rgb(255, 255, 255); 230 | outline-offset: 2px; 231 | } 232 | 233 | .info-container { 234 | font-size: 1rem; 235 | margin-top: 24px; 236 | max-width: 1080px; 237 | margin-left: auto; 238 | margin-right: auto; 239 | padding-bottom: 10px; 240 | } 241 | 242 | .result-container { 243 | display: block; 244 | color: #dde4ee; 245 | margin: 24px 0; 246 | border-radius: 16px; 247 | border: 2px solid #242629; 248 | min-height: 240px; 249 | overflow: auto; 250 | padding: 16px; 251 | font-size: 1.125rem; 252 | overflow-wrap: break-word; 253 | word-break: break-all; 254 | } 255 | .result-container > * { 256 | margin-top: 12px; 257 | white-space: normal; 258 | } 259 | .donation-container > table { 260 | margin: 24px auto; 261 | } 262 | .donation-container td { 263 | color: #94a1b2; 264 | } 265 | .tr:not(:first-child) { 266 | margin-top: 8px; 267 | } 268 | .requests-container { 269 | word-break: break-all; 270 | margin: 64px 16px 64px 8px; 271 | } 272 | .api-request { 273 | margin: 0 auto 0 auto; 274 | max-width: 1440px; 275 | } 276 | .request-item > h2 { 277 | color: #fffffe; 278 | } 279 | .request-item > p { 280 | font-size: 1rem; 281 | font-family: monospace; 282 | color: #94a1b2; 283 | margin: 24px 0; 284 | } 285 | 286 | .faq, 287 | .scripts, 288 | .upload { 289 | margin: 0 auto 0 auto; 290 | max-width: 1080px; 291 | } 292 | .questions-container { 293 | margin-top: 64px; 294 | } 295 | details { 296 | background: #242629; 297 | margin-bottom: 12px; 298 | padding: 12px 16px; 299 | } 300 | details > div { 301 | font-size: 1rem; 302 | margin-top: 12px; 303 | font-style: italic; 304 | color: #b4b4b4; 305 | } 306 | 307 | summary { 308 | font-size: 1.125rem; 309 | cursor: pointer; 310 | } 311 | 312 | summary > * { 313 | display: inline; 314 | } 315 | 316 | .scripts-list { 317 | margin-top: 64px; 318 | } 319 | .upload-container { 320 | display: flex; 321 | flex-direction: column; 322 | align-items: center; 323 | justify-content: center; 324 | } 325 | .upload-error-message { 326 | color: #d43139; 327 | font-size: 1.125rem; 328 | font-style: italic; 329 | margin-top: 24px; 330 | } 331 | .upload-container > form { 332 | margin-top: 64px; 333 | display: flex; 334 | flex-direction: column; 335 | } 336 | .upload-form-item:not(:first-child) { 337 | margin-top: 36px; 338 | } 339 | .upload-form-item { 340 | display: flex; 341 | justify-content: space-between; 342 | } 343 | .upload-form-item > input { 344 | margin-left: 16px; 345 | } 346 | 347 | .upload-complete-wrapper { 348 | display: flex; 349 | flex-direction: column; 350 | align-items: center; 351 | gap: 20px; 352 | } 353 | .upload-complete-img { 354 | max-width: 50%; 355 | padding: 10px; 356 | box-shadow: 0 8px 12px rgb(0, 0, 0); 357 | } 358 | .upload-complete-code { 359 | font-size: 1.125rem; 360 | background-color: #242629; 361 | padding: 5px; 362 | } 363 | 364 | footer { 365 | background-color: #242629; 366 | padding: 16px; 367 | margin-top: 20px; 368 | } 369 | footer > p { 370 | text-align: center; 371 | } 372 | .login-container { 373 | display: flex; 374 | justify-content: center; 375 | } 376 | 377 | .login-info { 378 | display: flex; 379 | align-items: center; 380 | flex-direction: column; 381 | padding: 32px 0; 382 | } 383 | 384 | .login-info > p { 385 | font-size: 12px; 386 | } 387 | 388 | .flashes { 389 | position: fixed; 390 | right: 0; 391 | padding: 16px; 392 | } 393 | 394 | .flash { 395 | padding: 16px; 396 | } 397 | 398 | .flash-info { 399 | color: #00529b; 400 | background-color: #bde5f8; 401 | } 402 | .flash-success { 403 | color: #4f8a10; 404 | background-color: #dff2bf; 405 | } 406 | 407 | .flash-warning { 408 | color: #9f6000; 409 | background-color: #feefb3; 410 | } 411 | 412 | .flash-error { 413 | color: #d8000c; 414 | background-color: #ffd2d2; 415 | } 416 | 417 | input[name="login"] { 418 | border-radius: 4px; 419 | color: #fffffe; 420 | font-size: 1.125rem; 421 | background-color: #5865f2; 422 | border: 0; 423 | margin-top: 24px; 424 | padding: 12px 48px; 425 | margin-left: auto; 426 | margin-right: auto; 427 | transition: transform 0.2s ease-in; 428 | } 429 | input[name="login"]:disabled { 430 | background-color: #6146b1; 431 | } 432 | input[name="login"]:hover { 433 | transform: scale(1.02); 434 | cursor: pointer; 435 | background-color: #4752c4; 436 | } 437 | input[name="login"]:focus { 438 | transform: scale(1.05); 439 | background-color: #3c45a5; 440 | outline: none; 441 | } 442 | .avatar { 443 | border-radius: 50%; 444 | } 445 | .dropdown { 446 | position: relative; 447 | display: inline-block; 448 | } 449 | .dropdown-item { 450 | display: flex; 451 | flex-direction: row; 452 | align-items: center; 453 | } 454 | .dropdown-label { 455 | display: flex; 456 | flex-direction: row; 457 | align-items: center; 458 | margin: 0 16px; 459 | } 460 | .dropdown-label-text { 461 | margin: 0 6px 0 0; 462 | } 463 | .dropdown-menu { 464 | display: none; 465 | position: absolute; 466 | background-color: #36333c; 467 | min-width: 160px; 468 | z-index: 1; 469 | box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.4); 470 | -webkit-box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.4); 471 | -moz-box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.4); 472 | } 473 | .dropdown-menu a { 474 | padding: 12px 16px; 475 | text-decoration: none; 476 | display: block; 477 | } 478 | .dropdown-menu a:hover { 479 | background-color: #5e5b63; 480 | } 481 | .dropdown-menu a:focus { 482 | background-color: #4a474f; 483 | } 484 | .dropdown-menu hr { 485 | color: #302d36; 486 | } 487 | .dropdown:hover .dropdown-menu { 488 | display: block; 489 | } 490 | .dropdown:hover div { 491 | cursor: default; 492 | } 493 | .profile-container { 494 | display: flex; 495 | flex: 1; 496 | flex-direction: column; 497 | margin-left: auto; 498 | margin-right: auto; 499 | max-width: 1080px; 500 | } 501 | .flex-column { 502 | display: flex; 503 | flex-direction: column; 504 | } 505 | .flex-row { 506 | display: flex; 507 | flex-direction: row; 508 | } 509 | .apikey-container, 510 | .devices-container { 511 | display: flex; 512 | flex: 1; 513 | align-items: center; 514 | } 515 | .apikey-container.form-item > .form-item-items { 516 | display: flex; 517 | flex-direction: column; 518 | flex: 1; 519 | margin: 0; 520 | } 521 | .apikey-container.form-item > span { 522 | text-align: center; 523 | font-size: 1.3rem; 524 | margin: 0 0 16px 0; 525 | } 526 | .apikey-container.form-item > .form-item-items > input { 527 | text-align: center; 528 | } 529 | .devices-container.form-item > .form-item-items { 530 | display: flex; 531 | flex-direction: column; 532 | flex: 1; 533 | margin: 0; 534 | align-items: center; 535 | } 536 | .devices-container > span { 537 | text-align: center; 538 | font-size: 1.3rem; 539 | margin-bottom: 12px; 540 | } 541 | .devices-list-item { 542 | padding-bottom: 16px; 543 | } 544 | .device-item { 545 | min-width: 50rem; 546 | display: flex; 547 | background-color: #36333c; 548 | padding: 5px; 549 | align-items: center; 550 | justify-content: space-between; 551 | } 552 | .section-divider { 553 | margin: 32px 0 12px 0; 554 | } 555 | ul { 556 | list-style-type: none; 557 | margin: 0; 558 | padding: 0; 559 | } 560 | .container-center { 561 | align-items: center; 562 | display: flex; 563 | } 564 | .profile-info { 565 | text-align: center; 566 | } 567 | 568 | button[type="disable"] { 569 | border-radius: 4px; 570 | color: #fffffe; 571 | font-size: 1.125rem; 572 | background-color: #dc3545; 573 | border: 0; 574 | margin-top: 24px; 575 | padding: 12px 48px; 576 | margin-left: auto; 577 | margin-right: auto; 578 | transition: transform 0.2s ease-in; 579 | } 580 | 581 | button[type="disable"]:disabled { 582 | background-color: #dc3545; 583 | } 584 | 585 | button[type="disable"]:hover { 586 | transform: scale(1.02); 587 | cursor: pointer; 588 | background-color: #bb2d3b; 589 | } 590 | 591 | button[type="disable"]:focus { 592 | border-color: #b02a37; 593 | box-shadow: rgb(225, 83, 97) 0 0 0 0.25rem; 594 | } 595 | 596 | button[type="enable"] { 597 | border-radius: 4px; 598 | color: #fffffe; 599 | font-size: 1.125rem; 600 | background-color: #198754; 601 | border: 0; 602 | margin-top: 24px; 603 | padding: 12px 48px; 604 | margin-left: auto; 605 | margin-right: auto; 606 | transition: transform 0.2s ease-in; 607 | } 608 | 609 | button[type="enable"]:disabled { 610 | background-color: #dc3545; 611 | } 612 | 613 | button[type="enable"]:hover { 614 | transform: scale(1.02); 615 | cursor: pointer; 616 | background-color: #157347; 617 | } 618 | 619 | button[type="enable"]:focus { 620 | border-color: #146c43; 621 | box-shadow: rgb(60, 153, 110) 0 0 0 0.25rem; 622 | } 623 | .error-wrapper { 624 | display: flex; 625 | flex-direction: column; 626 | align-items: center; 627 | } 628 | .error-title { 629 | margin: 0 0 32px 0; 630 | } 631 | .delete-button { 632 | border-radius: 4px; 633 | color: #fffffe; 634 | font-size: 1.125rem; 635 | background-color: #dc3545; 636 | border: 0; 637 | transition: transform 0.2s ease-in; 638 | margin: 0 16px; 639 | } 640 | 641 | .delete-button:hover { 642 | transform: scale(1.02); 643 | cursor: pointer; 644 | background-color: #bb2d3b; 645 | } 646 | 647 | .delete-button:active { 648 | border-color: #b02a37; 649 | box-shadow: rgb(225, 83, 97) 0 0 0 0.15rem; 650 | } 651 | 652 | #keycount { 653 | display: flex; 654 | padding: 0 0 0 5px; 655 | } 656 | 657 | .keycount-wrapper { 658 | display: flex; 659 | justify-content: center; 660 | } 661 | 662 | code { 663 | background-color: #242629; 664 | padding: 0 5px; 665 | } 666 | 667 | #devices { 668 | display: grid; 669 | gap: 10px; 670 | } 671 | -------------------------------------------------------------------------------- /getwvkeys/static/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | * Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published 7 | * by the Free Software Foundation, version 3 of the License. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | function handleFormSubmit(event) { 19 | event.preventDefault(); 20 | formButton.disabled = true; 21 | formButton.value = "Sending..."; 22 | doRequest(); 23 | } 24 | 25 | function getCookie(name) { 26 | const value = `; ${document.cookie}`; 27 | const parts = value.split(`; ${name}=`); 28 | if (parts.length === 2) return parts.pop().split(";").shift(); 29 | } 30 | 31 | async function doRequest() { 32 | async function doPost() { 33 | document.getElementById("container-text").innerHTML = "Sending Request..."; 34 | const apiKey = getCookie("api_key"); 35 | const response = await fetch("/wv", { 36 | method: "POST", 37 | headers: { 38 | Accept: "application/json, text/plain, */*", 39 | "Content-Type": "application/json", 40 | "X-API-Key": apiKey, 41 | }, 42 | body: JSON.stringify({ 43 | license_url: document.getElementById("license").value, 44 | headers: document.getElementById("headers").value, 45 | pssh: document.getElementById("pssh").value, 46 | device_code: document.getElementById("deviceCode").value, 47 | proxy: document.getElementById("proxy").value, 48 | force: document.getElementById("force").checked, 49 | }), 50 | }); 51 | return await response.text(); 52 | } 53 | const response = await doPost(); 54 | const elem = document.getElementById("container-text"); 55 | setInnerHTML(elem, response); 56 | 57 | formButton.disabled = false; 58 | formButton.value = "Send"; 59 | } 60 | 61 | const setInnerHTML = function (elm, html) { 62 | elm.innerHTML = html; 63 | Array.from(elm.querySelectorAll("script")).forEach((oldScript) => { 64 | const newScript = document.createElement("script"); 65 | Array.from(oldScript.attributes).forEach((attr) => newScript.setAttribute(attr.name, attr.value)); 66 | newScript.appendChild(document.createTextNode(oldScript.innerHTML)); 67 | oldScript.parentNode.replaceChild(newScript, oldScript); 68 | }); 69 | }; 70 | 71 | function parseUnixTimestamp(timestamp) { 72 | const date = new Date(timestamp * 1000); 73 | return date.toLocaleString(); 74 | } 75 | 76 | function deleteDevice(code) { 77 | const isConfirmed = confirm("Are you sure you want to delete this device?"); 78 | if (!isConfirmed) return; 79 | const apiKey = getCookie("api_key"); 80 | fetch(`/me/devices/${code}`, { 81 | method: "DELETE", 82 | headers: { 83 | Accept: "application/json, text/plain, */*", 84 | "Content-Type": "application/json", 85 | "X-API-Key": apiKey, 86 | }, 87 | }) 88 | .catch((e) => { 89 | alert(`Error deleting device: ${e}`); 90 | }) 91 | .then(async (r) => { 92 | const text = await r.json(); 93 | if (!r.ok) alert(`An error occurred: ${text.message}`); 94 | else { 95 | alert(text.message); 96 | location.reload(); 97 | } 98 | }); 99 | } 100 | 101 | // --------- Main --------- // 102 | 103 | const DEFAULT_USER_AGENT = 104 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"; 105 | const mainForm = document.querySelector(".form-container>form"); 106 | const formButton = mainForm.querySelector('input[type="submit"]'); 107 | 108 | mainForm.addEventListener("submit", handleFormSubmit); 109 | 110 | // wait until page is loaded 111 | document.addEventListener("DOMContentLoaded", () => { 112 | const headersBox = document.getElementById("headers"); 113 | headersBox.innerText = `User-Agent: ${navigator.userAgent ?? DEFAULT_USER_AGENT}`; 114 | }); 115 | -------------------------------------------------------------------------------- /getwvkeys/static/search.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | * Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published 7 | * by the Free Software Foundation, version 3 of the License. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU Affero General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU Affero General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | function getCookie(name) { 19 | const value = `; ${document.cookie}`; 20 | const parts = value.split(`; ${name}=`); 21 | if (parts.length === 2) return parts.pop().split(";").shift(); 22 | } 23 | 24 | function handleFormSubmit(event) { 25 | event.preventDefault(); 26 | formButton.disabled = true; 27 | formButton.value = "Sending..."; 28 | server_request(); 29 | } 30 | 31 | async function server_request() { 32 | async function server_request_data() { 33 | document.getElementById("container-text").innerHTML = "Searching the database"; 34 | 35 | const apiKey = getCookie("api_key"); 36 | const response = await fetch("/search", { 37 | method: "POST", 38 | headers: { 39 | "Content-Type": "application/x-www-form-urlencoded", 40 | "X-API-Key": apiKey, 41 | }, 42 | body: document.getElementById("pssh").value, 43 | }); 44 | return await response.json(); 45 | } 46 | const response = await server_request_data(); 47 | setInnerHTML(document.getElementById("container-text"), response); 48 | 49 | formButton.disabled = false; 50 | formButton.value = "Send"; 51 | } 52 | 53 | const setInnerHTML = function (elm, data) { 54 | let html = `

Cached Key

55 |

KID: ${data["kid"]}

56 | `; 57 | if (data["keys"] && data["keys"].length == 0) { 58 | html += `

No keys found

`; 59 | } else { 60 | html += data["keys"] 61 | .map( 62 | (key) => `
    63 |
  1. 64 |
      65 |
    • Key: ${key["key"]}
    • 66 |
    • License URL: ${key["license_url"]}
    • 67 |
    • 68 | Added At: ${key["added_at"]} 69 |
    • 70 |
    71 |
  2. 72 |
` 73 | ) 74 | .join(); 75 | } 76 | html += ` 77 | `; 84 | elm.innerHTML = html; 85 | Array.from(elm.querySelectorAll("script")).forEach((oldScript) => { 86 | const newScript = document.createElement("script"); 87 | Array.from(oldScript.attributes).forEach((attr) => newScript.setAttribute(attr.name, attr.value)); 88 | newScript.appendChild(document.createTextNode(oldScript.innerHTML)); 89 | oldScript.parentNode.replaceChild(newScript, oldScript); 90 | }); 91 | }; 92 | 93 | function parseUnixTimestamp(timestamp) { 94 | const date = new Date(timestamp * 1000); 95 | return date.toLocaleString(); 96 | } 97 | 98 | const mainForm = document.querySelector(".form-container>form"); 99 | const formButton = mainForm.querySelector('input[type="submit"]'); 100 | 101 | mainForm.addEventListener("submit", handleFormSubmit); 102 | -------------------------------------------------------------------------------- /getwvkeys/templates/admin_user.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Admin - View User{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Admin - View User

26 |
27 |
28 |
29 | Avatar 36 |
37 |

{{user.username}}#{{user.discriminator}}

38 |
39 | ID: 40 | {{user.id}} 41 |
42 |
43 | Status: 44 | {{"SUSPENDED" if user.status == 1 else "ACTIVE"}} 45 |
46 |
47 | Admin: 48 | {{"Yes" if user.is_admin == 1 else "No"}} 49 |
50 | 53 |
54 |
55 |
56 |
57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /getwvkeys/templates/api.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}API Request{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Send API Request

26 |
27 |
28 |
29 |

Curl

30 |
curl -L -X POST "https://getwvkeys.cc/api" \
31 | -H "X-API-Key: {{current_user.api_key if current_user.api_key else "YOUR API KEY HERE" }}" \
32 | -H "Content-Type: application/json" \
33 | -d "{
34 |     \"license_url\": \"https://cwip-shaka-proxy.appspot.com/no_auth\",
35 |     \"pssh\": \"AAAAp3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAIcSEFF0U4YtQlb9i61PWEIgBNcSEPCTfpp3yFXwptQ4ZMXZ82USEE1LDKJawVjwucGYPFF+4rUSEJAqBRprNlaurBkm/A9dkjISECZHD0KW1F0Eqbq7RC4WmAAaDXdpZGV2aW5lX3Rlc3QiFnNoYWthX2NlYzViZmY1ZGM0MGRkYzlI49yVmwY=\",
36 |     \"cache\": false
37 | }"
38 |
39 |
40 |

Python

41 |
import requests
42 | 
43 | api_url = "https://getwvkeys.cc/api"
44 | license_url = "https://cwip-shaka-proxy.appspot.com/no_auth"
45 | pssh = "AAAAp3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAIcSEFF0U4YtQlb9i61PWEIgBNcSEPCTfpp3yFXwptQ4ZMXZ82USEE1LDKJawVjwucGYPFF+4rUSEJAqBRprNlaurBkm/A9dkjISECZHD0KW1F0Eqbq7RC4WmAAaDXdpZGV2aW5lX3Rlc3QiFnNoYWthX2NlYzViZmY1ZGM0MGRkYzlI49yVmwY="
46 | headers = {
47 |     "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (Ktesttemp, like Gecko) Chrome/90.0.4430.85 Safari/537.36",
48 |     "Content-Type": "application/json",
49 |     "X-API-Key": '{{ current_user.api_key if current_user.api_key else "YOUR API KEY HERE" }}',
50 | }
51 | payload = {
52 |     "license_url": license_url,
53 |     "pssh": pssh,
54 | }
55 | r = requests.post(api_url, headers=headers, json=payload).text
56 | print(r)
57 |
58 |
59 |

JSON Payload

60 |
{
61 |   "license_url": "https://cwip-shaka-proxy.appspot.com/no_auth",
62 |   "pssh": "AAAAp3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAIcSEFF0U4YtQlb9i61PWEIgBNcSEPCTfpp3yFXwptQ4ZMXZ82USEE1LDKJawVjwucGYPFF+4rUSEJAqBRprNlaurBkm/A9dkjISECZHD0KW1F0Eqbq7RC4WmAAaDXdpZGV2aW5lX3Rlc3QiFnNoYWthX2NlYzViZmY1ZGM0MGRkYzlI49yVmwY=",
63 |   "proxy": "",
64 |   "device_code": "",
65 |   "cache": true
66 | }
67 |
68 |
69 |

JSON Response

70 |
{
71 |   "added_at": 1656267215,
72 |   "keys": [
73 |       "902a051a6b3656aeac1926fc0f5d9232:7a13acb8d7cf12fdfaecea19c96edc4e",
74 |       "4d4b0ca25ac158f0b9c1983c517ee2b5:41954cca5a98b00ad05db8bbf2330fee",
75 |       "f0937e9a77c855f0a6d43864c5d9f365:d041141f4e556079a89c685a6ca50f4a",
76 |       "517453862d4256fd8bad4f58422004d7:5b4b848eac0855d79165d05f3cf16d56",
77 |       "26470f4296d45d04a9babb442e169800:a38e1ade8a367b7f6fd0427126902c19"
78 |   ],
79 |   "kid": "517453862d4256fd8bad4f58422004d7",
80 |   "license_url": "https://cwip-shaka-proxy.appspot.com/no_auth"
81 | }
82 |
83 |
84 |
85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /getwvkeys/templates/base.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% block title %}{% endblock %} - GetWVKeys 25 | 26 | 33 | 34 | 35 | {% include 'partials/nav.html' %} 36 | 37 | 38 | 45 | 46 |
{% block content %}{% endblock %}
47 |
{% include 'partials/footer.html' %}
48 | 49 | {% block js %} {% endblock %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /getwvkeys/templates/cache.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 |

Cached Key

22 |

KID: {{results['kid']}}

23 | {% if results['keys']|length == 0 %} 24 |

No keys found

25 | {% else %} 26 |
    27 | {%for key in results['keys']%} 28 |
  1. 29 |
      30 |
    • Key: {{key['key']}}
    • 31 |
    • License URL: {{key['license_url']}}
    • 32 |
    • Added At: {{key['added_at']}}
    • 33 |
    34 |
  2. 35 | {%endfor%} 36 |
37 | {% endif %} 38 | 39 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /getwvkeys/templates/error.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Error{% endblock %} 21 | 22 | {% block content %} 23 |
24 |

An error has occurred

25 |

{{title}}

26 |

{{details}}

27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /getwvkeys/templates/faq.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}FAQ{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Frequently Asked Questions

26 |
27 |
28 |
29 | What is this website? 30 |

Workaround to get widevine content keys

31 |
32 |
33 | What is PSSH? 34 |

Protection Scheme Specific Header (PSSH)

35 |
36 |
37 | Is it L1? 38 |
39 |

No it is Generic L3 key, system ID: [removed] to be precise

40 |
41 |
42 |
43 | What does 'Error headers' means? 44 |
45 |

46 | Your headers are not in YAML or JSON Format. For YAML, Validate your headers here: 47 | http://yaml-online-parser.appspot.com 50 |

51 |
52 |
53 |
54 | What does 'Wrong PSSH' means? 55 |
56 |

57 | Your PSSH is wrong. Validate your PSSH first on 58 | https://integration.widevine.com/diagnostics 61 | under "View Widevine PSSH" 62 |

63 |
64 |
65 |
66 | What to do when there is no PSSH in the mpd? 67 |
68 |

69 | Get KID from ISM/MPD/Media then make a PSSH from the KID using 70 | https://integration.widevine.com/diagnostics 73 | under "Generate widevine PSSH". Also read: 74 | 78 |

79 |
80 |
81 |
82 | Need Help? 83 |
84 |

85 | Join our discord server: 86 | https://discord.gg/ezK22qJFR8 87 |

88 |
89 |
90 |
91 | Can it support x site? How can I request to add a new site? 92 |
93 |

I do not take requests thank you.

94 |
95 |
96 |
97 | How can I get PSSH from ISM manifest with ProtectionHeader? 98 | 107 |
108 |
109 | Why do I need to login all of the sudden? 110 |
111 |

Too much abuse

112 |
113 |
114 |
115 | How do I use my API Key? 116 |
117 |

X-API-Key header

118 |
119 |
120 |
121 | Why? 122 |
123 |

Leaked

124 |
125 |
126 |
127 |
128 | {% endblock %} 129 | -------------------------------------------------------------------------------- /getwvkeys/templates/index.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Home{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Send Widevine Request

26 |
27 |
28 |
29 |
30 | PSSH: 31 |
32 | 39 |
40 |
41 |
42 | License URL: 43 |
44 | 51 |
52 |
53 |
54 | Headers: 55 |
56 | 57 | 58 |
59 |
60 |
61 | Device Code: 62 |
63 | 64 | 67 |
68 |
69 |
70 | Proxy: 71 |
72 | 73 | 74 |
75 |
76 |
77 | Force: 78 |
79 | 80 | 81 |
82 |
83 | 84 |
85 |
86 |
87 |
88 | Total key count: 89 | {{keyCount}} 90 |
91 |
92 |
Send a request to see the result.
93 |
94 |
95 |
96 | {% endblock %} 97 | 98 | {% block js %} 99 | 100 | {% endblock %} 101 | -------------------------------------------------------------------------------- /getwvkeys/templates/login.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Login{% endblock %} 21 | 22 | {% block content %} 23 |
24 |

Login to continue

25 |
26 | 29 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /getwvkeys/templates/partials/footer.html: -------------------------------------------------------------------------------- 1 | 17 | 18 |

Copyright (C) 2022-2024 Notaghost9997, Puyodead1 and GetWVKeys Contributors

19 |

Website version: {{ website_version }}

20 | -------------------------------------------------------------------------------- /getwvkeys/templates/partials/nav.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 53 | -------------------------------------------------------------------------------- /getwvkeys/templates/profile.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Profile{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Profile

26 |
27 |
28 |
29 |
30 | API Key: 31 |
32 | 40 |
41 |
42 |
43 |
44 | Your Devices 45 |
46 | {% if devices|length == 0 %} 47 | Nothing here, you can always upload one. 48 | {% else %} 49 |
    50 | {% for device in devices %} 51 |
  • 52 |
    53 |
    54 |
    55 | 56 | {{device.code}} 57 |
    58 | 59 |
    60 | 61 | {{device.info}} 62 |
    63 |
    64 | 67 |
    68 |
  • 69 | {% endfor %} 70 |
71 | {% endif %} 72 |
73 |
74 |
75 |
76 |
77 | {% endblock %} 78 | 79 | {% block js %} 80 | 81 | {% endblock %} 82 | -------------------------------------------------------------------------------- /getwvkeys/templates/scripts.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Scripts{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Scripts

26 |
27 |
28 | 33 |
34 | 35 |
36 | Looking for something else? Try looking in the #scripts channel in our Discord server. 37 |
38 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /getwvkeys/templates/search.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Search{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Send Widevine Request

26 |
27 |
28 |
29 |
30 | PSSH/KID: 31 |
32 | 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 | Total key count: 47 | {{keyCount}} 48 |
49 |
50 |
Send a request to see the result.
51 |
52 |
53 |
54 | {% endblock %} 55 | 56 | {% block js %} 57 | 58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /getwvkeys/templates/success.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 |

SUCCESS

22 |

KID: {{results['kid']}}

23 |

License URL: {{results['license_url']}}

24 |

Acquired At: {{results['added_at']}}

25 |
    26 | {%for key in results['keys']%} 27 |
  1. {{key}}
  2. 28 | {%endfor%} 29 |
30 | 31 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /getwvkeys/templates/upload.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Upload Device Key{% endblock %} 21 | 22 | {% block content %} 23 |
24 |
25 |

Upload your own device to use for requests

26 |
27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 | 42 | 43 |
44 | 45 |
46 | {% if error %} 47 |

{{ error }}

48 | {% endif %} 49 |
50 |
51 |
52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /getwvkeys/templates/upload_complete.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | {% extends "base.html" %} 19 | 20 | {% block title %}Upload Complete{% endblock %} 21 | 22 | {% block content %} 23 |
24 |

{{title_text}}

25 |

Device Code:

26 | {{code}} 27 | Italian Trulli 28 |

29 | You can now use this code with the Device Code field to change the device used for license 30 | requests. 31 |

32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /getwvkeys/user.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import secrets 4 | from typing import Union 5 | 6 | import requests 7 | from flask_login import UserMixin 8 | from flask_sqlalchemy import SQLAlchemy 9 | from sqlalchemy import text 10 | from werkzeug.exceptions import BadRequest, Forbidden, NotFound 11 | 12 | from getwvkeys import config 13 | from getwvkeys.models.APIKey import APIKey as APIKeyModel 14 | from getwvkeys.models.Device import Device 15 | from getwvkeys.models.User import User 16 | from getwvkeys.utils import Bitfield, FlagAction, UserFlags 17 | 18 | logger = logging.getLogger("getwvkeys") 19 | 20 | 21 | class FlaskUser(UserMixin): 22 | def __init__(self, db: SQLAlchemy, user: User): 23 | self.db = db 24 | self.id = user.id 25 | self.username = user.username 26 | self.discriminator = user.discriminator 27 | self.avatar = user.avatar 28 | self.public_flags = user.public_flags 29 | self.api_key = user.api_key 30 | self.flags_raw = user.flags 31 | self.flags = Bitfield(user.flags) 32 | self.user_model = user 33 | 34 | def get_user_devices(self): 35 | return [{"code": x.code, "info": x.info} for x in self.user_model.devices] 36 | 37 | def patch(self, data): 38 | disallowed_keys = ["id", "username", "discriminator", "avatar", "public_flags", "api_key"] 39 | 40 | for key, value in data.items(): 41 | # Skip attributes that cant be changed 42 | if key in disallowed_keys: 43 | logger.warning("{} cannot be updated".format(key)) 44 | continue 45 | # change attribute 46 | setattr(self.user_model, key, value) 47 | # save changes 48 | self.db.session.commit() 49 | # get a new user object 50 | return FlaskUser(self.db, self.user_model) 51 | 52 | def to_json(self, api_key=False): 53 | return { 54 | "id": self.id, 55 | "username": self.username, 56 | "discriminator": self.discriminator, 57 | "avatar": self.avatar, 58 | "public_flags": self.public_flags, 59 | "api_key": self.api_key if api_key else None, 60 | "flags": self.flags_raw, 61 | } 62 | 63 | def update_flags(self, flags: Union[int, Bitfield], action: FlagAction): 64 | # get bits from bitfield if it is one 65 | if isinstance(flags, Bitfield): 66 | flags = flags.bits 67 | 68 | if action == FlagAction.ADD.value: 69 | self.user_model.flags = self.flags.add(flags) 70 | elif action == FlagAction.REMOVE.value: 71 | self.user_model.flags = self.flags.remove(flags) 72 | else: 73 | raise BadRequest("Unknown flag action") 74 | 75 | self.db.session.commit() 76 | return FlaskUser(self.db, self.user_model) 77 | 78 | def reset_api_key(self): 79 | api_key = secrets.token_hex(32) 80 | self.user_model.api_key = api_key 81 | 82 | # check if we already have the key recorded in the history, if not (ex: accounts created before implementation), add it 83 | a = APIKeyModel.query.filter_by(user_id=self.user_model.id, api_key=api_key) 84 | if not a: 85 | history_entry = APIKeyModel(user_id=self.user_model.id, api_key=api_key) 86 | self.db.session.add(history_entry) 87 | 88 | self.db.session.commit() 89 | 90 | def delete_device(self, code) -> str: 91 | # Start a new session 92 | session = self.db.session 93 | 94 | # Query the device by code 95 | device = session.query(Device).filter_by(code=code).first() 96 | if not device: 97 | raise NotFound("Device not found") 98 | 99 | # Check if the device is associated with the user 100 | if device in self.user_model.devices: 101 | association_query = text("DELETE FROM user_device WHERE user_id = :user_id AND device_code = :device_code") 102 | session.execute(association_query, {"user_id": self.user_model.id, "device_code": device.code}) 103 | session.commit() 104 | 105 | # Check if the device is still associated with any other users 106 | count_query = text("SELECT COUNT(*) FROM user_device WHERE device_code = :device_code") 107 | device_users_count = session.execute(count_query, {"device_code": device.code}).scalar() 108 | 109 | if device_users_count == 0: 110 | # If no other users are associated, delete the device 111 | session.delete(device) 112 | session.commit() 113 | return "Device deleted" 114 | else: 115 | return "Device unlinked" 116 | else: 117 | raise NotFound("You do not have this device associated with your profile") 118 | 119 | @staticmethod 120 | def get(db: SQLAlchemy, user_id: str): 121 | user = User.query.filter_by(id=user_id).first() 122 | if not user: 123 | return None 124 | 125 | return FlaskUser(db, user) 126 | 127 | @staticmethod 128 | def create(db: SQLAlchemy, userinfo: dict): 129 | api_key = secrets.token_hex(32) 130 | user = User( 131 | id=userinfo.get("id"), 132 | username=userinfo.get("username"), 133 | discriminator=userinfo.get("discriminator"), 134 | avatar=userinfo.get("avatar"), 135 | public_flags=userinfo.get("public_flags"), 136 | api_key=api_key, 137 | ) 138 | history_entry = APIKeyModel(user_id=user.id, api_key=api_key) 139 | db.session.add(history_entry) 140 | db.session.add(user) 141 | db.session.commit() 142 | 143 | @staticmethod 144 | def update(db: SQLAlchemy, userinfo: dict): 145 | user = User.query.filter_by(id=userinfo.get("id")).first() 146 | if not user: 147 | return None 148 | 149 | user.username = userinfo.get("username") 150 | user.discriminator = userinfo.get("discriminator") 151 | user.avatar = userinfo.get("avatar") 152 | user.public_flags = userinfo.get("public_flags") 153 | db.session.commit() 154 | 155 | @staticmethod 156 | def user_is_in_guild(token): 157 | url = "https://discord.com/api/users/@me/guilds" 158 | headers = {"Authorization": f"Bearer {token}"} 159 | r = requests.get(url, headers=headers) 160 | if not r.ok: 161 | raise Exception(f"Failed to get user guilds: [{r.status_code}] {r.text}") 162 | guilds = r.json() 163 | is_in_guild = any(guild.get("id") == config.GUILD_ID for guild in guilds) 164 | return is_in_guild 165 | 166 | @staticmethod 167 | def user_is_verified(token): 168 | url = f"https://discord.com/api/users/@me/guilds/{config.GUILD_ID}/member" 169 | headers = { 170 | "Authorization": f"Bearer {token}", 171 | } 172 | r = requests.get(url, headers=headers) 173 | if not r.ok: 174 | raise Exception(f"Failed to get guild member: [{r.status_code}] {r.text}") 175 | data = r.json() 176 | return any(role == config.VERIFIED_ROLE_ID for role in data.get("roles")) 177 | 178 | @staticmethod 179 | def is_api_key_bot(api_key): 180 | """checks if the api key is from the bot""" 181 | bot_key = base64.b64encode( 182 | "{}:{}".format(config.OAUTH2_CLIENT_ID, config.OAUTH2_CLIENT_SECRET).encode() 183 | ).decode("utf8") 184 | return api_key == bot_key 185 | 186 | @staticmethod 187 | def get_user_by_api_key(db: SQLAlchemy, api_key): 188 | user = User.query.filter_by(api_key=api_key).first() 189 | if not user: 190 | return None 191 | 192 | return FlaskUser(db, user) 193 | 194 | def is_blacklist_exempt(self): 195 | return self.flags.has(UserFlags.BLACKLIST_EXEMPT) 196 | 197 | def check_status(self, ignore_suspended=False): 198 | if self.flags.has(UserFlags.SUSPENDED) == 1 and not ignore_suspended: 199 | raise Forbidden("Your account has been suspended.") 200 | 201 | def has_device(self, device_code: str): 202 | return any(device.code == device_code for device in self.user_model.devices) 203 | 204 | @staticmethod 205 | def is_api_key_valid(db: SQLAlchemy, api_key: str): 206 | # allow the bot to pass 207 | if FlaskUser.is_api_key_bot(api_key): 208 | return True 209 | 210 | user = FlaskUser.get_user_by_api_key(db, api_key) 211 | if not user: 212 | return False 213 | 214 | # if the user is suspended, throw forbidden 215 | user.check_status() 216 | 217 | return True 218 | 219 | @staticmethod 220 | def disable_user(db: SQLAlchemy, user_id: str): 221 | user = User.query.filter_by(id=user_id).first() 222 | if not user: 223 | raise NotFound("User not found") 224 | flags = Bitfield(user.flags) 225 | flags.add(UserFlags.SUSPENDED) 226 | user.flags = flags.bits 227 | db.session.commit() 228 | 229 | @staticmethod 230 | def disable_users(db: SQLAlchemy, user_ids: list): 231 | print("Request to disable {} users: {}".format(len(user_ids), ", ".join([str(x) for x in user_ids]))) 232 | if len(user_ids) == 0: 233 | raise BadRequest("No data to update or update is not allowed") 234 | 235 | for user_id in user_ids: 236 | try: 237 | FlaskUser.disable_user(db, user_id) 238 | except NotFound: 239 | continue 240 | 241 | @staticmethod 242 | def enable_user(db: SQLAlchemy, user_id): 243 | user = User.query.filter_by(id=user_id).first() 244 | if not user: 245 | raise NotFound("User not found") 246 | flags = Bitfield(user.flags) 247 | flags.remove(UserFlags.SUSPENDED) 248 | user.flags = flags.bits 249 | db.session.commit() 250 | 251 | @staticmethod 252 | def get_user_count(): 253 | return User.query.count() 254 | -------------------------------------------------------------------------------- /getwvkeys/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of the GetWVKeys project (https://github.com/GetWVKeys/getwvkeys) 3 | Copyright (C) 2022-2024 Notaghost, Puyodead1 and GetWVKeys contributors 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU Affero General Public License for more details. 13 | 14 | You should have received a copy of the GNU Affero General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import base64 19 | import logging 20 | import logging.handlers 21 | import re 22 | from enum import Enum 23 | from typing import Union 24 | from urllib.parse import urlsplit 25 | 26 | from cerberus import Validator 27 | from coloredlogs import ColoredFormatter 28 | 29 | from getwvkeys import config 30 | from getwvkeys.formats import wv_proto2_pb2 31 | from getwvkeys.models.Key import Key as KeyModel 32 | from getwvkeys.pssh_utils import parse_pssh 33 | 34 | 35 | class OPCode(Enum): 36 | DISABLE_USER = 0 37 | DISABLE_USER_BULK = 1 38 | ENABLE_USER = 2 39 | KEY_COUNT = 3 40 | USER_COUNT = 4 41 | SEARCH = 5 42 | UPDATE_PERMISSIONS = 6 43 | QUARANTINE = 7 44 | REPLY = 8 45 | RESET_API_KEY = 9 46 | 47 | 48 | class UserFlags(Enum): 49 | ADMIN = 1 << 0 50 | BETA_TESTER = 1 << 1 51 | DEVINE = 1 << 2 52 | KEY_ADDING = 1 << 3 53 | SUSPENDED = 1 << 4 54 | BLACKLIST_EXEMPT = 1 << 5 55 | SYSTEM = 1 << 6 56 | 57 | 58 | class FlagAction(Enum): 59 | ADD = "add" 60 | REMOVE = "remove" 61 | 62 | 63 | def construct_logger(): 64 | # ensure parent folders exist 65 | config.WVK_LOG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) 66 | 67 | # setup handlers 68 | # create a colored formatter for the console 69 | console_formatter = ColoredFormatter(config.LOG_FORMAT, datefmt=config.LOG_DATE_FORMAT) 70 | 71 | # create a regular non-colored formatter for the log file 72 | file_formatter = logging.Formatter(config.LOG_FORMAT, datefmt=config.LOG_DATE_FORMAT) 73 | 74 | # create a handler for console logging 75 | stream = logging.StreamHandler() 76 | stream.setLevel(config.CONSOLE_LOG_LEVEL) 77 | stream.setFormatter(console_formatter) 78 | 79 | # create a handler for file logging, 5 mb max size, with 5 backup files 80 | file_handler = logging.handlers.RotatingFileHandler( 81 | config.WVK_LOG_FILE_PATH, maxBytes=(1024 * 1024) * 5, backupCount=5 82 | ) 83 | file_handler.setFormatter(file_formatter) 84 | file_handler.setLevel(config.FILE_LOG_LEVEL) 85 | 86 | # construct the logger 87 | logger = logging.getLogger("getwvkeys") 88 | logger.setLevel(config.CONSOLE_LOG_LEVEL) 89 | logger.addHandler(stream) 90 | logger.addHandler(file_handler) 91 | return logger 92 | 93 | 94 | class CacheBase(object): 95 | def __init__(self, added_at: int, added_by: Union[str, None], license_url: Union[str, None]): 96 | self.added_at = added_at 97 | self.added_by = added_by 98 | self.license_url = license_url 99 | 100 | @staticmethod 101 | def from_dict(d: dict): 102 | (added_at, added_by, license_url) = (d["added_at"], d.get("added_by"), d.get("license_url")) 103 | return CacheBase(added_at, added_by, license_url) 104 | 105 | 106 | class CachedKey(CacheBase): 107 | """ 108 | Represents cached key information that contains a single key 109 | """ 110 | 111 | def __init__(self, kid: str, added_at: int, added_by: Union[str, None], license_url: Union[str, None], key: str): 112 | super().__init__(added_at, added_by, license_url) 113 | self.kid = kid 114 | self.key = key 115 | 116 | @staticmethod 117 | def from_dict(d: dict): 118 | (kid, added_at, license_url, key) = (d["kid"], d["added_at"], d.get("license_url", None), d["key"]) 119 | return CachedKey(kid, added_at, license_url, key) 120 | 121 | def to_json(self): 122 | return {"kid": self.kid, "added_at": self.added_at, "license_url": self.license_url, "key": self.key} 123 | 124 | 125 | def extract_kid_from_pssh(pssh: str): 126 | logger = logging.getLogger("getwvkeys") 127 | try: 128 | parsed_pssh = parse_pssh(pssh) 129 | if len(parsed_pssh.key_ids) == 1: 130 | return parsed_pssh.key_ids[0].hex() 131 | elif len(parsed_pssh.key_ids) > 1: 132 | logger.warning("Multiple key ids found in pssh! {}".format(pssh)) 133 | return parsed_pssh.key_ids[0].hex() 134 | elif len(parsed_pssh.key_ids) == 0: 135 | if len(parsed_pssh.data.key_ids) == 0 and parsed_pssh.data.content_id: 136 | return base64.b64encode(bytes.fromhex(parsed_pssh.data.content_id)).hex() 137 | elif len(parsed_pssh.data.key_ids) == 1: 138 | return parsed_pssh.data.key_ids[0] 139 | elif len(parsed_pssh.data.key_ids) > 1: 140 | logger.warning("Multiple key ids found in pssh! {}".format(pssh)) 141 | return parsed_pssh.data.key_ids[0] 142 | else: 143 | raise Exception("No KID or Content ID was found in the PSSH.") 144 | else: 145 | raise Exception("No KID or Content ID was found in the PSSH.") 146 | except Exception as e: 147 | raise e 148 | 149 | 150 | # class Validators: 151 | # def __init__(self) -> None: 152 | # self.vinetrimmer_schema = { 153 | # "method": {"required": True, "type": "string", "allowed": ["GetKeysX", "GetKeys", "GetChallenge"]}, 154 | # "params": {"required": False, "type": "dict"}, 155 | # "token": {"required": True, "type": "string"}, 156 | # } 157 | # self.key_exchange_schema = { 158 | # "cdmkeyresponse": {"required": True, "type": ["string", "binary"]}, 159 | # "encryptionkeyid": {"required": True, "type": ["string", "binary"]}, 160 | # "hmackeyid": {"required": True, "type": ["string", "binary"]}, 161 | # "session_id": {"required": True, "type": "string"}, 162 | # } 163 | # self.keys_schema = { 164 | # "cdmkeyresponse": {"required": True, "type": ["string", "binary"]}, 165 | # "session_id": {"required": True, "type": "string"}, 166 | # } 167 | # self.challenge_schema = { 168 | # "init": {"required": True, "type": "string"}, 169 | # "cert": {"required": True, "type": "string"}, 170 | # "raw": {"required": True, "type": "boolean"}, 171 | # "licensetype": {"required": True, "type": "string", "allowed": ["OFFLINE", "STREAMING"]}, 172 | # "device": {"required": True, "type": "string"}, 173 | # } 174 | # self.vinetrimmer_validator = Validator(self.vinetrimmer_schema) 175 | # self.key_exchange_validator = Validator(self.key_exchange_schema) 176 | # self.keys_validator = Validator(self.keys_schema) 177 | # self.challenge_validator = Validator(self.challenge_schema) 178 | 179 | 180 | class Bitfield: 181 | def __init__(self, bits: Union[int, UserFlags] = 0): 182 | if isinstance(bits, UserFlags): 183 | bits = bits.value 184 | self.bits = bits 185 | 186 | def add(self, bit: Union[int, UserFlags]): 187 | if isinstance(bit, UserFlags): 188 | bit = bit.value 189 | self.bits |= bit 190 | return self.bits 191 | 192 | def remove(self, bit: Union[int, UserFlags]): 193 | if isinstance(bit, UserFlags): 194 | bit = bit.value 195 | self.bits &= ~bit 196 | return self.bits 197 | 198 | def has(self, bit: Union[int, UserFlags]): 199 | if isinstance(bit, UserFlags): 200 | bit = bit.value 201 | return (self.bits & bit) == bit 202 | 203 | 204 | class BlacklistEntry: 205 | def __init__(self, obj) -> None: 206 | self.url = obj["url"] 207 | self.partial = obj["partial"] 208 | 209 | if self.partial: 210 | self.url = re.compile(self.url) 211 | 212 | def matches(self, url: str): 213 | if self.partial: 214 | m = self.url.match(url) 215 | return m is not None 216 | else: 217 | return self.url == url 218 | 219 | 220 | class Blacklist: 221 | def __init__(self) -> None: 222 | self.blacklist: list[BlacklistEntry] = list() 223 | 224 | for x in config.URL_BLACKLIST: 225 | self.blacklist.append(BlacklistEntry(x)) 226 | 227 | def is_url_blacklisted(self, url: str): 228 | for entry in self.blacklist: 229 | if entry.matches(url): 230 | return True 231 | return False 232 | 233 | 234 | def get_blob_id(blob): 235 | blob_ = base64.b64decode(blob) 236 | ci = wv_proto2_pb2.ClientIdentification() 237 | ci.ParseFromString(blob_) 238 | return str(ci.ClientInfo[5]).split("Value: ")[1].replace("\n", "").replace('"', "") 239 | 240 | 241 | def search_res_to_dict(kid: str, keys: list[KeyModel]) -> dict: 242 | """ 243 | Converts a list of Keys from search method to a list of dicts 244 | """ 245 | results = {"kid": kid, "keys": list()} 246 | for key in keys: 247 | license_url = key.license_url 248 | if license_url: 249 | s = urlsplit(key.license_url) 250 | license_url = "{}://{}".format(s.scheme, s.netloc) 251 | results["keys"].append( 252 | { 253 | "added_at": key.added_at, 254 | # We shouldnt return the license url as that could have sensitive information it in still 255 | "license_url": license_url, 256 | "key": "{}:{}".format(key.kid, key.key_), 257 | } 258 | ) 259 | return results 260 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "getwvkeys" 3 | version = "0.1.2" 4 | description = "Widevine License Proxy" 5 | authors = [ 6 | "notaghost <65109659+Notaghost9997@users.noreply.github.com>", 7 | "Puyodead1 <14828766+Puyodead1@users.noreply.github.com>", 8 | ] 9 | license = "AGPL-3.0-only" 10 | 11 | [tool.poetry.dependencies] 12 | python = ">=3.9,<4.0" 13 | Flask = "^2.2.5" 14 | Flask-Login = "^0.6.1" 15 | oauthlib = "3.2.2" 16 | requests = "^2.31.0" 17 | protobuf = "4.25.4" 18 | pycryptodomex = "^3.14.1" 19 | PyYAML = "^6.0" 20 | coloredlogs = "^15.0.1" 21 | dunamai = "^1.12.0" 22 | python-dotenv = "^0.20.0" 23 | Cerberus = "^1.3.4" 24 | Flask-SQLAlchemy = ">=3.1.1" 25 | gunicorn = "^23.0.0" 26 | validators = "^0.20.0" 27 | toml = "^0.10.2" 28 | sqlalchemy = ">=2.0.16" 29 | alembic = "^1.13.1" 30 | mariadb = { version = "^1.1.10", optional = true } 31 | mysql-connector-python = { version = "^9.1.0", optional = true } 32 | pywidevine = "^1.8.0" 33 | waitress = "^3.0.0" 34 | click = "^8.1.7" 35 | redis = "^5.2.0" 36 | flask-caching = "^2.3.0" 37 | 38 | [tool.poetry.dev-dependencies] 39 | black = "^22.3.0" 40 | isort = "^5.10.1" 41 | 42 | [tool.poetry.scripts] 43 | serve = 'getwvkeys.main:main' 44 | migrate = 'getwvkeys.main:run_migrations' 45 | gwvk = 'getwvkeys.scripts:main' 46 | 47 | [build-system] 48 | requires = ["poetry-core>=1.0.0"] 49 | build-backend = "poetry.core.masonry.api" 50 | 51 | [tool.isort] 52 | profile = "black" 53 | 54 | [tool.poetry.extras] 55 | mysql = ["mysql-connector-python"] 56 | mariadb = ["mariadb"] 57 | --------------------------------------------------------------------------------