mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-20 07:43:48 +08:00
it's CarniVAl
This commit is contained in:
@@ -409,8 +409,7 @@ def process_hud_alert(enabled, fingerprint, hud_control):
|
||||
|
||||
|
||||
def preserve_stock_canfd_lfa_status(car_fingerprint) -> bool:
|
||||
# The 2022-24 Carnival expects a clean replacement status payload after its radar ECU is disabled.
|
||||
return car_fingerprint != CAR.KIA_CARNIVAL_4TH_GEN
|
||||
return car_fingerprint not in (CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN)
|
||||
|
||||
|
||||
def suppress_redundant_gv70_brake_cancel(CP, brake_pressed: bool, lat_active: bool) -> bool:
|
||||
|
||||
@@ -125,15 +125,14 @@ def get_test_toggles() -> SimpleNamespace:
|
||||
|
||||
|
||||
class TestHyundaiFingerprint:
|
||||
def test_carnival_2024_uses_clean_canfd_lfa_status(self):
|
||||
assert not preserve_stock_canfd_lfa_status(CAR.KIA_CARNIVAL_4TH_GEN)
|
||||
assert preserve_stock_canfd_lfa_status(CAR.KIA_CARNIVAL_2025)
|
||||
assert preserve_stock_canfd_lfa_status(CAR.KIA_CARNIVAL_HEV_4TH_GEN)
|
||||
@pytest.mark.parametrize("candidate", (CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN))
|
||||
def test_carnival_uses_clean_canfd_lfa_status(self, candidate):
|
||||
assert not preserve_stock_canfd_lfa_status(candidate)
|
||||
assert preserve_stock_canfd_lfa_status(CAR.HYUNDAI_IONIQ_6)
|
||||
|
||||
CP = CarParams.new_message()
|
||||
CP.carFingerprint = CAR.KIA_CARNIVAL_4TH_GEN
|
||||
CP.flags = int(HyundaiFlags.CANFD | HyundaiFlags.RADAR_SCC)
|
||||
CP.carFingerprint = candidate
|
||||
CP.flags = int(HyundaiFlags.CANFD | (HyundaiFlags.RADAR_SCC if candidate == CAR.KIA_CARNIVAL_4TH_GEN else HyundaiFlags.CCNC))
|
||||
CP.openpilotLongitudinalControl = True
|
||||
packer = CANPacker(DBC[CP.carFingerprint][Bus.pt])
|
||||
can_bus = CanBus(CP)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { galaxyPath } from "/assets/js/utils.js"
|
||||
|
||||
const STORAGE_KEY = "starpilot.sentry.last-event"
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const SERVICE_WORKER_PATH = "/service-worker.js"
|
||||
@@ -96,13 +98,15 @@ export async function enableSentryPush() {
|
||||
return { ok: false, message: "Chrome notification permission was not granted." }
|
||||
}
|
||||
|
||||
const configResponse = await fetch("/api/sentry/push/config", { cache: "no-store" })
|
||||
const configResponse = await fetch(galaxyPath("/api/sentry/push/config"), { cache: "no-store" })
|
||||
const config = await readJsonResponse(configResponse)
|
||||
if (!configResponse.ok || !config.enabled || !config.publicKey) {
|
||||
return { ok: false, message: config.error || "Galaxy Web Push is unavailable." }
|
||||
}
|
||||
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_PATH, { scope: "/" })
|
||||
const serviceWorkerPath = galaxyPath(SERVICE_WORKER_PATH)
|
||||
const serviceWorkerScope = galaxyPath("/")
|
||||
await navigator.serviceWorker.register(serviceWorkerPath, { scope: serviceWorkerScope })
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
@@ -112,7 +116,7 @@ export async function enableSentryPush() {
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch("/api/sentry/push/subscribe", {
|
||||
const response = await fetch(galaxyPath("/api/sentry/push/subscribe"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscriptionPayload(subscription)),
|
||||
@@ -123,7 +127,7 @@ export async function enableSentryPush() {
|
||||
}
|
||||
|
||||
export async function sendSentryTestPush() {
|
||||
const response = await fetch("/api/sentry/push/test", { method: "POST" })
|
||||
const response = await fetch(galaxyPath("/api/sentry/push/test"), { method: "POST" })
|
||||
const payload = await readJsonResponse(response)
|
||||
if (!response.ok) throw new Error(payload.error || "Galaxy could not send the test push.")
|
||||
return payload
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { galaxyPath, isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import {
|
||||
enableSentryPush,
|
||||
sendSentryTestPush,
|
||||
@@ -19,7 +19,7 @@ let pollTimer = null
|
||||
|
||||
async function fetchParams() {
|
||||
try {
|
||||
const response = await fetch("/api/params/all", { cache: "no-store" })
|
||||
const response = await fetch(galaxyPath("/api/params/all"), { cache: "no-store" })
|
||||
if (response.ok) state.params = await response.json()
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Sentry settings:", error)
|
||||
@@ -30,7 +30,7 @@ async function fetchParams() {
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/sentry/status", { cache: "no-store" })
|
||||
const response = await fetch(galaxyPath("/api/sentry/status"), { cache: "no-store" })
|
||||
if (!response.ok) return
|
||||
const payload = await response.json()
|
||||
state.status = payload.status || {}
|
||||
@@ -50,7 +50,7 @@ function startPolling() {
|
||||
async function saveParam(key, value) {
|
||||
state.savingKey = key
|
||||
try {
|
||||
const response = await fetch("/api/params", {
|
||||
const response = await fetch(galaxyPath("/api/params"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value }),
|
||||
@@ -73,7 +73,7 @@ async function sendTestEvent() {
|
||||
if (state.testBusy) return
|
||||
state.testBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/sentry/test", { method: "POST" })
|
||||
const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" })
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
showSnackbar(payload.error || "Sentry test failed.")
|
||||
@@ -126,8 +126,8 @@ function renderEvent() {
|
||||
${Array.isArray(event.imageUrls) && event.imageUrls.length > 0 ? html`
|
||||
<div class="sentry-image-grid">
|
||||
${event.imageUrls.map((url, index) => html`
|
||||
<a href="${url}" target="_blank" rel="noopener">
|
||||
<img src="${url}" alt="Sentry capture ${index + 1}" loading="lazy" />
|
||||
<a href="${galaxyPath(url)}" target="_blank" rel="noopener">
|
||||
<img src="${galaxyPath(url)}" alt="Sentry capture ${index + 1}" loading="lazy" />
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
@@ -73,3 +73,13 @@ export function hideSidebar() {
|
||||
export function isGalaxyTunnel() {
|
||||
return window.location.hostname === 'galaxy.firestar.link';
|
||||
}
|
||||
|
||||
export function galaxyPath(path) {
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`
|
||||
if (!isGalaxyTunnel()) return suffix
|
||||
|
||||
const firstPathSegment = window.location.pathname.split("/").filter(Boolean)[0] || ""
|
||||
const slug = /^[A-Za-z0-9]{16}$/.test(firstPathSegment) ? `/${firstPathSegment}` : ""
|
||||
if (!slug || suffix === slug || suffix.startsWith(`${slug}/`)) return suffix
|
||||
return `${slug}${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
self.addEventListener("install", () => self.skipWaiting())
|
||||
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()))
|
||||
|
||||
const appBasePath = self.location.pathname.replace(/\/service-worker\.js$/, "").replace(/\/$/, "")
|
||||
|
||||
function scopedUrl(path) {
|
||||
const url = new URL(path || "/sentry", self.location.origin)
|
||||
if (appBasePath && url.pathname !== appBasePath && !url.pathname.startsWith(`${appBasePath}/`)) {
|
||||
url.pathname = `${appBasePath}${url.pathname}`
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {}
|
||||
try {
|
||||
@@ -10,9 +23,9 @@ self.addEventListener("push", (event) => {
|
||||
const options = {
|
||||
body: data.body || "Movement detected while parked.",
|
||||
tag: `starpilot-sentry-${data.eventId || "event"}`,
|
||||
data: { url: data.url || "/sentry" },
|
||||
icon: "/assets/images/favicon.ico",
|
||||
badge: "/assets/images/favicon-32x32.png",
|
||||
data: { url: scopedUrl(data.url || "/sentry") },
|
||||
icon: scopedUrl("/assets/images/favicon.ico"),
|
||||
badge: scopedUrl("/assets/images/favicon-32x32.png"),
|
||||
requireInteraction: true,
|
||||
}
|
||||
|
||||
@@ -21,7 +34,7 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close()
|
||||
const targetUrl = new URL(event.notification.data?.url || "/sentry", self.location.origin).href
|
||||
const targetUrl = scopedUrl(event.notification.data?.url || "/sentry")
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import math
|
||||
import numbers
|
||||
import os
|
||||
import sys
|
||||
import sysconfig
|
||||
import tarfile
|
||||
|
||||
from io import BytesIO
|
||||
@@ -110,11 +111,30 @@ MODEL_SMOOTHING_KEYS = {"LatSmoothSeconds", "LongSmoothSeconds"}
|
||||
GALAXY_DEPS_PATH = "/data/galaxy_deps"
|
||||
LEGACY_GALAXY_DEPS_PATH = "/data/" + "".join(chr(code) for code in (112, 111, 110, 100)) + "_deps"
|
||||
GALAXY_DEPS_PATHS = (GALAXY_DEPS_PATH, LEGACY_GALAXY_DEPS_PATH)
|
||||
for deps_path in GALAXY_DEPS_PATHS:
|
||||
|
||||
|
||||
def _galaxy_runtime_dependency_paths() -> tuple[str, ...]:
|
||||
"""Return existing dependency locations used by Galaxy on-device and in builds."""
|
||||
repo_root = REPO_THIRD_PARTY_PATH.parent.parent
|
||||
candidates = [
|
||||
sysconfig.get_paths().get("purelib", ""),
|
||||
"/usr/local/venv/lib/python3.12/site-packages",
|
||||
]
|
||||
|
||||
for venv_name in (".venv", ".venv-linux-arm64"):
|
||||
venv_path = repo_root / venv_name / "lib"
|
||||
if venv_path.is_dir():
|
||||
candidates.extend(str(path) for path in venv_path.glob("python*/site-packages"))
|
||||
|
||||
return tuple(dict.fromkeys(path for path in candidates if path and os.path.isdir(path)))
|
||||
|
||||
|
||||
REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
|
||||
GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths()
|
||||
for deps_path in GALAXY_DEPS_PATHS + GALAXY_RUNTIME_DEPENDENCY_PATHS:
|
||||
if os.path.isdir(deps_path) and deps_path not in sys.path:
|
||||
sys.path.insert(0, deps_path)
|
||||
|
||||
REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
|
||||
if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_THIRD_PARTY_PATH))
|
||||
|
||||
@@ -690,7 +710,7 @@ def _sentry_push_subscription_count() -> int:
|
||||
|
||||
def _dispatch_sentry_push(event: dict) -> None:
|
||||
try:
|
||||
from pywebpush import webpush
|
||||
from openpilot.starpilot.system.the_galaxy.web_push import webpush
|
||||
|
||||
vapid = _get_sentry_vapid()
|
||||
except Exception:
|
||||
@@ -4316,6 +4336,7 @@ def setup(app):
|
||||
if request.path in {
|
||||
"/assets/components/router.js",
|
||||
"/assets/components/sentry_notifications.js",
|
||||
"/assets/js/utils.js",
|
||||
"/assets/components/home/home.js",
|
||||
"/assets/components/home/home.css",
|
||||
"/assets/components/tools/device_settings.js",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Small synchronous Web Push sender for the device Galaxy runtime.
|
||||
|
||||
The desktop dependency, pywebpush, also imports aiohttp even when only its
|
||||
synchronous sender is used. The device Galaxy runtime does not ship aiohttp,
|
||||
so keep the synchronous path independent of that optional dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import http_ece
|
||||
import requests
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from py_vapid import Vapid, Vapid01
|
||||
|
||||
|
||||
class WebPushException(Exception):
|
||||
def __init__(self, message: str, response=None) -> None:
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
|
||||
def _decode_urlsafe_base64(value: str) -> bytes:
|
||||
encoded = value.encode("ascii")
|
||||
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||
|
||||
|
||||
def _vapid_key(value) -> Vapid01:
|
||||
if isinstance(value, Vapid01):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
if os.path.isfile(value):
|
||||
return Vapid.from_file(private_key_file=value)
|
||||
return Vapid.from_string(private_key=value)
|
||||
raise WebPushException("VAPID private key is missing")
|
||||
|
||||
|
||||
def webpush(
|
||||
subscription_info: Mapping,
|
||||
data: str | bytes | None = None,
|
||||
vapid_private_key=None,
|
||||
vapid_claims: Mapping | None = None,
|
||||
content_encoding: str = "aes128gcm",
|
||||
ttl: int = 0,
|
||||
timeout: float | None = None,
|
||||
headers: Mapping | None = None,
|
||||
):
|
||||
"""Encrypt and synchronously publish one browser push notification."""
|
||||
endpoint = str(subscription_info.get("endpoint") or "")
|
||||
keys = subscription_info.get("keys")
|
||||
if not endpoint or not isinstance(keys, Mapping):
|
||||
raise WebPushException("Push subscription is missing its endpoint or keys")
|
||||
|
||||
receiver_key = _decode_urlsafe_base64(str(keys.get("p256dh") or ""))
|
||||
auth_secret = _decode_urlsafe_base64(str(keys.get("auth") or ""))
|
||||
if len(receiver_key) != 65 or not receiver_key.startswith(b"\x04") or not auth_secret:
|
||||
raise WebPushException("Push subscription contains invalid encryption keys")
|
||||
|
||||
request_headers = {str(key): str(value) for key, value in (headers or {}).items()}
|
||||
claims = dict(vapid_claims or {})
|
||||
if claims:
|
||||
if not claims.get("aud"):
|
||||
parsed_endpoint = urlparse(endpoint)
|
||||
claims["aud"] = f"{parsed_endpoint.scheme}://{parsed_endpoint.netloc}"
|
||||
if not claims.get("exp") or int(claims["exp"]) < int(time.time()):
|
||||
claims["exp"] = int(time.time()) + 12 * 60 * 60
|
||||
request_headers.update({str(key): str(value) for key, value in _vapid_key(vapid_private_key).sign(claims).items()})
|
||||
|
||||
payload = b"" if data is None else data.encode("utf-8") if isinstance(data, str) else data
|
||||
sender_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
encrypted = http_ece.encrypt(
|
||||
payload,
|
||||
private_key=sender_key,
|
||||
dh=receiver_key,
|
||||
auth_secret=auth_secret,
|
||||
version=content_encoding,
|
||||
)
|
||||
request_headers.update({
|
||||
"Content-Encoding": content_encoding,
|
||||
"TTL": str(ttl),
|
||||
})
|
||||
|
||||
response = requests.post(endpoint, data=encrypted, headers=request_headers, timeout=timeout or 10)
|
||||
if response.status_code > 202:
|
||||
raise WebPushException(
|
||||
f"Push failed: {response.status_code} {response.reason}\nResponse body:{response.text}",
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1 @@
|
||||
uv
|
||||
@@ -0,0 +1,33 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: http_ece
|
||||
Version: 1.2.1
|
||||
Summary: Encrypted Content Encoding for HTTP
|
||||
Home-page: https://github.com/martinthomson/encrypted-content-encoding
|
||||
Author: Martin Thomson
|
||||
Author-email: martin.thomson@gmail.com
|
||||
License: MIT
|
||||
Keywords: crypto http
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python :: 2.7
|
||||
Classifier: Programming Language :: Python :: 3.4
|
||||
Classifier: Programming Language :: Python :: 3.5
|
||||
Classifier: Programming Language :: Python :: 3.6
|
||||
Classifier: Programming Language :: Python :: 3.7
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Requires-Dist: cryptography>=2.5
|
||||
Dynamic: author
|
||||
Dynamic: author-email
|
||||
Dynamic: classifier
|
||||
Dynamic: description
|
||||
Dynamic: home-page
|
||||
Dynamic: keywords
|
||||
Dynamic: license
|
||||
Dynamic: requires-dist
|
||||
Dynamic: summary
|
||||
|
||||
Encipher HTTP Messages
|
||||
@@ -0,0 +1,8 @@
|
||||
http_ece-1.2.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
||||
http_ece-1.2.1.dist-info/METADATA,sha256=O44G_AI2K-3Pc1-s7L2DUUmvXzOeaCemhJbfX1M0nQg,1092
|
||||
http_ece-1.2.1.dist-info/RECORD,,
|
||||
http_ece-1.2.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
http_ece-1.2.1.dist-info/WHEEL,sha256=4YBfCYNH4wlLpv3pzq1hbEuIlXA4WJabKLFurZ7eTL0,109
|
||||
http_ece-1.2.1.dist-info/top_level.txt,sha256=-74Do2Twxx-mpdqAgnY6SjAfhncdj5LOK9ImmXbDtj4,9
|
||||
http_ece-1.2.1.dist-info/uv_build.json,sha256=RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o,2
|
||||
http_ece/__init__.py,sha256=Ld0PGOGwwof-a-pTvn-6sdlQ_hW3GEu4D_q9mMQEM_k,13073
|
||||
@@ -0,0 +1,6 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (84.0.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py2-none-any
|
||||
Tag: py3-none-any
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
http_ece
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
import functools
|
||||
import os
|
||||
import struct
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
MAX_RECORD_SIZE = pow(2, 31) - 1
|
||||
MIN_RECORD_SIZE = 3
|
||||
KEY_LENGTH = 16
|
||||
NONCE_LENGTH = 12
|
||||
TAG_LENGTH = 16
|
||||
|
||||
# Valid content types (ordered from newest, to most obsolete)
|
||||
versions = {
|
||||
"aes128gcm": {"pad": 1},
|
||||
"aesgcm": {"pad": 2},
|
||||
"aesgcm128": {"pad": 1},
|
||||
}
|
||||
|
||||
|
||||
class ECEException(Exception):
|
||||
"""Exception for ECE encryption functions"""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
|
||||
def derive_key(
|
||||
mode, version, salt, key, private_key, dh, auth_secret, keyid, keylabel="P-256"
|
||||
):
|
||||
"""Derive the encryption key
|
||||
|
||||
:param mode: operational mode (encrypt or decrypt)
|
||||
:type mode: enumerate('encrypt', 'decrypt)
|
||||
:param salt: encryption salt value
|
||||
:type salt: str
|
||||
:param key: raw key
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param dh: Diffie Helman public key value
|
||||
:type dh: str
|
||||
:param keyid: key identifier label
|
||||
:type keyid: str
|
||||
:param keylabel: label for aesgcm/aesgcm128
|
||||
:type keylabel: str
|
||||
:param auth_secret: authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: Content Type identifier
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
|
||||
"""
|
||||
context = b""
|
||||
keyinfo = ""
|
||||
nonceinfo = ""
|
||||
|
||||
def build_info(base, info_context):
|
||||
return b"Content-Encoding: " + base + b"\0" + info_context
|
||||
|
||||
def derive_dh(mode, version, private_key, dh, keylabel):
|
||||
def length_prefix(key):
|
||||
return struct.pack("!H", len(key)) + key
|
||||
|
||||
if isinstance(dh, ec.EllipticCurvePublicKey):
|
||||
pubkey = dh
|
||||
dh = dh.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||
else:
|
||||
pubkey = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), dh)
|
||||
|
||||
encoded = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
if mode == "encrypt":
|
||||
sender_pub_key = encoded
|
||||
receiver_pub_key = dh
|
||||
else:
|
||||
sender_pub_key = dh
|
||||
receiver_pub_key = encoded
|
||||
|
||||
if version == "aes128gcm":
|
||||
context = b"WebPush: info\x00" + receiver_pub_key + sender_pub_key
|
||||
else:
|
||||
context = (
|
||||
keylabel.encode("utf-8")
|
||||
+ b"\0"
|
||||
+ length_prefix(receiver_pub_key)
|
||||
+ length_prefix(sender_pub_key)
|
||||
)
|
||||
|
||||
return private_key.exchange(ec.ECDH(), pubkey), context
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
if mode not in ["encrypt", "decrypt"]:
|
||||
raise ECEException("unknown 'mode' specified: " + mode)
|
||||
if salt is None or len(salt) != KEY_LENGTH:
|
||||
raise ECEException("'salt' must be a 16 octet value")
|
||||
if dh is not None:
|
||||
if private_key is None:
|
||||
raise ECEException("DH requires a private_key")
|
||||
(secret, context) = derive_dh(
|
||||
mode=mode,
|
||||
version=version,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
else:
|
||||
secret = key
|
||||
|
||||
if secret is None:
|
||||
raise ECEException("unable to determine the secret")
|
||||
|
||||
if version == "aesgcm":
|
||||
keyinfo = build_info(b"aesgcm", context)
|
||||
nonceinfo = build_info(b"nonce", context)
|
||||
elif version == "aesgcm128":
|
||||
keyinfo = b"Content-Encoding: aesgcm128"
|
||||
nonceinfo = b"Content-Encoding: nonce"
|
||||
elif version == "aes128gcm":
|
||||
keyinfo = b"Content-Encoding: aes128gcm\x00"
|
||||
nonceinfo = b"Content-Encoding: nonce\x00"
|
||||
if dh is None:
|
||||
# Only mix the authentication secret when using DH for aes128gcm
|
||||
auth_secret = None
|
||||
|
||||
if auth_secret is not None:
|
||||
if version == "aes128gcm":
|
||||
info = context
|
||||
else:
|
||||
info = build_info(b"auth", b"")
|
||||
hkdf_auth = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=auth_secret,
|
||||
info=info,
|
||||
backend=default_backend(),
|
||||
)
|
||||
secret = hkdf_auth.derive(secret)
|
||||
|
||||
hkdf_key = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_LENGTH,
|
||||
salt=salt,
|
||||
info=keyinfo,
|
||||
backend=default_backend(),
|
||||
)
|
||||
hkdf_nonce = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=NONCE_LENGTH,
|
||||
salt=salt,
|
||||
info=nonceinfo,
|
||||
backend=default_backend(),
|
||||
)
|
||||
return hkdf_key.derive(secret), hkdf_nonce.derive(secret)
|
||||
|
||||
|
||||
def iv(base, counter):
|
||||
"""Generate an initialization vector."""
|
||||
if (counter >> 64) != 0:
|
||||
raise ECEException("Counter too big")
|
||||
(mask,) = struct.unpack("!Q", base[4:])
|
||||
return base[:4] + struct.pack("!Q", counter ^ mask)
|
||||
|
||||
|
||||
def decrypt(
|
||||
content,
|
||||
salt=None,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid=None,
|
||||
keylabel="P-256",
|
||||
rs=4096,
|
||||
version="aes128gcm",
|
||||
):
|
||||
"""
|
||||
Decrypt a data block
|
||||
|
||||
:param content: Data to be decrypted
|
||||
:type content: str
|
||||
:param salt: Encryption salt
|
||||
:type salt: str
|
||||
:param key: local public key
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param keyid: Internal key identifier for private key info
|
||||
:type keyid: str
|
||||
:param dh: Remote Diffie Hellman sequence (omit for aes128gcm)
|
||||
:type dh: str
|
||||
:param rs: Record size
|
||||
:type rs: int
|
||||
:param auth_secret: Authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: ECE Method version
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
:return: Decrypted message content
|
||||
:rtype str
|
||||
|
||||
"""
|
||||
|
||||
def parse_content_header(content):
|
||||
"""Parse an aes128gcm content body and extract the header values.
|
||||
|
||||
:param content: The encrypted body of the message
|
||||
:type content: str
|
||||
|
||||
"""
|
||||
id_len = struct.unpack("!B", content[20:21])[0]
|
||||
return {
|
||||
"salt": content[:16],
|
||||
"rs": struct.unpack("!L", content[16:20])[0],
|
||||
"keyid": content[21 : 21 + id_len],
|
||||
"content": content[21 + id_len :],
|
||||
}
|
||||
|
||||
def decrypt_record(key, nonce, counter, content):
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv(nonce, counter), tag=content[-TAG_LENGTH:]),
|
||||
backend=default_backend(),
|
||||
).decryptor()
|
||||
return decryptor.update(content[:-TAG_LENGTH]) + decryptor.finalize()
|
||||
|
||||
def unpad_legacy(data):
|
||||
pad_size = versions[version]["pad"]
|
||||
pad = functools.reduce(
|
||||
lambda x, y: x << 8 | y,
|
||||
struct.unpack("!" + ("B" * pad_size), data[0:pad_size]),
|
||||
)
|
||||
if pad_size + pad > len(data) or data[pad_size : pad_size + pad] != (
|
||||
b"\x00" * pad
|
||||
):
|
||||
raise ECEException("Bad padding")
|
||||
return data[pad_size + pad :]
|
||||
|
||||
def unpad(data, last):
|
||||
i = len(data) - 1
|
||||
for i in range(len(data) - 1, -1, -1):
|
||||
v = struct.unpack("B", data[i : i + 1])[0]
|
||||
if v != 0:
|
||||
if not last and v != 1:
|
||||
raise ECEException("record delimiter != 1")
|
||||
if last and v != 2:
|
||||
raise ECEException("last record delimiter != 2")
|
||||
return data[0:i]
|
||||
raise ECEException("all zero record plaintext")
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
|
||||
overhead = versions[version]["pad"]
|
||||
if version == "aes128gcm":
|
||||
try:
|
||||
content_header = parse_content_header(content)
|
||||
except Exception:
|
||||
raise ECEException("Could not parse the content header")
|
||||
salt = content_header["salt"]
|
||||
rs = content_header["rs"]
|
||||
keyid = content_header["keyid"]
|
||||
if private_key is not None and not dh:
|
||||
dh = keyid
|
||||
else:
|
||||
keyid = keyid.decode("utf-8")
|
||||
content = content_header["content"]
|
||||
overhead += 16
|
||||
|
||||
(key_, nonce_) = derive_key(
|
||||
"decrypt",
|
||||
version=version,
|
||||
salt=salt,
|
||||
key=key,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
auth_secret=auth_secret,
|
||||
keyid=keyid,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
if rs <= overhead:
|
||||
raise ECEException("Record size too small")
|
||||
chunk = rs
|
||||
if version != "aes128gcm":
|
||||
chunk += 16 # account for tags in old versions
|
||||
if len(content) % chunk == 0:
|
||||
raise ECEException("Message truncated")
|
||||
|
||||
result = b""
|
||||
counter = 0
|
||||
try:
|
||||
for i in list(range(0, len(content), chunk)):
|
||||
data = decrypt_record(key_, nonce_, counter, content[i : i + chunk])
|
||||
if version == "aes128gcm":
|
||||
last = (i + chunk) >= len(content)
|
||||
result += unpad(data, last)
|
||||
else:
|
||||
result += unpad_legacy(data)
|
||||
counter += 1
|
||||
except InvalidTag as ex:
|
||||
raise ECEException("Decryption error: {}".format(repr(ex)))
|
||||
return result
|
||||
|
||||
|
||||
def encrypt(
|
||||
content,
|
||||
salt=None,
|
||||
key=None,
|
||||
private_key=None,
|
||||
dh=None,
|
||||
auth_secret=None,
|
||||
keyid=None,
|
||||
keylabel="P-256",
|
||||
rs=4096,
|
||||
version="aes128gcm",
|
||||
):
|
||||
"""
|
||||
Encrypt a data block
|
||||
|
||||
:param content: block of data to encrypt
|
||||
:type content: str
|
||||
:param salt: Encryption salt
|
||||
:type salt: str
|
||||
:param key: Encryption key data
|
||||
:type key: str
|
||||
:param private_key: DH private key
|
||||
:type key: object
|
||||
:param keyid: Internal key identifier for private key info
|
||||
:type keyid: str
|
||||
:param dh: Remote Diffie Hellman sequence
|
||||
:type dh: str
|
||||
:param rs: Record size
|
||||
:type rs: int
|
||||
:param auth_secret: Authorization secret
|
||||
:type auth_secret: str
|
||||
:param version: ECE Method version
|
||||
:type version: enumerate('aes128gcm', 'aesgcm', 'aesgcm128')
|
||||
:return: Encrypted message content
|
||||
:rtype str
|
||||
|
||||
"""
|
||||
|
||||
def encrypt_record(key, nonce, counter, buf, last):
|
||||
encryptor = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv(nonce, counter)),
|
||||
backend=default_backend(),
|
||||
).encryptor()
|
||||
|
||||
if version == "aes128gcm":
|
||||
data = encryptor.update(buf + (b"\x02" if last else b"\x01"))
|
||||
else:
|
||||
data = encryptor.update((b"\x00" * versions[version]["pad"]) + buf)
|
||||
data += encryptor.finalize()
|
||||
data += encryptor.tag
|
||||
return data
|
||||
|
||||
def compose_aes128gcm(salt, content, rs, keyid):
|
||||
"""Compose the header and content of an aes128gcm encrypted
|
||||
message body
|
||||
|
||||
:param salt: The sender's salt value
|
||||
:type salt: str
|
||||
:param content: The encrypted body of the message
|
||||
:type content: str
|
||||
:param rs: Override for the content length
|
||||
:type rs: int
|
||||
:param keyid: The keyid to use for this message
|
||||
:type keyid: str
|
||||
|
||||
"""
|
||||
if len(keyid) > 255:
|
||||
raise ECEException("keyid is too long")
|
||||
header = salt
|
||||
if rs > MAX_RECORD_SIZE:
|
||||
raise ECEException("Too much content")
|
||||
header += struct.pack("!L", rs)
|
||||
header += struct.pack("!B", len(keyid))
|
||||
header += keyid
|
||||
return header + content
|
||||
|
||||
if version not in versions:
|
||||
raise ECEException("Invalid version")
|
||||
|
||||
if salt is None:
|
||||
salt = os.urandom(16)
|
||||
|
||||
(key_, nonce_) = derive_key(
|
||||
"encrypt",
|
||||
version=version,
|
||||
salt=salt,
|
||||
key=key,
|
||||
private_key=private_key,
|
||||
dh=dh,
|
||||
auth_secret=auth_secret,
|
||||
keyid=keyid,
|
||||
keylabel=keylabel,
|
||||
)
|
||||
|
||||
overhead = versions[version]["pad"]
|
||||
if version == "aes128gcm":
|
||||
overhead += 16
|
||||
end = len(content)
|
||||
else:
|
||||
end = len(content) + 1
|
||||
if rs <= overhead:
|
||||
raise ECEException("Record size too small")
|
||||
chunk_size = rs - overhead
|
||||
|
||||
result = b""
|
||||
counter = 0
|
||||
|
||||
# the extra one on the loop ensures that we produce a padding only
|
||||
# record if the data length is an exact multiple of the chunk size
|
||||
for i in list(range(0, end, chunk_size)):
|
||||
result += encrypt_record(
|
||||
key_, nonce_, counter, content[i : i + chunk_size], (i + chunk_size) >= end
|
||||
)
|
||||
counter += 1
|
||||
if version == "aes128gcm":
|
||||
if keyid is None and private_key is not None:
|
||||
kid = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
else:
|
||||
kid = (keyid or "").encode("utf-8")
|
||||
return compose_aes128gcm(salt, result, rs, keyid=kid)
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uv
|
||||
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,122 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: py-vapid
|
||||
Version: 1.9.2
|
||||
Summary: Simple VAPID header generation library
|
||||
Author-email: JR Conlin <src+vapid@jrconlin.com>
|
||||
License: MPL-2.0
|
||||
Project-URL: Homepage, https://github.com/mozilla-services/vapid
|
||||
Keywords: vapid,push,webpush
|
||||
Classifier: Topic :: Internet :: WWW/HTTP
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: cryptography >=2.5
|
||||
|
||||
|PyPI version py_vapid|
|
||||
|
||||
Easy VAPID generation
|
||||
=====================
|
||||
|
||||
This minimal library contains the minimal set of functions you need to
|
||||
generate a VAPID key set and get the headers you’ll need to sign a
|
||||
WebPush subscription update.
|
||||
|
||||
VAPID is a voluntary standard for WebPush subscription providers (sites
|
||||
that send WebPush updates to remote customers) to self-identify to Push
|
||||
Servers (the servers that convey the push notifications).
|
||||
|
||||
The VAPID “claims” are a set of JSON keys and values. There are two
|
||||
required fields, one semi-optional and several optional additional
|
||||
fields.
|
||||
|
||||
At a minimum a VAPID claim set should look like:
|
||||
|
||||
::
|
||||
|
||||
{"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"}
|
||||
|
||||
A few notes:
|
||||
|
||||
**sub** is the email address you wish to have on record for this
|
||||
request, prefixed with “``mailto:``”. If things go wrong, this is the
|
||||
email that will be used to contact you (for instance). This can be a
|
||||
general delivery address like “``mailto:push_operations@example.com``”
|
||||
or a specific address like “``mailto:bob@example.com``”.
|
||||
|
||||
**aud** is the audience for the VAPID. This is the scheme and host you
|
||||
use to send subscription endpoints and generally coincides with the
|
||||
``endpoint`` specified in the Subscription Info block.
|
||||
|
||||
As example, if a WebPush subscription info contains:
|
||||
``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}``
|
||||
|
||||
then the ``aud`` would be “``https://push.example.com:8012``”
|
||||
|
||||
While some Push Services consider this an optional field, others may be
|
||||
stricter.
|
||||
|
||||
**exp** This is the UTC timestamp for when this VAPID request will
|
||||
expire. The maximum period is 24 hours. Setting a shorter period can
|
||||
prevent “replay” attacks. Setting a longer period allows you to reuse
|
||||
headers for multiple sends (e.g. if you’re sending hundreds of updates
|
||||
within an hour or so.) If no ``exp`` is included, one that will expire
|
||||
in 24 hours will be auto-generated for you.
|
||||
|
||||
Claims should be stored in a JSON compatible file. In the examples
|
||||
below, we’ve stored the claims into a file named ``claims.json``.
|
||||
|
||||
py_vapid can either be installed as a library or used as a stand along
|
||||
app, ``bin/vapid``.
|
||||
|
||||
App Installation
|
||||
----------------
|
||||
|
||||
You’ll need ``python virtualenv`` Run that in the current directory.
|
||||
|
||||
Then run
|
||||
|
||||
::
|
||||
|
||||
bin/pip install -r requirements.txt
|
||||
|
||||
bin/python -m pip install -e .
|
||||
|
||||
App Usage
|
||||
---------
|
||||
|
||||
Run by itself, ``bin/vapid`` will check and optionally create the
|
||||
public_key.pem and private_key.pem files.
|
||||
|
||||
``bin/vapid --gen`` can be used to generate a new set of public and
|
||||
private key PEM files. These will overwrite the contents of
|
||||
``private_key.pem`` and ``public_key.pem``.
|
||||
|
||||
``bin/vapid --sign claims.json`` will generate a set of HTTP headers
|
||||
from a JSON formatted claims file. A sample ``claims.json`` is included
|
||||
with this distribution.
|
||||
|
||||
``bin/vapid --sign claims.json --json`` will output the headers in JSON
|
||||
format, which may be useful for other programs.
|
||||
|
||||
``bin/vapid --applicationServerKey`` will return the
|
||||
``applicationServerKey`` value you can use to make a restricted
|
||||
endpoint. See
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe
|
||||
for more details. Be aware that this value is tied to the generated
|
||||
public/private key. If you remove or generate a new key, any restricted
|
||||
URL you’ve previously generated will need to be reallocated. Please note
|
||||
that some User Agents may require you `to decode this string into a
|
||||
Uint8Array <https://github.com/GoogleChrome/push-notifications/blob/master/app/scripts/main.js>`__.
|
||||
|
||||
See ``bin/vapid -h`` for all options and commands.
|
||||
|
||||
CHANGELOG
|
||||
---------
|
||||
|
||||
I’m terrible about updating the Changelog. Please see the
|
||||
```git log`` <https://github.com/web-push-libs/vapid/pulls?q=is%3Apr+is%3Aclosed>`__
|
||||
history for details.
|
||||
|
||||
.. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg
|
||||
:target: https://pypi.org/project/py-vapid/
|
||||
@@ -0,0 +1,15 @@
|
||||
../../../bin/vapid,sha256=cghXi0BJHj9UXeRWzyy79lJ3EEs3VBMg4Lscy8cH5-Y,314
|
||||
py_vapid-1.9.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
|
||||
py_vapid-1.9.2.dist-info/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
|
||||
py_vapid-1.9.2.dist-info/METADATA,sha256=rD4ZUM8i0Mx_uKfJ2YWqnJ5L7q0x_NERx0m0N-EskL0,4416
|
||||
py_vapid-1.9.2.dist-info/RECORD,,
|
||||
py_vapid-1.9.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
py_vapid-1.9.2.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
|
||||
py_vapid-1.9.2.dist-info/entry_points.txt,sha256=8VfF1HHZcNIS15s9Y9JNV3Ue2nX2VIbSbOdkxClZJRc,45
|
||||
py_vapid-1.9.2.dist-info/top_level.txt,sha256=pEd5d-K8MR9lMLsrWn3PXgX564C-4e_eZO4m7xnI0w4,9
|
||||
py_vapid/__init__.py,sha256=CtKxAcjwuEqzRHZAJ2EbHC-sX2RYyuQen0zPJKPxnZM,12833
|
||||
py_vapid/__main__.py,sha256=LHdIG_A9e2Z5w3IQy1THx1hz0sJ5FHQ0GD9MNpfSPGc,4597
|
||||
py_vapid/jwt.py,sha256=GIbrJc2Sb2frpZQMbCY04Cw9v-iHolBccy5qKCrDYE8,2547
|
||||
py_vapid/main.py,sha256=44Lyn5lDapqQ8OCMxzcwlMZsm9jWh_zEkQcfzeteCno,4548
|
||||
py_vapid/tests/test_vapid.py,sha256=Rg1bBdhUUhF9sgRuwcDcJd0wGLTff7HmyOBifGtBOJc,10403
|
||||
py_vapid/utils.py,sha256=1OGZIKOcQRnjJaTzy81SVopIyvC-AGME-eu5nBcnPLw,921
|
||||
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (75.5.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
vapid = py_vapid.main:main
|
||||
@@ -0,0 +1 @@
|
||||
py_vapid
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import binascii
|
||||
import time
|
||||
import re
|
||||
import copy
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils as ecutils
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
from py_vapid.utils import b64urldecode, b64urlencode
|
||||
from py_vapid.jwt import sign
|
||||
|
||||
# Show compliance version. For earlier versions see previously tagged releases.
|
||||
VERSION = "VAPID-RFC/ECE-RFC"
|
||||
|
||||
|
||||
class VapidException(Exception):
|
||||
"""An exception wrapper for Vapid."""
|
||||
pass
|
||||
|
||||
|
||||
class Vapid01(object):
|
||||
"""Minimal VAPID Draft 01 signature generation library.
|
||||
|
||||
https://tools.ietf.org/html/draft-ietf-webpush-vapid-01
|
||||
|
||||
"""
|
||||
_private_key = None
|
||||
_public_key = None
|
||||
_schema = "WebPush"
|
||||
|
||||
def __init__(self, private_key=None, conf=None):
|
||||
"""Initialize VAPID with an optional private key.
|
||||
|
||||
:param private_key: A private key object
|
||||
:type private_key: ec.EllipticCurvePrivateKey
|
||||
|
||||
"""
|
||||
if conf is None:
|
||||
conf = {}
|
||||
self.conf = conf
|
||||
self.private_key = private_key
|
||||
if private_key:
|
||||
self._public_key = self.private_key.public_key()
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, private_raw):
|
||||
"""Initialize VAPID using a private key point in "raw" or
|
||||
"uncompressed" form. Raw keys consist of a single, 32 octet
|
||||
encoded integer.
|
||||
|
||||
:param private_raw: A private key point in uncompressed form.
|
||||
:type private_raw: bytes
|
||||
|
||||
"""
|
||||
key = ec.derive_private_key(
|
||||
int(binascii.hexlify(b64urldecode(private_raw)), 16),
|
||||
curve=ec.SECP256R1(),
|
||||
backend=default_backend())
|
||||
return cls(key)
|
||||
|
||||
@classmethod
|
||||
def from_raw_public(cls, public_raw):
|
||||
key = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
curve=ec.SECP256R1(),
|
||||
data=b64urldecode(public_raw)
|
||||
)
|
||||
ss = cls()
|
||||
ss._public_key = key
|
||||
return ss
|
||||
|
||||
@classmethod
|
||||
def from_pem(cls, private_key):
|
||||
"""Initialize VAPID using a private key in PEM format.
|
||||
|
||||
:param private_key: A private key in PEM format.
|
||||
:type private_key: bytes
|
||||
|
||||
"""
|
||||
# not sure why, but load_pem_private_key fails to deserialize
|
||||
return cls.from_der(
|
||||
b''.join(private_key.splitlines()[1:-1]))
|
||||
|
||||
@classmethod
|
||||
def from_der(cls, private_key):
|
||||
"""Initialize VAPID using a private key in DER format.
|
||||
|
||||
:param private_key: A private key in DER format and Base64-encoded.
|
||||
:type private_key: bytes
|
||||
|
||||
"""
|
||||
key = serialization.load_der_private_key(b64urldecode(private_key),
|
||||
password=None,
|
||||
backend=default_backend())
|
||||
return cls(key)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, private_key_file=None):
|
||||
"""Initialize VAPID using a file containing a private key in PEM or
|
||||
DER format.
|
||||
|
||||
:param private_key_file: Name of the file containing the private key
|
||||
:type private_key_file: str
|
||||
|
||||
"""
|
||||
if not os.path.isfile(private_key_file):
|
||||
logging.info("Private key not found, generating key...")
|
||||
vapid = cls()
|
||||
vapid.generate_keys()
|
||||
vapid.save_key(private_key_file)
|
||||
return vapid
|
||||
with open(private_key_file, 'r') as file:
|
||||
private_key = file.read()
|
||||
try:
|
||||
if "-----BEGIN" in private_key:
|
||||
vapid = cls.from_pem(private_key.encode('utf8'))
|
||||
else:
|
||||
vapid = cls.from_der(private_key.encode('utf8'))
|
||||
return vapid
|
||||
except Exception as exc:
|
||||
logging.error("Could not open private key file: %s", repr(exc))
|
||||
raise VapidException(exc)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, private_key):
|
||||
"""Initialize VAPID using a string containing the private key. This
|
||||
will try to determine if the key is in RAW or DER format.
|
||||
|
||||
:param private_key: String containing the key info
|
||||
:type private_key: str
|
||||
|
||||
"""
|
||||
|
||||
pkey = private_key.encode().replace(b"\n", b"")
|
||||
key = b64urldecode(pkey)
|
||||
if len(key) == 32:
|
||||
return cls.from_raw(pkey)
|
||||
return cls.from_der(pkey)
|
||||
|
||||
@classmethod
|
||||
def verify(cls, key, auth):
|
||||
"""Verify a VAPID authorization token.
|
||||
|
||||
:param key: base64 serialized public key
|
||||
:type key: str
|
||||
:param auth: authorization token
|
||||
type key: str
|
||||
|
||||
"""
|
||||
tokens = auth.rsplit(' ', 1)[1].rsplit('.', 1)
|
||||
kp = cls().from_raw_public(key.encode())
|
||||
return kp.verify_token(
|
||||
validation_token=tokens[0].encode(),
|
||||
verification_token=tokens[1]
|
||||
)
|
||||
|
||||
@property
|
||||
def private_key(self):
|
||||
"""The VAPID private ECDSA key"""
|
||||
if not self._private_key:
|
||||
raise VapidException("No private key. Call generate_keys()")
|
||||
return self._private_key
|
||||
|
||||
@private_key.setter
|
||||
def private_key(self, value):
|
||||
"""Set the VAPID private ECDSA key
|
||||
|
||||
:param value: the byte array containing the private ECDSA key data
|
||||
:type value: ec.EllipticCurvePrivateKey
|
||||
|
||||
"""
|
||||
self._private_key = value
|
||||
if value:
|
||||
self._public_key = self.private_key.public_key()
|
||||
|
||||
@property
|
||||
def public_key(self):
|
||||
"""The VAPID public ECDSA key
|
||||
|
||||
The public key is currently read only. Set it via the `.private_key`
|
||||
method. This will autogenerate a public and private key if no value
|
||||
has been set.
|
||||
|
||||
:returns ec.EllipticCurvePublicKey
|
||||
|
||||
"""
|
||||
return self._public_key
|
||||
|
||||
def generate_keys(self):
|
||||
"""Generate a valid ECDSA Key Pair."""
|
||||
self.private_key = ec.generate_private_key(ec.SECP256R1,
|
||||
default_backend())
|
||||
|
||||
def private_pem(self):
|
||||
return self.private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
def public_pem(self):
|
||||
return self.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def save_key(self, key_file):
|
||||
"""Save the private key to a PEM file.
|
||||
|
||||
:param key_file: The file path to save the private key data
|
||||
:type key_file: str
|
||||
|
||||
"""
|
||||
with open(key_file, "wb") as file:
|
||||
file.write(self.private_pem())
|
||||
file.close()
|
||||
|
||||
def save_public_key(self, key_file):
|
||||
"""Save the public key to a PEM file.
|
||||
:param key_file: The name of the file to save the public key
|
||||
:type key_file: str
|
||||
|
||||
"""
|
||||
with open(key_file, "wb") as file:
|
||||
file.write(self.public_pem())
|
||||
file.close()
|
||||
|
||||
def verify_token(self, validation_token, verification_token):
|
||||
"""Internally used to verify the verification token is correct.
|
||||
|
||||
:param validation_token: Provided validation token string
|
||||
:type validation_token: str
|
||||
:param verification_token: Generated verification token
|
||||
:type verification_token: str
|
||||
:returns: Boolean indicating if verifictation token is valid.
|
||||
:rtype: boolean
|
||||
|
||||
"""
|
||||
hsig = b64urldecode(verification_token.encode('utf8'))
|
||||
r = int(binascii.hexlify(hsig[:32]), 16)
|
||||
s = int(binascii.hexlify(hsig[32:]), 16)
|
||||
try:
|
||||
self.public_key.verify(
|
||||
ecutils.encode_dss_signature(r, s),
|
||||
validation_token,
|
||||
signature_algorithm=ec.ECDSA(hashes.SHA256())
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
def _base_sign(self, claims):
|
||||
cclaims = copy.deepcopy(claims)
|
||||
if not cclaims.get('exp'):
|
||||
cclaims['exp'] = int(time.time()) + 86400
|
||||
if not self.conf.get('no-strict', False):
|
||||
valid = _check_sub(cclaims.get('sub', ''))
|
||||
else:
|
||||
valid = cclaims.get('sub') is not None
|
||||
if not valid:
|
||||
raise VapidException(
|
||||
"Missing 'sub' from claims. "
|
||||
"'sub' is your admin email as a mailto: link.")
|
||||
if not re.match(r"^https?://[^/:]+(:\d+)?$",
|
||||
cclaims.get("aud", ""),
|
||||
re.IGNORECASE):
|
||||
raise VapidException(
|
||||
"Missing 'aud' from claims. "
|
||||
"'aud' is the scheme, host and optional port for this "
|
||||
"transaction e.g. https://example.com:8080")
|
||||
return cclaims
|
||||
|
||||
def sign(self, claims, crypto_key=None):
|
||||
"""Sign a set of claims.
|
||||
:param claims: JSON object containing the JWT claims to use.
|
||||
:type claims: dict
|
||||
:param crypto_key: Optional existing crypto_key header content. The
|
||||
vapid public key will be appended to this data.
|
||||
:type crypto_key: str
|
||||
:returns: a hash containing the header fields to use in
|
||||
the subscription update.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
sig = sign(self._base_sign(claims), self.private_key)
|
||||
pkey = 'p256ecdsa='
|
||||
pkey += b64urlencode(
|
||||
self.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
))
|
||||
if crypto_key:
|
||||
crypto_key = crypto_key + ';' + pkey
|
||||
else:
|
||||
crypto_key = pkey
|
||||
|
||||
return {"Authorization": "{} {}".format(self._schema, sig.strip('=')),
|
||||
"Crypto-Key": crypto_key}
|
||||
|
||||
|
||||
class Vapid02(Vapid01):
|
||||
"""Minimal Vapid RFC8292 signature generation library
|
||||
|
||||
https://tools.ietf.org/html/rfc8292
|
||||
|
||||
"""
|
||||
_schema = "vapid"
|
||||
|
||||
def sign(self, claims, crypto_key=None):
|
||||
"""Generate an authorization token
|
||||
|
||||
:param claims: JSON object containing the JWT claims to use.
|
||||
:type claims: dict
|
||||
:param crypto_key: Optional existing crypto_key header content. The
|
||||
vapid public key will be appended to this data.
|
||||
:type crypto_key: str
|
||||
:returns: a hash containing the header fields to use in
|
||||
the subscription update.
|
||||
:rtype: dict
|
||||
"""
|
||||
sig = sign(self._base_sign(claims), self.private_key)
|
||||
pkey = self.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
return{
|
||||
"Authorization": "{schema} t={t},k={k}".format(
|
||||
schema=self._schema,
|
||||
t=sig,
|
||||
k=b64urlencode(pkey)
|
||||
)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def verify(cls, auth):
|
||||
"""Ensure that the token is correctly formatted and valid
|
||||
|
||||
:param auth: An Authorization header
|
||||
:type auth: str
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
pref_tok = auth.rsplit(' ', 1)
|
||||
assert pref_tok[0].lower() == cls._schema, (
|
||||
"Incorrect schema specified")
|
||||
parts = {}
|
||||
for tok in pref_tok[1].split(','):
|
||||
kv = tok.split('=', 1)
|
||||
parts[kv[0]] = kv[1]
|
||||
assert 'k' in parts.keys(), (
|
||||
"Auth missing public key 'k' value")
|
||||
assert 't' in parts.keys(), (
|
||||
"Auth missing token set 't' value")
|
||||
kp = cls().from_raw_public(parts['k'].encode())
|
||||
tokens = parts['t'].rsplit('.', 1)
|
||||
return kp.verify_token(
|
||||
validation_token=tokens[0].encode(),
|
||||
verification_token=tokens[1]
|
||||
)
|
||||
|
||||
|
||||
def _check_sub(sub):
|
||||
""" Check to see if the `sub` is a properly formatted `mailto:`
|
||||
|
||||
a `mailto:` should be a SMTP mail address. Mind you, since I run
|
||||
YouFailAtEmail.com, you have every right to yell about how terrible
|
||||
this check is. I really should be doing a proper component parse
|
||||
and valiate each component individually per RFC5341, instead I do
|
||||
the unholy regex you see below.
|
||||
|
||||
:param sub: Candidate JWT `sub`
|
||||
:type sub: str
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
pattern = (
|
||||
r"^(mailto:.+@((localhost|[%\w-]+(\.[%\w-]+)+|([0-9a-f]{1,4}):+([0-9a-f]{1,4})?)))|https:\/\/(localhost|[\w-]+\.[\w\.-]+|([0-9a-f]{1,4}:+)+([0-9a-f]{1,4})?)$" # noqa
|
||||
)
|
||||
return re.match(pattern, sub, re.IGNORECASE) is not None
|
||||
|
||||
|
||||
Vapid = Vapid02
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
|
||||
from typing import cast
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||
|
||||
|
||||
def prompt(prompt: str) -> str:
|
||||
# Not sure why, but python3 throws and exception if you try to
|
||||
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||
try:
|
||||
return input(prompt)
|
||||
except NameError:
|
||||
return raw_input(prompt) # noqa: F821
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||
parser.add_argument("--sign", "-s", help="claims file to sign")
|
||||
parser.add_argument(
|
||||
"--gen", "-g", help="generate new key pairs", default=False, action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version2",
|
||||
"-2",
|
||||
help="use RFC8292 VAPID spec",
|
||||
default=True,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version1",
|
||||
"-1",
|
||||
help="use VAPID spec Draft-01",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", help="dump as json", default=False, action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-strict",
|
||||
help='Do not be strict about "sub"',
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--applicationServerKey",
|
||||
help="show applicationServerKey value",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--private-key", "-k", help="private key pem file", default="private_key.pem"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Added to solve 2.7 => 3.* incompatibility
|
||||
Vapid = Vapid02
|
||||
if args.version1:
|
||||
Vapid = Vapid01
|
||||
if args.gen or not os.path.exists(args.private_key):
|
||||
if not args.gen:
|
||||
print("No private key file found.")
|
||||
answer = None
|
||||
while answer not in ["y", "n"]:
|
||||
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||
if not answer:
|
||||
answer = "y"
|
||||
answer = answer.lower()[0]
|
||||
if answer == "n":
|
||||
print("Sorry, can't do much for you then.")
|
||||
exit(1)
|
||||
vapid = Vapid(conf=args)
|
||||
vapid.generate_keys()
|
||||
print("Generating private_key.pem")
|
||||
vapid.save_key("private_key.pem")
|
||||
print("Generating public_key.pem")
|
||||
vapid.save_public_key("public_key.pem")
|
||||
vapid = Vapid.from_file(args.private_key)
|
||||
claim_file = args.sign
|
||||
result = dict()
|
||||
if args.applicationServerKey:
|
||||
raw_pub = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub)))
|
||||
if claim_file:
|
||||
if not os.path.exists(claim_file):
|
||||
print("No {} file found.".format(claim_file))
|
||||
print(
|
||||
"""
|
||||
The claims file should be a JSON formatted file that holds the
|
||||
information that describes you. There are three elements in the claims
|
||||
file you'll need:
|
||||
|
||||
"sub" This is your site's admin email address
|
||||
(e.g. "mailto:admin@example.com")
|
||||
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||
have one, I'll add one that expires in 24 hours.
|
||||
|
||||
You're also welcome to add additional fields to the claims which could be
|
||||
helpful for the Push Service operations team to pass along to your operations
|
||||
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||
these values short to prevent some servers from rejecting the transaction due
|
||||
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||
|
||||
For example, a claims.json file could contain:
|
||||
|
||||
{"sub": "mailto:admin@example.com"}
|
||||
"""
|
||||
)
|
||||
exit(1)
|
||||
try:
|
||||
claims = json.loads(open(claim_file).read())
|
||||
result.update(vapid.sign(claims))
|
||||
except Exception as exc:
|
||||
print("Crap, something went wrong: {}".format(repr(exc)))
|
||||
raise exc
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
return
|
||||
print("Include the following headers in your request:\n")
|
||||
for key, value in result.items():
|
||||
print("{}: {}\n".format(key, value))
|
||||
print("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import binascii
|
||||
import json
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
from py_vapid.utils import b64urldecode, b64urlencode, num_to_bytes
|
||||
|
||||
|
||||
def extract_signature(auth):
|
||||
"""Extracts the payload and signature from a JWT, converting from RFC7518
|
||||
to RFC 3279
|
||||
|
||||
:param auth: A JWT Authorization Token.
|
||||
:type auth: str
|
||||
|
||||
:return tuple containing the signature material and signature
|
||||
|
||||
"""
|
||||
payload, asig = auth.encode('utf8').rsplit(b'.', 1)
|
||||
sig = b64urldecode(asig)
|
||||
if len(sig) != 64:
|
||||
raise InvalidSignature()
|
||||
|
||||
encoded = utils.encode_dss_signature(
|
||||
s=int(binascii.hexlify(sig[32:]), 16),
|
||||
r=int(binascii.hexlify(sig[:32]), 16)
|
||||
)
|
||||
return payload, encoded
|
||||
|
||||
|
||||
def decode(token, key):
|
||||
"""Decode a web token into an assertion dictionary
|
||||
|
||||
:param token: VAPID auth token
|
||||
:type token: str
|
||||
:param key: bitarray containing the public key
|
||||
:type key: str
|
||||
|
||||
:return dict of the VAPID claims
|
||||
|
||||
:raise InvalidSignature
|
||||
|
||||
"""
|
||||
try:
|
||||
sig_material, signature = extract_signature(token)
|
||||
dkey = b64urldecode(key.encode('utf8'))
|
||||
pkey = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
ec.SECP256R1(),
|
||||
dkey,
|
||||
)
|
||||
pkey.verify(
|
||||
signature,
|
||||
sig_material,
|
||||
ec.ECDSA(hashes.SHA256())
|
||||
)
|
||||
return json.loads(
|
||||
b64urldecode(sig_material.split(b'.')[1]).decode('utf8')
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise
|
||||
except(ValueError, TypeError, binascii.Error):
|
||||
raise InvalidSignature()
|
||||
|
||||
|
||||
def sign(claims, key):
|
||||
"""Sign the claims
|
||||
|
||||
:param claims: list of JWS claims
|
||||
:type claims: dict
|
||||
:param key: Private key for signing
|
||||
:type key: ec.EllipticCurvePrivateKey
|
||||
:param algorithm: JWT "alg" descriptor
|
||||
:type algorithm: str
|
||||
|
||||
"""
|
||||
header = b64urlencode(b"""{"typ":"JWT","alg":"ES256"}""")
|
||||
# Unfortunately, chrome seems to require the claims to be sorted.
|
||||
claims = b64urlencode(json.dumps(claims,
|
||||
separators=(',', ':'),
|
||||
sort_keys=True).encode('utf8'))
|
||||
token = "{}.{}".format(header, claims)
|
||||
rsig = key.sign(token.encode('utf8'), ec.ECDSA(hashes.SHA256()))
|
||||
(r, s) = utils.decode_dss_signature(rsig)
|
||||
sig = b64urlencode(num_to_bytes(r, 32) + num_to_bytes(s, 32))
|
||||
return "{}.{}".format(token, sig)
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from py_vapid import Vapid01, Vapid02, b64urlencode
|
||||
|
||||
|
||||
def prompt(prompt):
|
||||
# Not sure why, but python3 throws and exception if you try to
|
||||
# monkeypatch for this. It's ugly, but this seems to play nicer.
|
||||
try:
|
||||
return input(prompt)
|
||||
except NameError:
|
||||
return raw_input(prompt) # noqa: F821
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VAPID tool")
|
||||
parser.add_argument('--sign', '-s', help='claims file to sign')
|
||||
parser.add_argument('--gen', '-g', help='generate new key pairs',
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--version2', '-2', help="use RFC8292 VAPID spec",
|
||||
default=True, action="store_true")
|
||||
parser.add_argument('--version1', '-1', help="use VAPID spec Draft-01",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--json', help="dump as json",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--no-strict', help='Do not be strict about "sub"',
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--applicationServerKey',
|
||||
help="show applicationServerKey value",
|
||||
default=False, action="store_true")
|
||||
parser.add_argument('--private-key', '-k', help='private key pem file',
|
||||
default="private_key.pem")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Added to solve 2.7 => 3.* incompatibility
|
||||
Vapid = Vapid02
|
||||
if args.version1:
|
||||
Vapid = Vapid01
|
||||
if args.gen or not os.path.exists(args.private_key):
|
||||
if not args.gen:
|
||||
print("No private key file found.")
|
||||
answer = None
|
||||
while answer not in ['y', 'n']:
|
||||
answer = prompt("Do you want me to create one for you? (Y/n)")
|
||||
if not answer:
|
||||
answer = 'y'
|
||||
answer = answer.lower()[0]
|
||||
if answer == 'n':
|
||||
print("Sorry, can't do much for you then.")
|
||||
exit(1)
|
||||
vapid = Vapid(conf=args)
|
||||
vapid.generate_keys()
|
||||
print("Generating private_key.pem")
|
||||
vapid.save_key('private_key.pem')
|
||||
print("Generating public_key.pem")
|
||||
vapid.save_public_key('public_key.pem')
|
||||
vapid = Vapid.from_file(args.private_key)
|
||||
claim_file = args.sign
|
||||
result = dict()
|
||||
if args.applicationServerKey:
|
||||
raw_pub = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
print("Application Server Key = {}\n\n".format(
|
||||
b64urlencode(raw_pub)))
|
||||
if claim_file:
|
||||
if not os.path.exists(claim_file):
|
||||
print("No {} file found.".format(claim_file))
|
||||
print("""
|
||||
The claims file should be a JSON formatted file that holds the
|
||||
information that describes you. There are three elements in the claims
|
||||
file you'll need:
|
||||
|
||||
"sub" This is your site's admin email address
|
||||
(e.g. "mailto:admin@example.com")
|
||||
"exp" This is the expiration time for the claim in seconds. If you don't
|
||||
have one, I'll add one that expires in 24 hours.
|
||||
|
||||
You're also welcome to add additional fields to the claims which could be
|
||||
helpful for the Push Service operations team to pass along to your operations
|
||||
team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep
|
||||
these values short to prevent some servers from rejecting the transaction due
|
||||
to overly large headers. See https://jwt.io/introduction/ for details.
|
||||
|
||||
For example, a claims.json file could contain:
|
||||
|
||||
{"sub": "mailto:admin@example.com"}
|
||||
""")
|
||||
exit(1)
|
||||
try:
|
||||
claims = json.loads(open(claim_file).read())
|
||||
result.update(vapid.sign(claims))
|
||||
except Exception as exc:
|
||||
print("Crap, something went wrong: {}".format(repr(exc)))
|
||||
raise exc
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
return
|
||||
print("Include the following headers in your request:\n")
|
||||
for key, value in result.items():
|
||||
print("{}: {}\n".format(key, value))
|
||||
print("\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
|
||||
def b64urldecode(data):
|
||||
"""Decodes an unpadded Base64url-encoded string.
|
||||
|
||||
:param data: data bytes to decode
|
||||
:type data: bytes
|
||||
|
||||
:returns bytes
|
||||
|
||||
"""
|
||||
return base64.urlsafe_b64decode(data + b"===="[len(data) % 4:])
|
||||
|
||||
|
||||
def b64urlencode(data):
|
||||
"""Encode a byte string into a Base64url-encoded string without padding
|
||||
|
||||
:param data: data bytes to encode
|
||||
:type data: bytes
|
||||
|
||||
:returns str
|
||||
|
||||
"""
|
||||
return base64.urlsafe_b64encode(data).replace(b'=', b'').decode('utf8')
|
||||
|
||||
|
||||
def num_to_bytes(n, pad_to):
|
||||
"""Returns the byte representation of an integer, in big-endian order.
|
||||
:param n: The integer to encode.
|
||||
:type n: int
|
||||
:param pad_to: Expected length of result, zeropad if necessary.
|
||||
:type pad_to: int
|
||||
:returns bytes
|
||||
"""
|
||||
h = '%x' % n
|
||||
r = binascii.unhexlify('0' * (len(h) % 2) + h)
|
||||
return b'\x00' * (pad_to - len(r)) + r
|
||||
Reference in New Issue
Block a user