├── .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 | -
64 |
65 | - Key: ${key["key"]}
66 | - License URL: ${key["license_url"]}
67 | -
68 | Added At: ${key["added_at"]}
69 |
70 |
71 |
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 |
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 |
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 | -
29 |
30 | - Key: {{key['key']}}
31 | - License URL: {{key['license_url']}}
32 | - Added At: {{key['added_at']}}
33 |
34 |
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 |
52 |
53 |
54 | What does 'Wrong PSSH' means?
55 |
64 |
65 |
66 | What to do when there is no PSSH in the mpd?
67 |
80 |
81 |
82 | Need Help?
83 |
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 |
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 |
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 |
27 |
28 |
29 |
30 |
You will be redirected to Discord to authorize GetWVKeys to access your account.
31 |
We only check that you are in our support server and have been verified.
32 |
We also store your Discord ID, Username, and Discriminator.
33 |
34 | By clicking the button above and continuing to use this site, you agree to let us access and store the data
35 | mentioned above.
36 |
37 |
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 |
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 |
27 |
28 |
29 | {%for script_name in script_names%}
30 | - {{script_name}}
31 | {%endfor%}
32 |
33 |
34 |
35 |
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 |
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 | - {{key}}
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 |
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 |

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 |
--------------------------------------------------------------------------------