From f14112b4b1f70ab5c188e7f581f21c6f135ec2c4 Mon Sep 17 00:00:00 2001 From: j0904 Date: Sat, 1 Aug 2026 12:03:39 +0200 Subject: [PATCH 01/19] fix: enable SMS send/deliver and search on self-hosted deployment - fix(web): refresh Firebase ID token via onIdTokenChanged (API calls 401'd after ~1h) - fix(web): nginx try_files to avoid trailing-slash 301 leaking the private port - fix(deploy): correct Caddy /v1 pass-through and /api rewrite; init system user - fix(api): make Cloudflare Turnstile optional when secret key is unconfigured - feat(android): Docker build for the release APK with release signing config - test(e2e): Playwright suite against the deployed site (real Chrome for Google sign-in) - test(api): api-test.sh to send + poll + list received SMS - docs: add 1&1 SMS-Flat usage restrictions --- .dockerignore | 7 + .gitignore | 9 + README.md | 13 + android/Dockerfile | 26 ++ android/app/build.gradle.kts | 20 ++ android/app/google-services.json | 24 +- android/gradlew | 0 .../validators/message_handler_validator.go | 100 +++---- .../validators/turnstile_token_validator.go | 10 + deploy/deploy.sh | 271 ++++++++++++++++++ tests/api/api-test.sh | 80 ++++++ tests/e2e/README.md | 78 +++++ tests/e2e/helpers/auth.ts | 85 ++++++ tests/e2e/package-lock.json | 78 +++++ tests/e2e/package.json | 15 + tests/e2e/playwright.config.ts | 41 +++ tests/e2e/tests/auth.spec.ts | 57 ++++ tests/e2e/tests/smoke.spec.ts | 26 ++ web/Dockerfile | 4 +- web/app/pages/search-messages/index.vue | 10 +- web/app/plugins/firebase.client.ts | 7 +- web/nginx.conf | 4 +- web/package.json | 3 +- 23 files changed, 895 insertions(+), 73 deletions(-) create mode 100644 .dockerignore create mode 100644 android/Dockerfile mode change 100644 => 100755 android/gradlew create mode 100755 deploy/deploy.sh create mode 100755 tests/api/api-test.sh create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/helpers/auth.ts create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/tests/auth.spec.ts create mode 100644 tests/e2e/tests/smoke.spec.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2b53127f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!android/** +!.git/** +android/local.properties +android/.gradle/ +android/app/build/ +android/app/build.gradle.kts.bak diff --git a/.gitignore b/.gitignore index e15d590a9..043e00896 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ android/app/release/ tests/firebase-credentials.json tests/emulator/emulator.exe +tests/e2e/node_modules/ +tests/e2e/test-results/ +tests/e2e/playwright-report/ +tests/e2e/playwright/.cache/ SECURITY_AUDIT_REPORT.md *.exe @@ -17,3 +21,8 @@ docs/ .output .agents/ skills-lock.json + +android/local.properties +android/app/keystore.properties +android/app/httpsms-release.keystore +android/app/build/ diff --git a/README.md b/README.md index 14690530a..54b76737f 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,19 @@ docker compose down -v Integration tests also run automatically in CI on every push/PR to `main`. +## 1&1 SMS-Flat — Usage Restrictions (Germany) + +Um Missbrauch vorzubeugen, gelten für alle Tarife mit SMS-Flat als Tarifkomponente +oder als zubuchbare Option folgende zusätzliche Regelungen: + +- Der automatisierte Versand von SMS ist unzulässig. +- Der Versand von SMS an mehr als 50 Empfänger gleichzeitig ist unzulässig. +- Die Nutzung der Zusatzdienstleistung mit einem SMS-Modemzugang und/oder mit SMS zu E-Mail + oder Fax ist unzulässig. + +1&1 geht weiterhin von einer missbräuchlichen Nutzung bei mehr als 3.000 SMS pro Monat aus. +In diesem Fall behält sich 1&1 vor, das Vertragsverhältnis außerordentlich zu kündigen. + ## License This project is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE Version 3 - see the [LICENSE](LICENSE) file for details diff --git a/android/Dockerfile b/android/Dockerfile new file mode 100644 index 000000000..ec14e2755 --- /dev/null +++ b/android/Dockerfile @@ -0,0 +1,26 @@ +FROM eclipse-temurin:21-jdk + +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools/36.0.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip wget git \ + && rm -rf /var/lib/apt/lists/* + +# Android command-line tools (rev 12.0) +RUN mkdir -p $ANDROID_HOME/cmdline-tools \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip -O /tmp/cmdline.zip \ + && unzip -q /tmp/cmdline.zip -d $ANDROID_HOME/cmdline-tools \ + && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ + && rm /tmp/cmdline.zip + +# Accept licenses and install platform 36 + build-tools 36.0.0 +RUN yes | sdkmanager --licenses > /dev/null 2>&1 \ + && sdkmanager "platforms;android-36" "build-tools;36.0.0" platform-tools > /dev/null + +WORKDIR /app +COPY android/ /app/ +COPY .git/ /app/.git/ + +RUN chmod +x gradlew \ + && ./gradlew assembleRelease --no-daemon diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 15857e708..a958b6956 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,16 @@ plugins { id("io.sentry.android.gradle") version "6.2.0" } +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties().apply { + val propsFile = rootProject.file("app/keystore.properties") + if (propsFile.exists()) { + FileInputStream(propsFile).use { load(it) } + } +} + val gitHash = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") }.standardOutput.asText.map { it.trim() } @@ -28,6 +38,16 @@ android { manifestPlaceholders["sentryEnvironment"] = "production" isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = if (keystoreProperties.isNotEmpty()) { + signingConfigs.create("release") { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } else { + null + } } } compileOptions { diff --git a/android/app/google-services.json b/android/app/google-services.json index 4db4c0640..b9fb4dc80 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,41 +1,33 @@ { "project_info": { - "project_number": "877524083399", - "project_id": "httpsms-86c51", - "storage_bucket": "httpsms-86c51.appspot.com" + "project_number": "460506773943", + "project_id": "httpsms-4b379", + "storage_bucket": "httpsms-4b379.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:877524083399:android:5ceb08a909fd24a96514e2", + "mobilesdk_app_id": "1:460506773943:android:15e87ce1566139e9ce5032", "android_client_info": { "package_name": "com.httpsms" } }, "oauth_client": [ { - "client_id": "877524083399-londfmd80qqlb2gebpbhctavlmqtla3b.apps.googleusercontent.com", - "client_type": 1, - "android_info": { - "package_name": "com.httpsms", - "certificate_hash": "58df9f7489b8bb5e3cd57f01a03d5e0068d8c185" - } - }, - { - "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", + "client_id": "460506773943-224prpuv219rgh9o5l5n8874so7v1sn7.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyBKiokmk5qhE2Zc5F97KWzOvRe8FDDuxDg" + "current_key": "AIzaSyByG3Yp7r-Mot2BOcZY8xciI2IhQtZK6qw" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", + "client_id": "460506773943-224prpuv219rgh9o5l5n8874so7v1sn7.apps.googleusercontent.com", "client_type": 3 } ] @@ -44,4 +36,4 @@ } ], "configuration_version": "1" -} +} \ No newline at end of file diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index da6a7a1d6..9d98620eb 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -284,56 +284,58 @@ func (validator MessageHandlerValidator) ValidateMessageIndex(_ context.Context, // ValidateMessageSearch validates the requests.MessageSearch request func (validator MessageHandlerValidator) ValidateMessageSearch(ctx context.Context, request requests.MessageSearch) url.Values { - v := govalidator.New(govalidator.Options{ - Data: &request, - Rules: govalidator.MapData{ - "owners": []string{ - multipleContactPhoneNumberRule, - }, - "types": []string{ - multipleInRule + ":" + strings.Join([]string{ - entities.MessageTypeCallMissed, - entities.MessageTypeMobileOriginated, - entities.MessageTypeMobileTerminated, - }, ","), - }, - "statuses": []string{ - multipleInRule + ":" + strings.Join([]string{ - entities.MessageStatusPending, - entities.MessageStatusSent, - entities.MessageStatusDelivered, - entities.MessageStatusFailed, - entities.MessageStatusExpired, - entities.MessageStatusReceived, - }, ","), - }, - "sort_by": []string{ - "in:" + strings.Join([]string{ - "created_at", - "owner", - "contact", - "type", - "status", - }, ","), - }, - "limit": []string{ - "required", - "numeric", - "min:1", - "max:200", - }, - "skip": []string{ - "required", - "numeric", - "min:0", - }, - "query": []string{ - "max:50", - }, - "token": []string{ - "required", - }, + rules := govalidator.MapData{ + "owners": []string{ + multipleContactPhoneNumberRule, + }, + "types": []string{ + multipleInRule + ":" + strings.Join([]string{ + entities.MessageTypeCallMissed, + entities.MessageTypeMobileOriginated, + entities.MessageTypeMobileTerminated, + }, ","), }, + "statuses": []string{ + multipleInRule + ":" + strings.Join([]string{ + entities.MessageStatusPending, + entities.MessageStatusSent, + entities.MessageStatusDelivered, + entities.MessageStatusFailed, + entities.MessageStatusExpired, + entities.MessageStatusReceived, + }, ","), + }, + "sort_by": []string{ + "in:" + strings.Join([]string{ + "created_at", + "owner", + "contact", + "type", + "status", + }, ","), + }, + "limit": []string{ + "required", + "numeric", + "min:1", + "max:200", + }, + "skip": []string{ + "required", + "numeric", + "min:0", + }, + "query": []string{ + "max:50", + }, + } + if validator.tokenValidator.Enabled() { + rules["token"] = []string{"required"} + } + + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: rules, }) errors := v.ValidateStruct() diff --git a/api/pkg/validators/turnstile_token_validator.go b/api/pkg/validators/turnstile_token_validator.go index 788e6dd39..b8a946297 100644 --- a/api/pkg/validators/turnstile_token_validator.go +++ b/api/pkg/validators/turnstile_token_validator.go @@ -43,12 +43,22 @@ func NewTurnstileTokenValidator(logger telemetry.Logger, tracer telemetry.Tracer } } +// Enabled reports whether Turnstile server-side validation is configured. +func (v *TurnstileTokenValidator) Enabled() bool { + return v.secretKey != "" +} + // ValidateToken validates the cloudflare turnstile token // https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ func (v *TurnstileTokenValidator) ValidateToken(ctx context.Context, ipAddress, token string) bool { ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger) defer span.End() + // Turnstile is optional: if no secret key is configured, skip validation. + if v.secretKey == "" { + return true + } + payload, err := json.Marshal(map[string]string{ "secret": v.secretKey, "response": token, diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 000000000..f1c88a374 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,271 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +TARGET_VM="${TARGET_VM:-s1001.bigt.ai}" +TARGET_IP="${TARGET_IP:-85.214.37.95}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/oraclevpc.key}" +SSH_USER="${SSH_USER:-root}" +DOMAIN="${DOMAIN:-httpsms.bigt.ai}" +REGISTRY="${REGISTRY:-ghcr.io/j0904/httpsms}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +API_IMAGE="${REGISTRY}-api:${IMAGE_TAG}" +WEB_IMAGE="${REGISTRY}-web:${IMAGE_TAG}" + +unset SSH_AUTH_SOCK +unset SSH_ASKPASS_REQUIRE +export DISPLAY=none +export GIT_TERMINAL_PROMPT=0 +export GCM_INTERACTIVE=never + +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes -o IdentitiesOnly=yes" +REMOTE="${SSH_USER}@${TARGET_IP}" +REMOTE_DIR="${REMOTE_DIR:-/root/httpsms}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +if [ ! -f "${SSH_KEY}" ]; then + echo "Error: SSH key not found at ${SSH_KEY}" + exit 1 +fi + +test_ssh() { + echo "Testing SSH to ${REMOTE}..." + ssh ${SSH_OPTS} -i ${SSH_KEY} ${REMOTE} "echo OK" 2>/dev/null && echo "SSH OK" || { echo "SSH failed"; exit 1; } +} + +ensure_networks() { + echo -e "${YELLOW}Ensuring Docker networks...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} \ + "sudo docker network inspect caddy_net >/dev/null 2>&1 || sudo docker network create caddy_net --driver bridge --subnet 172.20.6.0/24" 2>&1 +} + +sync_env() { + echo -e "${YELLOW}Syncing .env files...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} "mkdir -p ${REMOTE_DIR}" + scp -i ${SSH_KEY} ${SSH_OPTS} "${PROJECT_DIR}/api/.env" "${REMOTE}:${REMOTE_DIR}/api.env" + scp -i ${SSH_KEY} ${SSH_OPTS} "${PROJECT_DIR}/web/.env" "${REMOTE}:${REMOTE_DIR}/web.env" +} + +write_compose() { + echo -e "${YELLOW}Writing docker-compose.yml...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} "cat > ${REMOTE_DIR}/docker-compose.yml" << COMPOSE +services: + postgres: + image: postgres:alpine + container_name: httpsms-db + environment: + POSTGRES_DB: httpsms + POSTGRES_PASSWORD: dbpassword + POSTGRES_USER: dbusername + volumes: + - httpsms_postgres:/var/lib/postgresql + restart: on-failure + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dbusername -d httpsms"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 10s + networks: + - httpsms_net + + redis: + image: redis:latest + container_name: httpsms-redis + command: redis-server + volumes: + - httpsms_redis:/var/lib/redis + restart: on-failure + networks: + - httpsms_net + + api: + image: ${API_IMAGE} + container_name: httpsms-api + env_file: + - /root/httpsms/api.env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - httpsms_net + - caddy_net + + web: + image: ${WEB_IMAGE} + container_name: httpsms-web + restart: unless-stopped + networks: + - httpsms_net + - caddy_net + +volumes: + httpsms_postgres: + httpsms_redis: + +networks: + httpsms_net: + driver: bridge + caddy_net: + external: true +COMPOSE +} + +start() { + echo -e "${YELLOW}Pulling and starting containers on server...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} << ENDSSH + cd ${REMOTE_DIR} + sudo docker compose pull + sudo docker compose up -d --remove-orphans + sleep 5 + sudo docker ps --filter "name=httpsms-" +ENDSSH +} + +configure_caddy() { + echo -e "${YELLOW}Configuring Caddy for ${DOMAIN}...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} << ENDSSH + API_IP=\$(sudo docker inspect httpsms-api --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{" "}}{{end}}' 2>/dev/null | grep -oE '172\.20\.6\.[0-9]+' | head -1) + WEB_IP=\$(sudo docker inspect httpsms-web --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{" "}}{{end}}' 2>/dev/null | grep -oE '172\.20\.6\.[0-9]+' | head -1) + + if [ -z "\$API_IP" ] || [ -z "\$WEB_IP" ]; then + echo "Error: Cannot get container IPs. Are they running?" + exit 1 + fi + + echo "API container IP: \$API_IP" + echo "Web container IP: \$WEB_IP" + + sudo tee /etc/caddy/Caddyfile.d/httpsms.caddy > /dev/null << CADDYEOF +httpsms.bigt.ai { + handle /health { + reverse_proxy \${API_IP}:8000 + } + handle_path /api/* { + reverse_proxy \${API_IP}:8000 + } + handle /v1/* { + reverse_proxy \${API_IP}:8000 + } + handle { + reverse_proxy \${WEB_IP}:3000 + } +} +CADDYEOF + + sudo systemctl reload caddy +ENDSSH +} + +build_and_push() { + echo -e "${YELLOW}Building and pushing images to ${REGISTRY}...${NC}" + + echo "Building API image..." + cd "${PROJECT_DIR}/api" + docker build -t "${API_IMAGE}" . + docker push "${API_IMAGE}" + + echo "Building Web image..." + cd "${PROJECT_DIR}/web" + docker build -t "${WEB_IMAGE}" . + docker push "${WEB_IMAGE}" + + echo -e "${GREEN}Done: ${API_IMAGE} / ${WEB_IMAGE}${NC}" +} + +init_db() { + echo -e "${YELLOW}=== PostgreSQL Setup ===${NC}" + echo "The database will be created automatically on first start." + echo "" + echo "After first deploy, create the system user:" + echo " docker exec -i httpsms-db psql -U dbusername -d httpsms << SQL" + echo " INSERT INTO users (id, api_key, email)" + echo " VALUES ('system-user-id', 'system-user-api-key', 'system@httpsms.bigt.ai');" + echo " SQL" + echo "" + echo "Then set these in api.env:" + echo " EVENTS_QUEUE_USER_ID=system-user-id" + echo " EVENTS_QUEUE_USER_API_KEY=system-user-api-key" + echo "" + echo "Run '$0 deploy' again after updating api.env to restart with new config." +} + +status() { + echo -e "${GREEN}=== httpsms Status ===${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} << 'ENDSSH' + echo "--- Containers ---" + sudo docker ps --filter "name=httpsms-" --format "table {{.Names}}\t{{.Status}}" + echo "" + echo "--- Caddy ---" + cat /etc/caddy/Caddyfile.d/httpsms.caddy 2>/dev/null || echo "No Caddy config" + echo "" + sudo systemctl is-active caddy && echo "Caddy: active" || echo "Caddy: inactive" +ENDSSH +} + +cleanup() { + echo -e "${YELLOW}Stopping and removing httpsms containers...${NC}" + ssh -i ${SSH_KEY} ${SSH_OPTS} ${REMOTE} << ENDSSH + cd ${REMOTE_DIR} 2>/dev/null + sudo docker compose down -v 2>/dev/null || true + sudo docker rm -f httpsms-api httpsms-web httpsms-db httpsms-redis 2>/dev/null || true + sudo rm -f /etc/caddy/Caddyfile.d/httpsms.caddy + sudo systemctl reload caddy 2>/dev/null || true +ENDSSH + echo -e "${GREEN}Cleanup done.${NC}" +} + +main() { + local ACTION="${1:-deploy}" + + echo -e "${GREEN}=== httpsms Deployment ===${NC}" + echo -e "Target: ${YELLOW}${TARGET_VM} (${TARGET_IP})${NC}" + echo -e "Domain: ${YELLOW}${DOMAIN}${NC}" + echo -e "Images: ${YELLOW}${API_IMAGE} / ${WEB_IMAGE}${NC}" + + case "${ACTION}" in + build) + build_and_push + ;; + deploy) + test_ssh + ensure_networks + sync_env + write_compose + start + configure_caddy + init_db + echo -e "\n${GREEN}Deployed! Frontend: https://${DOMAIN}, API: https://${DOMAIN}/v1/${NC}" + ;; + redeploy) + test_ssh + start + echo -e "${GREEN}Containers updated.${NC}" + ;; + status) + status + ;; + caddy) + configure_caddy + echo -e "${GREEN}Caddy config updated.${NC}" + ;; + cleanup) + cleanup + ;; + *) + echo "Usage: $0 {build|deploy|redeploy|status|caddy|cleanup}" + exit 1 + ;; + esac +} + +main "$@" diff --git a/tests/api/api-test.sh b/tests/api/api-test.sh new file mode 100755 index 000000000..5061c0fd2 --- /dev/null +++ b/tests/api/api-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# API smoke test for a self-hosted httpSMS instance. +# +# Sends an SMS via the API, polls its status, and searches received SMS. +# +# Usage: +# API_KEY=uk_... [FROM=+4915229268907] [TO=+4915...] [BASE_URL=https://httpsms.bigt.ai] ./api-test.sh + +set -euo pipefail + +API_KEY="${API_KEY:-}" +BASE_URL="${BASE_URL:-https://httpsms.bigt.ai}" +FROM="${FROM:-+4915229268907}" +TO="${TO:-}" +CONTENT="${CONTENT:-Hello from httpSMS API test $(date +%s)}" + +if [ -z "$API_KEY" ]; then + echo "API_KEY is required" >&2 + exit 1 +fi +if [ -z "$TO" ]; then + echo "TO (recipient number) is required" >&2 + exit 1 +fi + +echo "==> Sending SMS from $FROM to $TO" +SEND_HTTP_CODE=$(curl -s -o /tmp/opencode/api-send.json -w "%{http_code}" -X POST "$BASE_URL/v1/messages/send" \ + -H "x-api-key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"from\":\"$FROM\",\"to\":\"$TO\",\"content\":\"$CONTENT\"}") +echo " HTTP $SEND_HTTP_CODE" +if [ "$SEND_HTTP_CODE" != "200" ]; then + echo "FAIL: send request failed:" >&2 + cat /tmp/opencode/api-send.json >&2; echo >&2 + exit 1 +fi +SEND_RESPONSE=$(cat /tmp/opencode/api-send.json) +echo "$SEND_RESPONSE" + +MESSAGE_ID=$(echo "$SEND_RESPONSE" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])") +echo "==> Message ID: $MESSAGE_ID" + +echo "==> Polling message status" +STATUS="" +for _ in $(seq 1 60); do + STATUS=$(curl -sf "$BASE_URL/v1/messages/$MESSAGE_ID" -H "x-api-key: $API_KEY" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['status'])") + echo " status: $STATUS" + case "$STATUS" in + delivered) break ;; + failed|expired) break ;; + esac + sleep 5 +done + +case "$STATUS" in + delivered) echo "OK: message delivered" ;; + sent) echo "OK: message sent (delivery receipt pending)" ;; + failed|expired) echo "FAIL: message ended in $STATUS" >&2; exit 1 ;; + *) echo "FAIL: timed out waiting for status (last: $STATUS)" >&2; exit 1 ;; +esac + +echo "" +echo "==> Searching received SMS (mobile-originated, latest 10)" +curl -sf "$BASE_URL/v1/messages/search?type=mobile-originated&limit=10&sort_by=created_at&sort_descending=true" \ + -H "x-api-key: $API_KEY" \ + | python3 -c " +import sys,json +data=json.load(sys.stdin)['data'] +if not data: + print(' (no received messages found)') +for m in data: + print(' {} | {} | {} | {}'.format(m['created_at'], m['owner'], m['contact'], m['content'][:60])) +" + +echo "" +echo "==> All received messages count" +curl -sf "$BASE_URL/v1/messages/search?type=mobile-originated&limit=1&sort_by=created_at&sort_descending=true" \ + -H "x-api-key: $API_KEY" > /dev/null \ + && echo " search endpoint OK" diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..b64aead41 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,78 @@ +# Browser E2E Tests (Playwright) + +End-to-end browser tests for the deployed web app. By default they run against +`https://httpsms.bigt.ai` (the primary deploy target), but any base URL can be +used via `E2E_BASE_URL`. + +## Setup + +```sh +cd tests/e2e +npm install +npx playwright install chromium +``` + +## Running + +```sh +npm test # all tests (headed: npm run test:headed) +``` + +Config: + +| Env var | Default | Description | +| ---------------- | -------------------------- | -------------------------------------------- | +| `E2E_BASE_URL` | `https://httpsms.bigt.ai` | Web app base URL to test against | +| `E2E_CHANNEL` | `chrome` | Browser to drive. Defaults to real Google Chrome (needed for the Google OAuth test). Set to `chromium` to use the bundled browser | +| `E2E_HEADLESS` | `false` | Set to `true` to run headless. Headed needs a display (`DISPLAY`) | +| `E2E_PASSWORD` | `e2e-password-123` | Password used for email sign-up/sign-in | +| `E2E_EMAIL` | — | Optional: existing account for the sign-in test. Sign-up tests use a fresh random email every run and need **no** pre-provisioned account | +| `E2E_GOOGLE_EMAIL` / `E2E_GOOGLE_PASSWORD` | — | Credentials used by the Google sign-in test. When unset the test is skipped | + +Example: + +```sh +# full default suite (real Chrome; Google sign-in runs if creds are set) +E2E_GOOGLE_EMAIL=you@gmail.com E2E_GOOGLE_PASSWORD='...' npm test + +# CI / headless (bundled chromium, Google test skipped) +E2E_CHANNEL=chromium E2E_HEADLESS=true npx playwright test +``` + +## Auth & DB user + +The app authenticates via Firebase (Google/GitHub popup or email/password). +There is **no separate DB user to provision** for tests: + +- Google/Firebase sign-in only creates an identity in Firebase Auth. +- The **DB user row is created lazily** on the first authenticated API call + (e.g. `GET /v1/users/me` right after login) via `UserService.Get` → + `UserRepository.LoadOrStore` (`api/pkg/repositories/gorm_user_repository.go`), + which also generates the `uk_`-prefixed API key. +- The `auth.spec.ts` "auto-creates the DB user" test proves this: after + email sign-up it opens `/settings` and asserts a `uk_` API key is present and + the account email is shown. + +## Deployment prerequisite + +The app routes (`/login`, `/threads`, `/settings`) and API routes (`/v1/*`) must +be reachable at the base URL. The host nginx currently 301-redirects unknown +paths to `http://httpsms.bigt.ai:3000` (which returns 404), so only the landing +page and `/health` respond. Fix the routing (serve `/index.html` for SPA routes, +proxy `/v1/*` to the API) before running the auth specs. + +## Notes on Google sign-in + +Google blocks OAuth sign-in from automated browsers ("This browser or app may +not be secure"), so the suite defaults to real Google Chrome (`E2E_CHANNEL=chrome`) +and the config passes `--disable-blink-features=AutomationControlled` plus +masks `navigator.webdriver` via a context init script. + +- The `google sign-in` test is part of the default run. It only skips when + `E2E_GOOGLE_EMAIL`/`E2E_GOOGLE_PASSWORD` are unset, or when a non-Chrome + browser (`E2E_CHANNEL=chromium`) is used. +- For a brand-new (unused) Google account there may be additional verification + steps that the test can't automate; a warmed-up account with a saved session + is most reliable. +- Prefer the email sign-up flow for pure CI; it exercises the exact same + Firebase → DB user path as Google sign-in. diff --git a/tests/e2e/helpers/auth.ts b/tests/e2e/helpers/auth.ts new file mode 100644 index 000000000..a867dd880 --- /dev/null +++ b/tests/e2e/helpers/auth.ts @@ -0,0 +1,85 @@ +import { expect, Page } from '@playwright/test' + +const LAST_LOGIN_METHOD_KEY = 'httpsms_last_login_method' + +export function randomEmail() { + const suffix = Math.random().toString(36).slice(2, 10) + return `e2e-${suffix}@httpsms.bigt.ai` +} + +export async function signUpWithEmail(page: Page, email: string, password: string) { + await page.goto('/login') + await expect(page.getByText('Welcome')).toBeVisible() + + await page.getByRole('button', { name: 'Continue with email' }).click() + await page.getByRole('button', { name: 'No account? Sign Up' }).click() + + const emailInput = page.getByLabel('Email Address') + await emailInput.fill(email) + await page.getByLabel('Password').fill(password) + await page.getByRole('button', { name: 'Sign Up' }).click() + + await expect(page).toHaveURL(/\/threads/, { timeout: 30_000 }) +} + +export async function signInWithEmail(page: Page, email: string, password: string) { + await page.goto('/login') + await expect(page.getByText('Welcome')).toBeVisible() + + await page.getByRole('button', { name: 'Continue with email' }).click() + + await page.getByLabel('Email Address').fill(email) + await page.getByLabel('Password').fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + + await expect(page).toHaveURL(/\/threads/, { timeout: 30_000 }) +} + +export async function signInWithGoogle( + page: Page, + googleEmail: string, + googlePassword: string, +) { + // Mask the automation fingerprint so Google doesn't reject the browser. + // Must be registered on the context before the popup navigates. + const context = page.context() + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }) + }) + + await page.goto('/login') + await expect(page.getByText('Welcome')).toBeVisible() + + const popupPromise = page.waitForEvent('popup', { timeout: 30_000 }) + await page.getByRole('button', { name: 'Continue with Google' }).click() + const popup = await popupPromise + await popup.waitForLoadState('domcontentloaded') + + // If a Google account is already signed in, the chooser appears instead of + // the email identifier page. Use the "Use another account" path. + if (await popup.getByRole('button', { name: 'Use another account' }).isVisible()) { + await popup.getByRole('button', { name: 'Use another account' }).click() + } + + const emailInput = popup.getByRole('textbox', { name: /email|phone/i }) + await emailInput.waitFor({ state: 'visible', timeout: 15_000 }) + await emailInput.fill(googleEmail) + await popup.getByRole('button', { name: 'Next' }).click() + + const passwordInput = popup.getByRole('textbox', { name: /password/i }) + await passwordInput.waitFor({ state: 'visible', timeout: 15_000 }) + await passwordInput.fill(googlePassword) + await popup.getByRole('button', { name: 'Next' }).click() + + await expect(page).toHaveURL(/\/threads/, { timeout: 60_000 }) + + await page.evaluate( + (key) => localStorage.setItem(key, 'google'), + LAST_LOGIN_METHOD_KEY, + ) +} + +export async function signOut(page: Page) { + await page.getByRole('button', { name: /sign out|logout|account/i }).click() + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }) +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 000000000..9e8b5c427 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "httpsms-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "httpsms-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000..50a36142f --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "httpsms-e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000..c6a5b665b --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL = process.env.E2E_BASE_URL || 'https://httpsms.bigt.ai' +// Default to real Google Chrome so the Google OAuth test works (Google blocks +// sign-in from the automated bundled Chromium). Override with +// E2E_CHANNEL=chromium to use the bundled browser, or E2E_HEADLESS=true to +// run headless. +const channel = process.env.E2E_CHANNEL || 'chrome' +const headless = process.env.E2E_HEADLESS === 'true' + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }]] + : [['list']], + timeout: 60_000, + expect: { + timeout: 15_000, + }, + use: { + baseURL, + channel, + headless, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + launchOptions: { + args: ['--disable-blink-features=AutomationControlled'], + }, + }, + projects: [ + { + name: channel === 'chromium' ? 'chromium' : 'google-chrome', + use: { ...devices['Desktop Chrome'], channel, headless }, + }, + ], +}) diff --git a/tests/e2e/tests/auth.spec.ts b/tests/e2e/tests/auth.spec.ts new file mode 100644 index 000000000..5ec6b13a6 --- /dev/null +++ b/tests/e2e/tests/auth.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test' +import { + randomEmail, + signUpWithEmail, + signInWithEmail, + signInWithGoogle, +} from '../helpers/auth' + +const E2E_PASSWORD = process.env.E2E_PASSWORD || 'e2e-password-123' + +test('sign up with email reaches the threads dashboard', async ({ page }) => { + const email = randomEmail() + await signUpWithEmail(page, email, E2E_PASSWORD) + await expect(page).toHaveTitle(/Threads/i) +}) + +test('email sign-up auto-creates the DB user with an API key', async ({ + page, +}) => { + const email = randomEmail() + await signUpWithEmail(page, email, E2E_PASSWORD) + + await page.goto('/settings') + await expect(page.getByRole('heading', { name: 'API Key' })).toBeVisible() + + const apiKeyField = page.locator('input[name="api-key"]') + await expect(apiKeyField).toBeVisible() + const apiKey = await apiKeyField.inputValue() + expect(apiKey).toMatch(/^uk_/) + + await expect(page.getByText(email).first()).toBeVisible() +}) + +test('existing account can sign in', async ({ page }) => { + test.skip(!process.env.E2E_EMAIL, 'E2E_EMAIL not set') + + await signInWithEmail(page, process.env.E2E_EMAIL!, E2E_PASSWORD) + await expect(page).toHaveTitle(/Threads/i) +}) + +test('google sign-in', async ({ page }, testInfo) => { + test.skip( + !process.env.E2E_GOOGLE_EMAIL || !process.env.E2E_GOOGLE_PASSWORD, + 'set E2E_GOOGLE_EMAIL and E2E_GOOGLE_PASSWORD to run', + ) + test.skip( + testInfo.project.use.channel !== 'chrome', + 'Google sign-in requires real Chrome (default)', + ) + + await signInWithGoogle( + page, + process.env.E2E_GOOGLE_EMAIL!, + process.env.E2E_GOOGLE_PASSWORD!, + ) + await expect(page).toHaveTitle(/Threads/i) +}) diff --git a/tests/e2e/tests/smoke.spec.ts b/tests/e2e/tests/smoke.spec.ts new file mode 100644 index 000000000..faf6bfc8b --- /dev/null +++ b/tests/e2e/tests/smoke.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test' + +test('landing page loads for httpsms.bigt.ai', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveTitle(/httpSMS/) + await expect(page.getByText(/SMS gateway/i).first()).toBeVisible() +}) + +test('api health endpoint is reachable', async ({ request }) => { + const resp = await request.get('/health') + expect(resp.status()).toBe(200) + expect(await resp.text()).toBe('OK') +}) + +test('unauthenticated users are redirected to /login', async ({ page }) => { + await page.goto('/threads') + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText('Welcome')).toBeVisible() +}) + +test('login page shows Google, GitHub and email options', async ({ page }) => { + await page.goto('/login') + await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Continue with GitHub' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Continue with email' })).toBeVisible() +}) diff --git a/web/Dockerfile b/web/Dockerfile index f283fd32a..cadaac92d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -8,10 +8,10 @@ COPY package.json pnpm-lock.yaml ./ # Install pnpm RUN npm install -g pnpm -RUN pnpm install +RUN pnpm install --ignore-scripts && pnpm rebuild COPY . . -RUN pnpm run generate +RUN npx nuxt prepare && pnpm run generate # production stage FROM nginx:stable-alpine AS production diff --git a/web/app/pages/search-messages/index.vue b/web/app/pages/search-messages/index.vue index 485dae3b5..a6f781548 100644 --- a/web/app/pages/search-messages/index.vue +++ b/web/app/pages/search-messages/index.vue @@ -126,6 +126,13 @@ const messageStatusSelectItems = [ ] function getCaptcha(): Promise { + const siteKey = (config.public as Record) + .cloudflareTurnstileSiteKey + if (!siteKey) { + // Turnstile is optional: if no site key is configured, skip the widget. + return Promise.resolve('') + } + return new Promise((resolve, reject) => { const turnstile: Turnstile = ( window as unknown as { turnstile?: Turnstile } @@ -138,8 +145,7 @@ function getCaptcha(): Promise { turnstileWidgetId = turnstile.render('#cloudflare-turnstile', { - sitekey: (config.public as Record) - .cloudflareTurnstileSiteKey!, + sitekey: siteKey, action: 'search_messages', callback: (token) => resolve(token), 'error-callback': (error: string) => reject(error), diff --git a/web/app/plugins/firebase.client.ts b/web/app/plugins/firebase.client.ts index 31f7c31a2..67a187cfb 100644 --- a/web/app/plugins/firebase.client.ts +++ b/web/app/plugins/firebase.client.ts @@ -1,6 +1,6 @@ import { initializeApp, getApps } from 'firebase/app' import type { FirebaseApp } from 'firebase/app' -import { getAuth, onAuthStateChanged } from 'firebase/auth' +import { getAuth, onAuthStateChanged, onIdTokenChanged } from 'firebase/auth' import { useAuthStore } from '../stores/auth' export default defineNuxtPlugin(() => { @@ -37,6 +37,11 @@ export default defineNuxtPlugin(() => { onAuthStateChanged(auth, (user) => { authStore.onAuthStateChanged(user) }) + // Keep the Authorization header fresh when Firebase refreshes the ID token + // (otherwise API calls start 401ing ~1h after sign-in). + onIdTokenChanged(auth, (user) => { + authStore.onIdTokenChanged(user) + }) return { provide: { diff --git a/web/nginx.conf b/web/nginx.conf index a66ccfd7d..7a00d972c 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -1,10 +1,10 @@ server { listen 3000; - server_name localhost; + server_name _; root /usr/share/nginx/html; index index.html index.htm; location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/index.html /index.html; } } diff --git a/web/package.json b/web/package.json index 8933f49de..e7b1e43c5 100644 --- a/web/package.json +++ b/web/package.json @@ -61,5 +61,6 @@ "stylelint-config-standard": "^40.0.0", "typescript": "^5.9.3", "vuetify-nuxt-module": "^0.19.5" - } + }, + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } From 896b43212ab4e6296656dd7fafb8fc30510ebd06 Mon Sep 17 00:00:00 2001 From: e2e Date: Sun, 20 Sep 2026 17:15:53 +0200 Subject: [PATCH 02/19] feat(self-hosted): did auth via ml-dsa-87, drop firebase/gcp DID auth: - verify ML-DSA-87 (FIPS 204) did:key signatures (multicodec 0x300001, KeyBundle/SignatureBundle, domain-separated SHA-256); dual ML+SLH keys - signature-only verify: raw privkey is no longer sent or accepted, /resolve removed, and the challenge is verified (previously not) - shared Redis nonce store (multi-replica safe): single-use, non-burning - web: client-side PQ signing and key parsing; DID routes registered Self-hosted cleanup: remove Firebase/GCP (FCM push, storage, auth), the Google Cloud attachment/push services, and the Android Firebase service; Android sends SMS via a worker. --- android/app/build.gradle.kts | 4 - android/app/google-services.json | 39 - android/app/src/main/AndroidManifest.xml | 12 - .../java/com/httpsms/HttpSmsApiService.kt | 28 + .../main/java/com/httpsms/LoginActivity.kt | 34 +- .../src/main/java/com/httpsms/MainActivity.kt | 49 -- .../app/src/main/java/com/httpsms/Models.kt | 9 + ...seMessagingService.kt => SendSmsWorker.kt} | 121 +-- .../app/src/main/java/com/httpsms/Settings.kt | 43 - .../main/java/com/httpsms/SettingsActivity.kt | 1 - .../com/httpsms/worker/HeartbeatWorker.kt | 43 + android/build.gradle.kts | 3 - api/go.mod | 36 +- api/go.sum | 80 +- api/pkg/cache/cache.go | 2 + api/pkg/cache/memory_cache.go | 13 + api/pkg/cache/redis_cache.go | 15 + api/pkg/di/container.go | 191 +---- api/pkg/did/did.go | 101 +++ api/pkg/did/did_test.go | 17 + api/pkg/did/pq.go | 178 ++++ api/pkg/did/pq_test.go | 122 +++ .../testdata/mldsa87_browser_challenge.json | 6 + api/pkg/did/testdata/mldsa87_challenge.json | 7 + .../mldsa87_slhdsa_browser_challenge.json | 6 + .../testdata/mldsa87_slhdsa_challenge.json | 6 + api/pkg/handlers/did_auth_handler.go | 191 +++++ api/pkg/handlers/did_auth_handler_test.go | 303 +++++++ api/pkg/handlers/did_auth_nonce.go | 129 +++ api/pkg/handlers/did_auth_nonce_test.go | 61 ++ api/pkg/handlers/message_handler.go | 35 + api/pkg/middlewares/bearer_auth_middleware.go | 51 -- ...gle_cloud_storage_attachment_repository.go | 92 --- .../repositories/gorm_message_repository.go | 25 + api/pkg/repositories/message_repository.go | 5 + api/pkg/requests/did_auth_request.go | 41 + api/pkg/responses/did_auth_responses.go | 13 + api/pkg/services/emulator_fcm_client.go | 100 --- api/pkg/services/fcm_client.go | 28 - .../google_cloud_push_queue_service.go | 111 --- api/pkg/services/marketting_service.go | 50 +- api/pkg/services/message_service.go | 22 + .../services/phone_notification_service.go | 80 +- api/pkg/services/user_service.go | 14 +- .../validators/did_auth_handler_validator.go | 73 ++ web/Dockerfile | 4 +- web/app/components/DidLogin.vue | 439 ++++++++++ web/app/components/FirebaseAuth.vue | 491 ----------- web/app/components/MessageThreadHeader.vue | 3 - web/app/layouts/default.vue | 6 - web/app/pages/login.vue | 2 +- web/app/pages/settings/index.vue | 73 +- web/app/plugins/did-session.client.ts | 9 + web/app/plugins/firebase.client.ts | 52 -- web/app/stores/auth.ts | 83 +- web/app/utils/didKey.ts | 255 ++++++ web/app/utils/pq.ts | 224 +++++ web/package.json | 3 +- web/pnpm-lock.yaml | 779 +----------------- 59 files changed, 2568 insertions(+), 2445 deletions(-) delete mode 100644 android/app/google-services.json rename android/app/src/main/java/com/httpsms/{FirebaseMessagingService.kt => SendSmsWorker.kt} (74%) create mode 100644 api/pkg/did/did.go create mode 100644 api/pkg/did/did_test.go create mode 100644 api/pkg/did/pq.go create mode 100644 api/pkg/did/pq_test.go create mode 100644 api/pkg/did/testdata/mldsa87_browser_challenge.json create mode 100644 api/pkg/did/testdata/mldsa87_challenge.json create mode 100644 api/pkg/did/testdata/mldsa87_slhdsa_browser_challenge.json create mode 100644 api/pkg/did/testdata/mldsa87_slhdsa_challenge.json create mode 100644 api/pkg/handlers/did_auth_handler.go create mode 100644 api/pkg/handlers/did_auth_handler_test.go create mode 100644 api/pkg/handlers/did_auth_nonce.go create mode 100644 api/pkg/handlers/did_auth_nonce_test.go delete mode 100644 api/pkg/middlewares/bearer_auth_middleware.go delete mode 100644 api/pkg/repositories/google_cloud_storage_attachment_repository.go create mode 100644 api/pkg/requests/did_auth_request.go create mode 100644 api/pkg/responses/did_auth_responses.go delete mode 100644 api/pkg/services/emulator_fcm_client.go delete mode 100644 api/pkg/services/fcm_client.go delete mode 100644 api/pkg/services/google_cloud_push_queue_service.go create mode 100644 api/pkg/validators/did_auth_handler_validator.go create mode 100644 web/app/components/DidLogin.vue delete mode 100644 web/app/components/FirebaseAuth.vue create mode 100644 web/app/plugins/did-session.client.ts delete mode 100644 web/app/plugins/firebase.client.ts create mode 100644 web/app/utils/didKey.ts create mode 100644 web/app/utils/pq.ts diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a958b6956..184d88e27 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.application") - id("com.google.gms.google-services") id("io.sentry.android.gradle") version "6.2.0" } @@ -62,10 +61,7 @@ android { } dependencies { - implementation(platform("com.google.firebase:firebase-bom:34.11.0")) implementation("com.journeyapps:zxing-android-embedded:4.3.0") - implementation("com.google.firebase:firebase-analytics") - implementation("com.google.firebase:firebase-messaging") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("com.jakewharton.timber:timber:5.0.1") implementation("androidx.preference:preference-ktx:1.2.1") diff --git a/android/app/google-services.json b/android/app/google-services.json deleted file mode 100644 index b9fb4dc80..000000000 --- a/android/app/google-services.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "project_info": { - "project_number": "460506773943", - "project_id": "httpsms-4b379", - "storage_bucket": "httpsms-4b379.firebasestorage.app" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:460506773943:android:15e87ce1566139e9ce5032", - "android_client_info": { - "package_name": "com.httpsms" - } - }, - "oauth_client": [ - { - "client_id": "460506773943-224prpuv219rgh9o5l5n8874so7v1sn7.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyByG3Yp7r-Mot2BOcZY8xciI2IhQtZK6qw" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "460506773943-224prpuv219rgh9o5l5n8874so7v1sn7.apps.googleusercontent.com", - "client_type": 3 - } - ] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 86ca0a514..4f6f2b670 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -60,15 +60,6 @@ android:exported="false"> - - - - - - @@ -106,9 +97,6 @@ android:resource="@xml/file_paths" /> - diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 51fa21cdc..bfc42332c 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -62,6 +62,34 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return null } + fun getOutstandingMessageIDs(): List { + val request: Request = Request.Builder() + .url(resolveURL("/v1/messages/outstanding/list")) + .header(apiKeyHeader, apiKey) + .header(clientVersionHeader, BuildConfig.VERSION_NAME) + .build() + + val response = try { + client.newCall(request).execute() + } catch (e: Exception) { + Timber.e(e, "Exception while fetching outstanding message IDs") + return emptyList() + } + if (response.isSuccessful) { + val payload = ResponseMessageIDList.fromJson(response.body!!.string())?.data + response.close() + if (payload == null) { + Timber.e("cannot decode outstanding message IDs payload") + return emptyList() + } + return payload + } + + Timber.e("invalid response with code [${response.code}]") + response.close() + return emptyList() + } + fun sendDeliveredEvent(messageId: String, timestamp: String): Boolean { return sendEvent(messageId, "DELIVERED", timestamp) } diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index babe12df6..37cd92393 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -16,8 +16,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.lifecycle.MutableLiveData -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability import com.google.android.material.button.MaterialButton import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.textfield.TextInputEditText @@ -163,35 +161,9 @@ class LoginActivity : AppCompatActivity() { Timber.d("creating permissions launcher") } - private fun isGooglePlayServicesAvailable(): String? { - val googleApiAvailability = GoogleApiAvailability.getInstance() - val status = googleApiAvailability.isGooglePlayServicesAvailable(this) - if (status != ConnectionResult.SUCCESS) { - if (googleApiAvailability.isUserResolvableError(status)) { - googleApiAvailability.getErrorDialog(this, status, 2404)?.show() - } - return googleApiAvailability.getErrorString(status) - } - return null - } - - private fun onLoginClick() { Timber.d("login button clicked") - val error = isGooglePlayServicesAvailable() - if (error != null) { - Timber.d("google play services not installed [${error}]") - Toast.makeText(this, error, Toast.LENGTH_SHORT).show() - return - } - - if (Settings.getFcmToken(this) == null) { - Timber.d("The FCM token is not set") - Toast.makeText(this, "Cannot find FCM token. Make sure you have Google Play Services installed", Toast.LENGTH_LONG).show() - return - } - loginButton().isEnabled = false val progressBar = findViewById(R.id.loginProgressIndicator) progressBar.visibility = View.VISIBLE @@ -295,9 +267,9 @@ class LoginActivity : AppCompatActivity() { val service = HttpSmsApiService(apiKey.text.toString(), URI(serverUrl.text.toString().trim())) var e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumber.text.toString().trim(), countryCode) - var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, Settings.getFcmToken(this) ?: "") + var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, "") if(response.second != null || response.third != null) { - Timber.e("error updating fcm token [${response.second}], third [${response.third}]") + Timber.e("error registering phone [${response.second}], third [${response.third}]") liveData.postValue(Pair(response.second, response.third)) return@Thread } @@ -309,7 +281,7 @@ class LoginActivity : AppCompatActivity() { } e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumberSIM2.text.toString().trim(), countryCode) - response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, Settings.getFcmToken(this) ?: "") + response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, "") liveData.postValue(Pair(response.second, response.third)) Timber.d("finished validating api URL") diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index 363e7c19a..942c1c2fc 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -54,7 +54,6 @@ class MainActivity : AppCompatActivity() { setCardContent(this) registerListeners() - refreshToken(this) startStickyNotification(this) scheduleHeartbeatWorker(this) @@ -73,7 +72,6 @@ class MainActivity : AppCompatActivity() { super.onResume() Timber.d( "on activity resume") redirectToLogin() - refreshToken(this) setCardContent(this) setSmsPermissionListener() setBatteryOptimizationListener() @@ -170,53 +168,6 @@ class MainActivity : AppCompatActivity() { Timber.d("foreground service started [${service?.className}]") } - private fun refreshToken(context: Context) { - if(!Settings.isLoggedIn(context)) { - Timber.w("cannot refresh token because owner is not logged in") - return - } - - if(!Settings.hasOwner(context)) { - Timber.w("cannot refresh token because owner does not exist") - return - } - - if (Settings.getFcmToken(context) == null) { - Timber.w("cannot refresh token because token does not exist") - return - } - - val updateTimestamp = Settings.getFcmTokenLastUpdateTimestamp(context) - Timber.d("FCM_TOKEN_UPDATE_TIMESTAMP: $updateTimestamp") - - val interval = 24 * 60 * 60 * 1000 // 1 day - val currentTimeStamp = System.currentTimeMillis() - - if (currentTimeStamp - updateTimestamp < interval) { - Timber.i("update interval [${currentTimeStamp - updateTimestamp}] < 24 hours [$interval]") - return - } - - sendFCMToken(currentTimeStamp, context, Settings.getSIM1PhoneNumber(context), Constants.SIM1) - if (Settings.isDualSIM(context)) { - sendFCMToken(currentTimeStamp, context, Settings.getSIM2PhoneNumber(context), Constants.SIM2) - } - } - - private fun sendFCMToken(timestamp: Long, context:Context, phoneNumber: String, sim: String) { - Thread { - val response = HttpSmsApiService.create(context).updateFcmToken(phoneNumber, sim,Settings.getFcmToken(context) ?: "") - if (response.first != null) { - Settings.setUserID(context, response.first!!.userID) - Settings.setFcmTokenLastUpdateTimestampAsync(context, timestamp) - Timber.i("[${sim}] FCM token uploaded successfully") - return@Thread - } else { - Timber.e("[${sim}] could not update FCM token") - } - }.start() - } - private fun initTimber() { if (Timber.treeCount > 1) { Timber.d("timber is already initialized with count [${Timber.treeCount}]") diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index b4bf5464e..38a0113b9 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -12,6 +12,15 @@ data class ResponseMessage ( fun fromJson(json: String) = Klaxon().parse(json) } } +data class ResponseMessageIDList ( + val data: List, + val message: String, + val status: String +) { + companion object { + fun fromJson(json: String) = Klaxon().parse(json) + } +} data class ResponsePhone ( val data: Phone, val message: String, diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/SendSmsWorker.kt similarity index 74% rename from android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt rename to android/app/src/main/java/com/httpsms/SendSmsWorker.kt index e4113289b..e70e7ca9d 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/SendSmsWorker.kt @@ -4,8 +4,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.work.* -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage import com.httpsms.SentReceiver.FailedMessageWorker import timber.log.Timber @@ -18,120 +16,9 @@ import com.google.android.mms.pdu_alt.SendReq import okhttp3.MediaType import java.io.File -class MyFirebaseMessagingService : FirebaseMessagingService() { - // [START receive_message] - override fun onMessageReceived(remoteMessage: RemoteMessage) { - initTimber() - Timber.d(MyFirebaseMessagingService::onMessageReceived.name) - - if (remoteMessage.data.containsKey(Constants.KEY_HEARTBEAT_ID)) { - Timber.w("received heartbeat message with ID [${remoteMessage.data[Constants.KEY_HEARTBEAT_ID]}] and priority [${remoteMessage.priority}] and original priority [${remoteMessage.originalPriority}]") - sendHeartbeat() - return - } - - val messageID = remoteMessage.data[Constants.KEY_MESSAGE_ID] - if (messageID == null) { - Timber.e("cannot get message id from notification data with key [${Constants.KEY_MESSAGE_ID}]") - return - } - - scheduleJob(messageID) - } - // [END receive_message] - - // [START on_new_token] - /** - * Called if the FCM registration token is updated. This may occur if the security of - * the previous token had been compromised. Note that this is called when the - * FCM registration token is initially generated so this is where you would retrieve the token. - */ - override fun onNewToken(token: String) { - initTimber() - Timber.d("Refreshed token: $token") - - // If you want to send messages to this application instance or - // manage this apps subscriptions on the server side, send the - // FCM registration token to your app server. - sendRegistrationToServer(token) - } - // [END on_new_token] - - private fun sendHeartbeat() { - Timber.d("sending heartbeat from FCM notification") - if (!Settings.isLoggedIn(applicationContext)) { - Timber.w("user is not logged in, not sending heartbeat") - return - } - Thread { - try { - val phoneNumbers = mutableListOf() - phoneNumbers.add(Settings.getSIM1PhoneNumber(applicationContext)) - if (Settings.getActiveStatus(applicationContext, Constants.SIM2)) { - phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) - } - - HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext)) - Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) - } catch (exception: Exception) { - Timber.e(exception) - } - Timber.d("finished sending pulse") - }.start() - } - - private fun scheduleJob(messageID: String) { - // [START dispatch_job] - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val inputData: Data = workDataOf(Constants.KEY_MESSAGE_ID to messageID) - val work = OneTimeWorkRequest - .Builder(SendSmsWorker::class.java) - .setConstraints(constraints) - .setInputData(inputData) - .addTag(messageID) - .build() - - WorkManager - .getInstance(this) - .enqueue(work) - - Timber.d("work enqueued with ID [${work.id}] for messageID [${messageID}]") - // [END dispatch_job] - } - private fun sendRegistrationToServer(token: String) { - Timber.d("sendRegistrationTokenToServer($token)") - Settings.setFcmTokenAsync(this, token) - - if (Settings.isLoggedIn(this)) { - Timber.d("updating SIM1 phone with new fcm token") - val response = HttpSmsApiService.create(this).updateFcmToken(Settings.getSIM1PhoneNumber(this), Constants.SIM1, token) - if (response.first != null) { - Settings.setUserID(this, response.first!!.userID) - } - } - - if(Settings.isDualSIM(this)) { - Timber.d("updating SIM2 phone with new fcm token") - HttpSmsApiService.create(this).updateFcmToken(Settings.getSIM2PhoneNumber(this), Constants.SIM2, token) - } - } - - private fun initTimber() { - if (Timber.treeCount > 1) { - Timber.d("timber is already initialized with count [${Timber.treeCount}]") - return - } - - if(Settings.isDebugLogEnabled(this)) { - Timber.plant(Timber.DebugTree()) - Timber.plant(LogzTree(this.applicationContext)) - } - } - - internal class SendSmsWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { +// Sends one outstanding message fetched from the API. Enqueued by the poll +// worker with the message ID in the input data (no push notifications). +class SendSmsWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { override fun doWork(): Result { if (!Settings.isLoggedIn(applicationContext)) { Timber.w("user is not logged in, stopping processing") @@ -441,4 +328,4 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { ) } } -} + diff --git a/android/app/src/main/java/com/httpsms/Settings.kt b/android/app/src/main/java/com/httpsms/Settings.kt index 8cda79da2..06fd81356 100644 --- a/android/app/src/main/java/com/httpsms/Settings.kt +++ b/android/app/src/main/java/com/httpsms/Settings.kt @@ -21,9 +21,7 @@ object Settings { private const val SETTINGS_DEBUG_LOG_ENABLED = "SETTINGS_DEBUG_LOG_ENABLED" private const val SETTINGS_API_KEY = "SETTINGS_API_KEY" private const val SETTINGS_SERVER_URL = "SETTINGS_SERVER_URL" - private const val SETTINGS_FCM_TOKEN = "SETTINGS_FCM_TOKEN" private const val SETTINGS_USER_ID = "SETTINGS_USER_ID" - private const val SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP = "SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP" private const val SETTINGS_HEARTBEAT_TIMESTAMP = "SETTINGS_HEARTBEAT_TIMESTAMP" private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY" private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES" @@ -71,27 +69,6 @@ object Settings { return getSIM1PhoneNumber(context) != "" } - fun getFcmTokenLastUpdateTimestamp(context: Context): Long { - Timber.d(Settings::getFcmTokenLastUpdateTimestamp.name) - - val timestamp = PreferenceManager - .getDefaultSharedPreferences(context) - .getLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP,0) - - Timber.d("SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP: [$timestamp]") - return timestamp - } - - - fun setFcmTokenLastUpdateTimestampAsync(context: Context, timestamp: Long) { - Timber.d(Settings::setFcmTokenLastUpdateTimestampAsync.name) - - PreferenceManager.getDefaultSharedPreferences(context) - .edit() - .putLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP, timestamp) - .apply() - } - fun setSIM1PhoneNumber(context: Context, owner: String?) { Timber.d(Settings::setSIM1PhoneNumber.name) @@ -331,26 +308,6 @@ object Settings { .apply() } - fun getFcmToken(context: Context): String?{ - Timber.d(Settings::getFcmToken.name) - - val activeStatus = PreferenceManager - .getDefaultSharedPreferences(context) - .getString(this.SETTINGS_FCM_TOKEN,null) - - Timber.d("SETTINGS_FCM_TOKEN: [$activeStatus]") - return activeStatus - } - - fun setFcmTokenAsync(context: Context, apiKey: String) { - Timber.d(Settings::setApiKeyAsync.name) - - PreferenceManager.getDefaultSharedPreferences(context) - .edit() - .putString(this.SETTINGS_FCM_TOKEN, apiKey) - .apply() - } - fun getHeartbeatTimestamp(context: Context): Long { Timber.d(Settings::getHeartbeatTimestamp.name) diff --git a/android/app/src/main/java/com/httpsms/SettingsActivity.kt b/android/app/src/main/java/com/httpsms/SettingsActivity.kt index cbc2831bc..91fe43d2f 100644 --- a/android/app/src/main/java/com/httpsms/SettingsActivity.kt +++ b/android/app/src/main/java/com/httpsms/SettingsActivity.kt @@ -151,7 +151,6 @@ class SettingsActivity : AppCompatActivity() { Settings.setUserID(this, null) Settings.setEncryptionKey(this, null) Settings.setEncryptReceivedMessages(this, false) - Settings.setFcmTokenLastUpdateTimestampAsync(this, 0) Settings.setIncomingCallEventsEnabled(this, Constants.SIM1, false) Settings.setIncomingCallEventsEnabled(this, Constants.SIM2, false) redirectToLogin() diff --git a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt index 174f27425..eed258bbf 100644 --- a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt +++ b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt @@ -1,10 +1,17 @@ package com.httpsms.worker import android.content.Context +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters +import androidx.work.workDataOf import com.httpsms.Constants import com.httpsms.HttpSmsApiService +import com.httpsms.SendSmsWorker import com.httpsms.Settings import timber.log.Timber @@ -35,6 +42,8 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) Timber.d("Set the heartbeat timestamp") + + pollOutstandingMessages() } catch (exception: Exception) { Timber.e(exception, "Failed to send [${phoneNumbers.joinToString()}] heartbeats to server") return Result.failure() @@ -42,4 +51,38 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor return Result.success() } + + // Without push notifications the phone polls for outstanding messages and + // enqueues one SendSmsWorker per message ID. Claiming happens server-side + // in GetOutstanding, so overlapping polls cannot double-send. + private fun pollOutstandingMessages() { + val messageIDs = try { + HttpSmsApiService.create(applicationContext).getOutstandingMessageIDs() + } catch (exception: Exception) { + Timber.e(exception, "Failed to fetch outstanding message IDs") + return + } + if (messageIDs.isEmpty()) { + return + } + Timber.d("fetched [${messageIDs.size}] outstanding message IDs") + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + for (messageID in messageIDs) { + val inputData: Data = workDataOf(Constants.KEY_MESSAGE_ID to messageID) + val work = OneTimeWorkRequest + .Builder(SendSmsWorker::class.java) + .setConstraints(constraints) + .setInputData(inputData) + .addTag(messageID) + .build() + + WorkManager + .getInstance(applicationContext) + .enqueue(work) + } + } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ee8d1b8d2..1644d0f3a 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -4,9 +4,6 @@ buildscript { google() mavenCentral() } - dependencies { - classpath("com.google.gms:google-services:4.4.2") - } } plugins { diff --git a/api/go.mod b/api/go.mod index bba0b2db1..15305d280 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,12 +3,7 @@ module github.com/NdoleStudio/httpsms go 1.25.8 require ( - cloud.google.com/go/cloudtasks v1.18.0 cloud.google.com/go/compute/metadata v0.9.0 - cloud.google.com/go/storage v1.63.0 - firebase.google.com/go v3.13.0+incompatible - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.0 github.com/NdoleStudio/go-otelroundtripper v0.0.15 github.com/NdoleStudio/lemonsqueezy-go v1.3.1 github.com/NdoleStudio/plunk-go v0.0.2 @@ -16,6 +11,7 @@ require ( github.com/axiomhq/axiom-go v0.32.0 github.com/carlmjohnson/requests v0.25.1 github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/cloudflare/circl v1.6.3 github.com/cockroachdb/cockroach-go/v2 v2.4.3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dgraph-io/ristretto/v2 v2.4.0 @@ -57,8 +53,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 golang.org/x/sync v0.21.0 - google.golang.org/api v0.287.0 - google.golang.org/protobuf v1.36.11 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.2 gorm.io/plugin/opentelemetry v0.1.16 @@ -77,21 +71,10 @@ require ( ) require ( - cel.dev/expr v0.25.2 // indirect - cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.20.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/firestore v1.22.0 // indirect - cloud.google.com/go/iam v1.11.0 // indirect - cloud.google.com/go/longrunning v1.0.0 // indirect - cloud.google.com/go/monitoring v1.29.0 // indirect - cloud.google.com/go/trace v1.16.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/ClickHouse/ch-go v0.72.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect @@ -103,14 +86,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -127,11 +106,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/gofiber/schema v1.8.0 // indirect github.com/gofiber/utils/v2 v2.1.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.2.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect - github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -157,14 +132,12 @@ require ( github.com/paulmach/orb v0.13.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect - github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect @@ -181,13 +154,12 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib v1.44.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect go.opentelemetry.io/contrib/processors/minsev v0.16.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect @@ -199,16 +171,14 @@ require ( golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.46.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect diff --git a/api/go.sum b/api/go.sum index 61ed6dd88..c6710b205 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,51 +1,15 @@ bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= -cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= -cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E= -cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= -cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= -cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= -cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= -cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= -cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= -cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= -cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= -cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= -cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= -firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= github.com/ClickHouse/ch-go v0.72.0 h1:DSyUd4kuxisOVXlZSXyIQYBAajSErZWC379651DpAMU= github.com/ClickHouse/ch-go v0.72.0/go.mod h1:eeWlJavWDsMf5fZzLNCYaBiMxVoREJYK00aiZ9FJ3E0= github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.0 h1:92kCbSANHUnbS7tzc9uhUx4H+MF1Lr/QXInoPkjz6JU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.0/go.mod h1:n2lr2dT7IuNeGhW6iB/+nJB+Fo9vZxCGwEQOy2Hc15Y= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -90,8 +54,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw= github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -104,14 +68,6 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5 github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= @@ -128,8 +84,6 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-hermes/hermes/v2 v2.6.2 h1:RuGQlICVtIHixfxtYwN7hAoqGyGxr+D3kE42oE6emcw= github.com/go-hermes/hermes/v2 v2.6.2/go.mod h1:RLVNk31/1KqF35vK3mAaQVuJvMH+K5//6OTGJk+j/80= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -180,27 +134,16 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= -github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= -github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= -github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= @@ -290,8 +233,6 @@ github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -321,8 +262,6 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= -github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -387,12 +326,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib v1.44.0 h1:cVL0yu3uyrXkAmxonxvzYysIo5EZa8jKh3740MzxBzI= go.opentelemetry.io/contrib v1.44.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 h1:s5k/FTgmE4Mj2jFG38Dj5TQgxDRMG92fRK3puTdMHew= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708/go.mod h1:VgtIsrVXKo8KmG2sFvQazHcQ98P2FHQ+ZePXtpTPjqs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= @@ -457,8 +392,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -487,23 +420,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= -google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= -google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/api/pkg/cache/cache.go b/api/pkg/cache/cache.go index bb9a829cd..d9bc01d7b 100644 --- a/api/pkg/cache/cache.go +++ b/api/pkg/cache/cache.go @@ -9,4 +9,6 @@ import ( type Cache interface { Set(ctx context.Context, key string, value string, ttl time.Duration) error Get(ctx context.Context, key string) (value string, err error) + // GetDel atomically returns and deletes an item, or an error if absent. + GetDel(ctx context.Context, key string) (value string, err error) } diff --git a/api/pkg/cache/memory_cache.go b/api/pkg/cache/memory_cache.go index 2e32bcffb..23fa9ef98 100644 --- a/api/pkg/cache/memory_cache.go +++ b/api/pkg/cache/memory_cache.go @@ -45,3 +45,16 @@ func (cache *memoryCache) Set(ctx context.Context, key string, value string, ttl cache.store.Set(key, value, ttl) return nil } + +// GetDel atomically returns and deletes an item +func (cache *memoryCache) GetDel(ctx context.Context, key string) (value string, err error) { + ctx, span := cache.tracer.Start(ctx) + defer span.End() + + response, ok := cache.store.Get(key) + if !ok { + return "", stacktrace.NewError(fmt.Sprintf("no item found in cache with key [%s]", key)) + } + cache.store.Delete(key) + return response.(string), nil +} diff --git a/api/pkg/cache/redis_cache.go b/api/pkg/cache/redis_cache.go index 18a6a887b..d181ca659 100644 --- a/api/pkg/cache/redis_cache.go +++ b/api/pkg/cache/redis_cache.go @@ -51,3 +51,18 @@ func (cache *redisCache) Set(ctx context.Context, key string, value string, ttl } return nil } + +// GetDel atomically returns and deletes an item from the redis cache +func (cache *redisCache) GetDel(ctx context.Context, key string) (value string, err error) { + ctx, span := cache.tracer.Start(ctx) + defer span.End() + + response, err := cache.client.GetDel(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", stacktrace.Propagate(err, fmt.Sprintf("no item found in redis with key [%s]", key)) + } + if err != nil { + return "", stacktrace.Propagate(err, fmt.Sprintf("cannot get and delete item in redis with key [%s]", key)) + } + return response, nil +} diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index f84f5b301..8728e01af 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -25,9 +25,6 @@ import ( "github.com/NdoleStudio/httpsms/pkg/discord" - "cloud.google.com/go/storage" - mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric" - cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/lemonsqueezy-go" "github.com/hashicorp/go-retryablehttp" @@ -41,8 +38,6 @@ import ( "github.com/NdoleStudio/httpsms/pkg/emails" - cloudtasks "cloud.google.com/go/cloudtasks/apiv2" - "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" @@ -55,10 +50,7 @@ import ( "github.com/rs/zerolog" "go.opentelemetry.io/otel/sdk/trace" - firebase "firebase.google.com/go" - "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/middlewares" - "google.golang.org/api/option" "github.com/gofiber/fiber/v3/middleware/compress" "github.com/gofiber/fiber/v3/middleware/cors" @@ -159,6 +151,8 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterMarketingListeners() container.RegisterWebsocketListeners() + container.RegisterDIDAuthRoutes() + // this has to be last since it registers the /* route container.RegisterSwaggerRoutes() @@ -201,7 +195,6 @@ func (container *Container) App() (app *fiber.App) { ), ) app.Use(middlewares.HTTPRequestLogger(container.Tracer(), container.Logger())) - app.Use(middlewares.BearerAuth(container.Logger(), container.Tracer(), container.FirebaseAuthClient())) app.Use(middlewares.APIKeyAuth(container.Logger(), container.Tracer(), container.UserRepository())) container.app = app @@ -417,18 +410,6 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( return container.db } -// FirebaseApp creates a new instance of firebase.App -func (container *Container) FirebaseApp() (app *firebase.App) { - container.logger.Debug(fmt.Sprintf("creating %T", app)) - - app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials())) - if err != nil { - msg := "cannot initialize firebase application" - container.logger.Fatal(stacktrace.Propagate(err, msg)) - } - return app -} - // InMemoryCache returns the shared in-memory cache.Cache, creating it on the first call. func (container *Container) InMemoryCache() cache.Cache { if container.inMemoryCache != nil { @@ -468,29 +449,6 @@ func (container *Container) Cache() cache.Cache { return cache.NewRedisCache(container.Tracer(), redisClient) } -// FirebaseAuthClient creates a new instance of auth.Client -func (container *Container) FirebaseAuthClient() (client *auth.Client) { - container.logger.Debug(fmt.Sprintf("creating %T", client)) - authClient, err := container.FirebaseApp().Auth(context.Background()) - if err != nil { - msg := "cannot initialize firebase auth client" - container.logger.Fatal(stacktrace.Propagate(err, msg)) - } - return authClient -} - -// CloudTasksClient creates a new instance of cloudtasks.Client -func (container *Container) CloudTasksClient() (client *cloudtasks.Client) { - container.logger.Debug(fmt.Sprintf("creating %T", client)) - - client, err := cloudtasks.NewClient(context.Background(), option.WithCredentialsJSON(container.FirebaseCredentials())) - if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize cloud tasks client")) - } - - return client -} - // EventsQueueConfiguration creates a new instance of services.PushQueueConfig func (container *Container) EventsQueueConfiguration() (config services.PushQueueConfig) { container.logger.Debug(fmt.Sprintf("creating %T", config)) @@ -503,15 +461,11 @@ func (container *Container) EventsQueueConfiguration() (config services.PushQueu } } -// EventsQueue creates a new instance of services.PushQueue +// EventsQueue creates an in-process instance of events services.PushQueue. +// Delayed delivery is handled locally without any cloud task provider. func (container *Container) EventsQueue() (queue services.PushQueue) { container.logger.Debug("creating events services.PushQueue") - - if os.Getenv("EVENTS_QUEUE_TYPE") == "emulator" { - return container.EmulatorEventsQueue() - } - - return container.CloudTaskEventsQueue() + return container.EmulatorEventsQueue() } // EmulatorEventsQueue creates an in process instance of events services.PushQueue @@ -525,46 +479,6 @@ func (container *Container) EmulatorEventsQueue() (queue services.PushQueue) { ) } -// CloudTaskEventsQueue creates a Google cloud task instance of events services.PushQueue -func (container *Container) CloudTaskEventsQueue() (queue services.PushQueue) { - container.logger.Debug("creating cloud task events services.PushQueue") - return services.NewGooglePushQueue( - container.Logger(), - container.Tracer(), - container.CloudTasksClient(), - container.EventsQueueConfiguration(), - ) -} - -// FCMClient creates the appropriate FCM client based on configuration. -// When FCM_ENDPOINT is set, it returns an EmulatorFCMClient that sends -// notifications directly to the phone emulator via HTTP. -// Otherwise, it returns a FirebaseFCMClient that uses the real Firebase SDK. -func (container *Container) FCMClient() services.FCMClient { - if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" { - container.logger.Info(fmt.Sprintf("using emulator FCM client with endpoint: %s", fcmEndpoint)) - return services.NewEmulatorFCMClient( - container.HTTPClient("emulator_fcm"), - fcmEndpoint, - container.Logger(), - ) - } - - container.logger.Debug("creating FirebaseFCMClient") - messagingClient, err := container.FirebaseApp().Messaging(context.Background()) - if err != nil { - msg := "cannot initialize firebase messaging client" - container.logger.Fatal(stacktrace.Propagate(err, msg)) - } - return services.NewFirebaseFCMClient(messagingClient) -} - -// FirebaseCredentials returns firebase credentials as bytes. -func (container *Container) FirebaseCredentials() []byte { - container.logger.Debug("creating firebase credentials") - return []byte(os.Getenv("FIREBASE_CREDENTIALS")) -} - // Tracer creates a new instance of telemetry.Tracer func (container *Container) Tracer() (t telemetry.Tracer) { container.logger.Debug("creating telemetry.Tracer") @@ -720,6 +634,27 @@ func (container *Container) UserHandlerValidator() (validator *validators.UserHa ) } +// DIDAuthHandler creates a new instance of handlers.DIDAuthHandler +func (container *Container) DIDAuthHandler() (handler *handlers.DIDAuthHandler) { + container.logger.Debug(fmt.Sprintf("creating %T", handler)) + return handlers.NewDIDAuthHandler( + container.Logger(), + container.Tracer(), + container.DIDAuthHandlerValidator(), + container.UserRepository(), + handlers.NewCacheNonceStore(container.Cache()), + ) +} + +// DIDAuthHandlerValidator creates a new instance of validators.DIDAuthHandlerValidator +func (container *Container) DIDAuthHandlerValidator() (validator *validators.DIDAuthHandlerValidator) { + container.logger.Debug(fmt.Sprintf("creating %T", validator)) + return validators.NewDIDAuthHandlerValidator( + container.Logger(), + container.Tracer(), + ) +} + // EventDispatcher creates a new instance of services.EventDispatcher func (container *Container) EventDispatcher() (dispatcher *services.EventDispatcher) { if container.eventDispatcher != nil { @@ -1047,7 +982,7 @@ func (container *Container) MarketingService() (service *services.MarketingServi return services.NewMarketingService( container.Logger(), container.Tracer(), - container.FirebaseAuthClient(), + container.UserRepository(), container.PlunkClient(), ) } @@ -1063,7 +998,6 @@ func (container *Container) UserService() (service *services.UserService) { container.UserEmailFactory(), container.LemonsqueezyClient(), container.EventDispatcher(), - container.FirebaseAuthClient(), container.HTTPClient("lemonsqueezy"), ) } @@ -1570,32 +1504,18 @@ func (container *Container) MessageService() (service *services.MessageService) ) } -// AttachmentRepository creates a cached AttachmentRepository based on configuration +// AttachmentRepository creates a cached in-process AttachmentRepository. +// Attachments are kept locally without any cloud storage provider. func (container *Container) AttachmentRepository() repositories.AttachmentRepository { if container.attachmentRepository != nil { return container.attachmentRepository } - bucket := os.Getenv("GCS_BUCKET_NAME") - if bucket != "" { - container.logger.Debug("creating GoogleCloudStorageAttachmentRepository") - client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials())) - if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client")) - } - container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository( - container.Logger(), - container.Tracer(), - client, - bucket, - ) - } else { - container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)") - container.attachmentRepository = repositories.NewMemoryAttachmentRepository( - container.Logger(), - container.Tracer(), - ) - } + container.logger.Debug("creating MemoryAttachmentRepository") + container.attachmentRepository = repositories.NewMemoryAttachmentRepository( + container.Logger(), + container.Tracer(), + ) return container.attachmentRepository } @@ -1639,7 +1559,6 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.FCMClient(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), @@ -1710,6 +1629,12 @@ func (container *Container) RegisterEventRoutes() { container.EventsHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } +// RegisterDIDAuthRoutes registers routes for the /did-auth prefix +func (container *Container) RegisterDIDAuthRoutes() { + container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.DIDAuthHandler{})) + container.DIDAuthHandler().RegisterRoutes(container.App()) +} + // RegisterSwaggerRoutes registers routes for swagger func (container *Container) RegisterSwaggerRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", swagger.HandlerDefault)) @@ -1798,42 +1723,6 @@ func (container *Container) InitializeTraceProvider() func() { return container.initializeAxiomTraceProvider(container.version, container.projectID) } -func (container *Container) initializeGoogleTraceProvider(version string, namespace string) func() { - container.logger.Debug("initializing google trace meterProvider") - - traceExporter, err := cloudtrace.New(cloudtrace.WithProjectID(os.Getenv("GCP_PROJECT_ID"))) - if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create cloud trace traceExporter")) - } - - tp := trace.NewTracerProvider( - trace.WithBatcher(traceExporter), - trace.WithSampler(trace.AlwaysSample()), - trace.WithResource(container.OtelResources(version, namespace)), - ) - otel.SetTracerProvider(tp) - - metricExporter, err := mexporter.New(mexporter.WithProjectID(os.Getenv("GCP_PROJECT_ID"))) - if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create cloud metric traceExporter")) - } - - meterProvider := metric.NewMeterProvider( - metric.WithReader(metric.NewPeriodicReader(metricExporter)), - metric.WithResource(container.OtelResources(version, namespace)), - ) - otel.SetMeterProvider(meterProvider) - - return func() { - if err = metricExporter.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown cloud metric metric exporter")) - } - if err = traceExporter.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown cloud trace trace exporter")) - } - } -} - func (container *Container) initializeAxiomTraceProvider(version string, namespace string) func() { container.logger.Debug("initializing axiom trace provider") diff --git a/api/pkg/did/did.go b/api/pkg/did/did.go new file mode 100644 index 000000000..3d48ad813 --- /dev/null +++ b/api/pkg/did/did.go @@ -0,0 +1,101 @@ +// Package did implements did:key operations for DID-based SSO login. +// +// Only ML-DSA-87 (FIPS 204) dids are supported, matching the dai/aifeeds +// wallet. The identifier is base58btc("z") of uvarint(0x300001) ‖ prefixed +// public key bytes; see pq.go for the bundle format and signature +// verification. +package did + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "math/big" + "strings" +) + +const base58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// base58Decode decodes a base58btc string to bytes. +func base58Decode(str string) ([]byte, error) { + zeros := 0 + for zeros < len(str) && str[zeros] == '1' { + zeros++ + } + + num := new(big.Int) + for i := zeros; i < len(str); i++ { + idx := strings.IndexByte(base58, str[i]) + if idx < 0 { + return nil, errors.New("invalid base58 character") + } + num.Mul(num, big.NewInt(58)) + num.Add(num, big.NewInt(int64(idx))) + } + + bytes := num.Bytes() + out := make([]byte, zeros+len(bytes)) + copy(out[zeros:], bytes) + return out, nil +} + +// base58Encode encodes bytes to a base58btc string. +func base58Encode(input []byte) string { + zeros := 0 + for zeros < len(input) && input[zeros] == 0 { + zeros++ + } + + num := new(big.Int).SetBytes(input[zeros:]) + if num.Sign() == 0 { + return strings.Repeat("1", zeros) + } + + var out []byte + mod := new(big.Int) + for num.Sign() > 0 { + num.DivMod(num, big.NewInt(58), mod) + out = append(out, base58[mod.Int64()]) + } + + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + + return strings.Repeat("1", zeros) + string(out) +} + +// encodeVarint encodes an unsigned integer as a multicodec varint. +func encodeVarint(value uint64) []byte { + var out []byte + for value > 0x7f { + out = append(out, byte(value&0x7f)|0x80) + value >>= 7 + } + out = append(out, byte(value)) + return out +} + +// decodeVarint decodes a multicodec varint from buf at offset, returning the +// value and the number of bytes consumed. +func decodeVarint(buf []byte, offset int) (value uint64, length int, err error) { + var shift uint + for i := offset; i < len(buf); i++ { + b := buf[i] + value |= uint64(b&0x7f) << shift + if b&0x80 == 0 { + return value, i - offset + 1, nil + } + shift += 7 + } + return 0, 0, errors.New("invalid varint") +} + +// GenerateNonce returns a 64-char hex random challenge nonce. +func GenerateNonce() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "" + } + return hex.EncodeToString(b) +} diff --git a/api/pkg/did/did_test.go b/api/pkg/did/did_test.go new file mode 100644 index 000000000..162e48286 --- /dev/null +++ b/api/pkg/did/did_test.go @@ -0,0 +1,17 @@ +package did + +import "testing" + +func TestGenerateNonce(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 100; i++ { + nonce := GenerateNonce() + if len(nonce) != 64 { + t.Fatalf("expected nonce of length 64, got %d", len(nonce)) + } + if _, ok := seen[nonce]; ok { + t.Fatal("expected unique nonces") + } + seen[nonce] = true + } +} diff --git a/api/pkg/did/pq.go b/api/pkg/did/pq.go new file mode 100644 index 000000000..02609a3ff --- /dev/null +++ b/api/pkg/did/pq.go @@ -0,0 +1,178 @@ +package did + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + + "github.com/cloudflare/circl/sign/mldsa/mldsa87" + "github.com/cloudflare/circl/sign/slhdsa" +) + +// ML-DSA-87 (FIPS 204) challenge verification, wire-compatible with +// bigtangle-ts PQKey / SignatureBundle. The DID encodes +// uvarint(MulticodecMLDSA87) ‖ prefixedPubBytes, where prefixedPubBytes is +// 0x05 ‖ KeyBundle; the challenge signature is a serialized SignatureBundle +// over a double domain-separated SHA-256 of the nonce. +const ( + // MulticodecMLDSA87 is the private-use multicodec for ML-DSA-87 did:key. + MulticodecMLDSA87 = 0x300001 + + pqPubPrefix = 0x05 + bundleVersion = 1 + algMLDSA87 = 1 + algSLHDSA256s = 2 + txDomain = "BIGTANGLE-PQ-TX-v1" + mldsaSigDomain = "MLDSA-SIG-DOMAIN" + slhdsaSigDomain = "SLHDSA-SIG-DOMAIN" + keyBundleMinSize = 2 +) + +// domainSeparatedHash is sha256(domain ‖ data), matching bigtangle-ts. +func domainSeparatedHash(data []byte, domain string) []byte { + h := sha256.New() + h.Write([]byte(domain)) + h.Write(data) + return h.Sum(nil) +} + +// parseBundle decodes a KeyBundle or SignatureBundle: +// version(1) ‖ count(1) ‖ { algorithm(1) ‖ length(2, big-endian) ‖ bytes }*. +func parseBundle(raw []byte) (map[byte][]byte, error) { + if len(raw) < keyBundleMinSize { + return nil, errors.New("bundle too short") + } + if raw[0] != bundleVersion { + return nil, errors.New("unsupported bundle version") + } + + entries := make(map[byte][]byte, raw[1]) + offset := keyBundleMinSize + for i := 0; i < int(raw[1]); i++ { + if offset+3 > len(raw) { + return nil, errors.New("truncated bundle entry") + } + algorithm := raw[offset] + length := int(raw[offset+1])<<8 | int(raw[offset+2]) + offset += 3 + if offset+length > len(raw) { + return nil, errors.New("truncated bundle bytes") + } + entries[algorithm] = raw[offset : offset+length] + offset += length + } + return entries, nil +} + +// pqPubFromDID extracts the prefixed public key bytes from a did:key:z… id. +func pqPubFromDID(did string) ([]byte, error) { + parts := strings.Split(did, ":") + if len(parts) != 3 || parts[0] != "did" || parts[1] != "key" { + return nil, errors.New("invalid DID format, expected did:key:...") + } + enc := parts[2] + if len(enc) == 0 || enc[0] != 'z' { + return nil, errors.New("only base58btc DIDs supported") + } + + decoded, err := base58Decode(enc[1:]) + if err != nil { + return nil, err + } + + codec, length, err := decodeVarint(decoded, 0) + if err != nil { + return nil, err + } + if codec != MulticodecMLDSA87 { + return nil, errors.New("unsupported multicodec") + } + + return decoded[length:], nil +} + +// IsValidPQDID reports whether did is a well-formed ML-DSA-87 did:key. +func IsValidPQDID(did string) bool { + prefixed, err := pqPubFromDID(did) + if err != nil || len(prefixed) < 2 || prefixed[0] != pqPubPrefix { + return false + } + entries, err := parseBundle(prefixed[1:]) + if err != nil { + return false + } + _, ok := entries[algMLDSA87] + return ok +} + +// VerifyPQSignature verifies a bapp-style ML-DSA-87 challenge signature +// (hex-serialized SignatureBundle) over the hex nonce against did. +func VerifyPQSignature(did string, nonceHex string, sigBundleHex string) bool { + prefixed, err := pqPubFromDID(did) + if err != nil || len(prefixed) < 2 || prefixed[0] != pqPubPrefix { + return false + } + keyEntries, err := parseBundle(prefixed[1:]) + if err != nil { + return false + } + pubBytes, ok := keyEntries[algMLDSA87] + if !ok { + return false + } + + sigRaw, err := hex.DecodeString(sigBundleHex) + if err != nil { + return false + } + sigEntries, err := parseBundle(sigRaw) + if err != nil { + return false + } + sig, ok := sigEntries[algMLDSA87] + if !ok { + return false + } + + nonce, err := hex.DecodeString(nonceHex) + if err != nil { + return false + } + + var pub mldsa87.PublicKey + if err := pub.UnmarshalBinary(pubBytes); err != nil { + return false + } + + txHash := domainSeparatedHash(nonce, txDomain) + mlMsg := domainSeparatedHash(txHash, mldsaSigDomain) + if !mldsa87.Verify(&pub, mlMsg, nil, sig) { + return false + } + + // Dual keys also carry SLH-DSA-SHA2-256s; verify it when present, matching + // bigtangle-ts PQKey.verify. + if slhPub, ok := keyEntries[algSLHDSA256s]; ok { + slhSig, ok := sigEntries[algSLHDSA256s] + if !ok { + return false + } + var slhKey slhdsa.PublicKey + slhKey.ID = slhdsa.SHA2_256s + if err := slhKey.UnmarshalBinary(slhPub); err != nil { + return false + } + slhMsg := domainSeparatedHash(txHash, slhdsaSigDomain) + if !slhdsa.Verify(&slhKey, slhdsa.NewMessage(slhMsg), slhSig, nil) { + return false + } + } + return true +} + +// DIDFromPQPub builds a did:key:z… identifier from prefixed PQ public key bytes. +func DIDFromPQPub(prefixedPub []byte) string { + raw := append(encodeVarint(MulticodecMLDSA87), prefixedPub...) + return "did:key:z" + base58Encode(raw) +} diff --git a/api/pkg/did/pq_test.go b/api/pkg/did/pq_test.go new file mode 100644 index 000000000..7f1dfd35c --- /dev/null +++ b/api/pkg/did/pq_test.go @@ -0,0 +1,122 @@ +package did + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// pqFixture is generated by bigtangle-ts PQKey (see the dai repo's +// packages/did test fixtures) so the Go verifier and the wallet signer cannot +// drift. +type pqFixture struct { + Seed string `json:"seed"` + DID string `json:"did"` + Nonce string `json:"nonce"` + SigBundle string `json:"sigBundle"` +} + +func loadPQFixture(t *testing.T) pqFixture { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "mldsa87_challenge.json")) + if err != nil { + t.Fatalf("read fixture: %s", err) + } + var fixture pqFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse fixture: %s", err) + } + return fixture +} + +func TestVerifyPQSignatureFixture(t *testing.T) { + fixture := loadPQFixture(t) + + if !IsValidPQDID(fixture.DID) { + t.Fatalf("fixture DID should be valid: %s", fixture.DID) + } + if !VerifyPQSignature(fixture.DID, fixture.Nonce, fixture.SigBundle) { + t.Fatal("bigtangle-ts fixture signature should verify with circl") + } + if VerifyPQSignature(fixture.DID, "00"+fixture.Nonce[2:], fixture.SigBundle) { + t.Fatal("wrong nonce should fail") + } + + tampered := fixture.SigBundle[:len(fixture.SigBundle)-2] + "00" + if VerifyPQSignature(fixture.DID, fixture.Nonce, tampered) { + t.Fatal("tampered signature should fail") + } +} + +func TestVerifyPQSignatureRejectsMalformed(t *testing.T) { + fixture := loadPQFixture(t) + + if VerifyPQSignature(fixture.DID, fixture.Nonce, "not-hex") { + t.Fatal("non-hex signature should fail") + } + if VerifyPQSignature(fixture.DID, "zz", fixture.SigBundle) { + t.Fatal("non-hex nonce should fail") + } + if VerifyPQSignature("did:key:z6Mkabc", fixture.Nonce, fixture.SigBundle) { + t.Fatal("non-PQ DID should fail") + } +} + +func TestVerifyPQSignatureBrowserFixture(t *testing.T) { + // Produced by the httpSMS web signer (web/app/utils/pq.ts) from the same + // seed, proving the browser and the Go verifier agree. + raw, err := os.ReadFile(filepath.Join("testdata", "mldsa87_browser_challenge.json")) + if err != nil { + t.Fatalf("read fixture: %s", err) + } + var fixture pqFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse fixture: %s", err) + } + + if !VerifyPQSignature(fixture.DID, fixture.Nonce, fixture.SigBundle) { + t.Fatal("browser-generated signature should verify") + } +} + +func TestVerifyPQSignatureDualFixture(t *testing.T) { + // Dual ML-DSA-87 + SLH-DSA-SHA2-256s key: both signatures must verify. + raw, err := os.ReadFile(filepath.Join("testdata", "mldsa87_slhdsa_challenge.json")) + if err != nil { + t.Fatalf("read fixture: %s", err) + } + var fixture pqFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse fixture: %s", err) + } + + if !VerifyPQSignature(fixture.DID, fixture.Nonce, fixture.SigBundle) { + t.Fatal("dual-key fixture should verify (ML-DSA-87 + SLH-DSA-SHA2-256s)") + } + if VerifyPQSignature(fixture.DID, "00"+fixture.Nonce[2:], fixture.SigBundle) { + t.Fatal("wrong nonce should fail") + } +} + +func TestVerifyPQSignatureDualBrowserFixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "mldsa87_slhdsa_browser_challenge.json")) + if err != nil { + t.Fatalf("read fixture: %s", err) + } + var fixture pqFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse fixture: %s", err) + } + if !VerifyPQSignature(fixture.DID, fixture.Nonce, fixture.SigBundle) { + t.Fatal("browser dual-key signature should verify") + } +} + +func TestIsValidPQDIDRejects(t *testing.T) { + for _, did := range []string{"", "not-a-did", "did:key:zInvalid", "did:key:z6Mkabc"} { + if IsValidPQDID(did) { + t.Fatalf("expected invalid: %q", did) + } + } +} diff --git a/api/pkg/did/testdata/mldsa87_browser_challenge.json b/api/pkg/did/testdata/mldsa87_browser_challenge.json new file mode 100644 index 000000000..4bec8b3e6 --- /dev/null +++ b/api/pkg/did/testdata/mldsa87_browser_challenge.json @@ -0,0 +1,6 @@ +{ + "description": "Signature produced by the httpSMS web signer (web/app/utils/pq.ts).", + "did": "did:key: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", + "nonce": "abababababababababababababababababababababababababababababababab", + "sigBundle": "0101011213857c03b5e40dcf274563282d6d891d402328d8f5b56f43f24a8280cecddf2228386fe1666007c58a063375ece09abb6f146e42f2de90ad3b749c8ed978e775f327854f73bea663317ace20f68ed9a178f19d1b4d89e1e78629a35379e84d8b64880885695b732f3754b6c33b8d5bd013c8a8c44cfa9b47b2484c056176abeea8e874c612a903db22cb51832b1a463ba99f1d36189743d4ba9320cf20527364e51c4c83fff3d9b7df7b9c067ae1c23d800949d8b6b557967d2a4277245352d9c72c6d581e6cbf8ca9aadf40111f43bef013cc635b12a679331bd6d9bb8577183e91713561287cbde6107533b26dfd778d461df023a2dd5d16d7c970c22a60150dc83efdc40a8cbdc2ac30dfa6b004e243746adb057dcf859a1251054a97bb620be99d81547966cb98ca0aef04ff76426791675af217133b2b3f5e968e2f5378f2ad0814691741bf1a896f40953f04de25ba04f9f4303e4edf2f76c1a68932fa00e62cfcd061a3d42497782193ea0ba0228f1c7b6d24977b3134e9e9a2d7d834a66298fe70a7a4477d9be8023bd1a3919fa7234b975e1cf42bc159f30a99dbc837a4c6b015d5c8964a661be22f57f0f9c177265335dbc7dfdebea9a66410df396ae5dd2ac1ec366ed9c565978f0a5011bc839eb518ed508bb5ce35e9be067eb2ca4a6f0af4e18eddbfd0b900c2173e831ae22bdf2a729fbffb50d9c353e6048a9ea369d07bb83c113864ea4c3da1bcda6de8bbb083fa264d9205ebedf060e861aa0c0a659eef44a69dc369231737db00f111ef7c82be228d6311d30d325a90080e7a31a96b2fe11bf0b4fd6527cc06e5b681b7da34be3da52f14be357159456f15e13fc5d944bc5c9a1e96539112a597bc9afc31eba6a4461033bc72806a510374efee0899b8d764d0823af6ebc0dfe54c5ceb9f52ea6b8a462aeece18c0d1aadd2d3d6e4c34927e6f3c309a65323504d12511ea93af67500bed35c57780f81718bbbeff92b8c87c194e50fcb5630f8ef87f1cb2ae35c8421d531cafb910e44333105f56a56487a81418b3ab3cc96eb88d069dcd3c5677084c6734b16c909172627fb5e83a80c273bcfc44c0365b16777bf9677124b06995150d354b09492226a90f845babd887778629730162629d6299c038a2da0ee519c82a99a7610787ffe6744641f0f50786ca8e9e8d8f879ed382866306dd1306400c6b4199fa3bbf33a520201a930c8e88be37c239bb22ace932ee992ab811170ce55998856f15a47943600affdf0ad0476ba9dceb11f75379454edcf3061d0c342fdd289f59782503d356613be0d8d3893af2b0431707e7c3a1d6e82c993962f372333a4def84feafa21850a969f444b700d7db3af76d26a0aa6f6be619c96631950c8339f7cbc916f71d61cb321c067e32c84663e1a71592d9b115de2b677f2e18070fd3ac2bca5a7429db1283967c9188b730fa51d559cdc844482b597e3c9fc4ee4435184f0ab0e6d073fa2b6cc2cafdfbea2c2f73d680f609d1b63935a03fb2afac298e587894a7eb56295c3d5427daa015bc341fcf79c6825604650439480ea164746f2c284ffe06c50cb36a400ae5ceecefac6305e625f7fabb60c2924d9d165d9671b1e909574f0bf38ff57ba7c29b84d3e4fedc5d4458771ca8a3413af3cd6dd006c8a6aa6678e3cd0f0055cf8be471f9ed3391ea6ddac681bab812a3f6663ff8e7fc1beda03566fdfc9e449cfaa144ba2eb1cb15b9ee7005d143dd088dfaced1952c4be2639faef00ea63a638fb1883708df3dabccf9e583a42c9ded108622da4254c725ec7ed9bc80f1d3814240627ef08ec571b09d4094d752e08b2bce1c65abe433511d4bb019f70a0a311a18a00898b8db258abd8fb7d958e18f73c80497b04cbe7bb550bf10e25fcce8b00718d158973afcade81ddf28468c0da8526220cd2b1cb43e2b6b8c8060df8f7ec14fdfdfd2b9ac036ea7ac4961a9200a979fb7fc3c3912ff1f10827b1f004bfb6a664cab2edca06fe55b5c6ffb246cb1c4d6aeffd06c1547c7f2de472c5af7dd0e863cb64f2c1d86f0fc28c606a08b96f56ae8b1930165182f426186e05b5f6097ab8b861e9a169aaef87ac7bd9561fdd43ca2f4c4ac04709333089f5fe521e3729803d1ccf1f223065ea9f1d33a1f1ec9403d1c50e4cd5feee6df771452f554c3a5540675998992c0b9015000d2252a9b58f2282210f54727ace72126945ee117537df64676450f070a3319064550ffa17896c158b5331e2296badbf0dbd0d6b96be6086da764a4aaf993f0372aba16548e9f8d43bf5a2116df9d3a35c1f84c9e79abfa63936a1d70cc869e9ffcbdbb94dda3b12aa7e0b0ebdbd5468abab804844001584c341214348a289549c4d7883df498e52434e19aa6e8a700b31c1c0772866c0b75b44739c9681f079f071f66eac97ba174c83f16fe21641c66ccb4f52bbb3506b62c0ca109cf2d3d9b6f6cf4971ac6c39f5e4c2a2e715a3bdb371589c8e99a6b3bbb649de16549038c5f6e1b124c8061703f82684c1b49136c2847f44f5e31fb5b0d510a41fde0415963b0603bc8d338e766c3babc759a90c7b7a023981249d13b7bd08ddafcac6ea6e5ade8b01d77ed6b102d34f5f0c496f4d3bf00bc4d4a58ebc952d03d2efd54f116a4e6c16337368c8d15f53c96ba6bd0eb29768ede6dabb6beebd18d91224b73d37a3448484677d2c06206959f9cbbe8c7effea1fbcc072b41942911562a8c7503c9174bdc29424e10148dd634acfe7807c19449b78016197450b7dfce09119d21a2173381fb83e17fe280931c7ec38115d8b159b7b58f752f037373af2adc164a3015a06d65d8f4cbb060784bb5e0f1b8dff4ea5c6631b56b3668b83134a4edf9563f24a8fd25c9baacb8f297c7071249886459639f5e68765b053153a76a034a5feca270c85246b5dd37a2334546db1049f8e0093f6e3b5f9a58c72406ba45fa872c3af7a4205758120212e678c780c66a1e17c54b01b6e7f71044a759558ac87578900b053a71231ad45d2ef76cb89d8e5ab886208377948c0f5423b0e36661b99e5eb48bef15717709fea104ee2a5e4e2af4e2f15e8ae1bd6b406ff9c7bb752b312bd713462b9654bf2cdad90ecfb87d79eaf895b0806034a8540c6a2f140a4f8fbab7510c4fa530f062c91ae5448b4de3a1a8bdaf65d62c9fc4c058f8c01ac59bcc3e4c5fc673cf6cb0aba4c9f0c9fc4442701501cb3173bca72d3ba189d06b26d7e174d5d3fda3ad31461c0737accbae32368ea91f6e2917f78d66df6985885e98ce21939ba789e8f4262c5fba4cac2e0cd859c835ff6618b5d8cff87701bc42c4d1618add5f47528b1c87bfa4e82458217e85911c629254886ec3be28738d9755134072f61ecce27cdda1efc64c7797b450cc9f716e5174ec55aab68c43e1ca73397f9346d323195d578e7510903c888bada83b7e5f7154b5a329e6d0d39ed8cfa0ba79e8f1d99c14f77df78dd0a9bee504e7d601a11f8745d3e76347eca6737490e5635e49a01e9a393eec2ee1b2ca557e0e1c6096f5961cee36cb2c2cccfe27ceffac7cf94b8c8fd27cb5aec629528add866d025840ed1153132bc8e5b13c01ca718457fb0f40fcf5ad60de700ae0b850cb81681ea84cc4daea55fe8d648ce898fc2d11f7a89f41061d5f8ee7b78d6c2a4945cbb1628b924abab44b672b286d777ddbb0e3132adaf92d2cc1a7372f1e5404afdebac821f9f208bdb5f889dc311001e5e59017e4c0bd3898bc7b2ac1faa9efea2f5f4ae09cf116b146115ec82738d235aa223321f7684bbe3502345a690407ab4af577257312b1c00baef8bfc34b7592f48f3e6ef05964459c27a29983c7936f61408cbf30245268ca6183925acaa4fafc766facc63fafb158d0e2c6d18447ba3de1da8fcf997fada2b088e290f485d501f62d00ba9ecf42c160a9f470b900a7f452284b45cf2c653d0ebb6da95768067b38762600f279e28607de3a3225c0282e6926ba265b2bf3b03e5a4983f93d42ecad93894be1fa30abbfd7a841d98405c5f24554fbd0529578229fcd19ae1ac4808e9baf68227d58d88e0037b56791b7670f48ca0a5ddd4b8f0cc92c23b203d59aea731dc70d705173d5b939e4235fed9490233a5b8b9143c81f0d9d83a6d268d8e4179485f430157cd46e0732571109e3fda94c447f879a3a4ee3ea74dfa54234d3133f35e4ae8de9223f09a2232653e33e6fa16801bc208810eea9cb930223901e11535bfb3e1afe56c4a00fb61c24251acb6ea12df71a4c2bd7b940c376d25d0092f66d0ff3630cfe678ebe94007f9ab5680b16d8cd2f3d49237f1ae643bd7151f4dc2ee2f046c2d35e2376ddbbde1dd1756e5bfaf9d68041b6e20a9654c23dce6b4d462b2570ca5984a7511b46f208e1a98e768c9307345655f7fac36caa12e7cb1e3a388665f0513d3389104a888708ec5f3a8b5ac631fbe4df7f8606de6a247c0d4b4b135dc5cb47b7aef529b7c8ed218ace7b7eec76846ffc85270237c14864a1fa3518ca4bf64eb17514087862d68895a7d547c46a161c2119473e5453abca0df4e5a0d847de10a0f4d0e339c20d040377f207df8cec6bf4c8b31245b74e547def1e0a4e091ab15bd139dcdade657d9fc7d285b24af14bf086c0794d55ae06840d23a706d5b26365069fb68b93aab99e5d139e33d72ac64057b88c0c5d83dcf189010570afa4a6fa11d23c6240a7558f29b20e4a2546e57a7c11290c0c46492b29cdfa057cc0d599cb198599e00771a222f45a1c3759424481c0ee4d23368f92aa78079397653a197fddca307aaf1a7c2f2bb664e0dc7881d7c2c92fa84db004f8cccdca556ec9aef08a9523ac0a80c1fd9707c7d73bd8c4e4f819c2fde1bdd186bf710deb0455ef5b826cfb5817975ceb7a2fc73f9c98a282900c572f15f9258125f596bf731fe4ae09582295dd2c7b71e200e8e307d283ef5f71373a3ad5d439f319a89b9d142579270d1a03e090548fa11c63b944af72c65482b922cc9039a525cab9984d648a96557b8d9d0983d6cfd0f115e02ed38787c8ec03a29ce593a6075de8437ac84444a1943bbcb6aef646dd0fd7f30a4813476659d76cb43de9081b92fa11ec19fef4b981c876a694a88a4de7190fb25195bf28e5a2acbb51b5a6f904e214ec3ee5bfc07fa6cf9e218bec9967215d646fb69e698b482eb53956a77bdd0530766d4478fc36cf4b758465d9b85297f29d9946bff34659002cb1fc118ad6f26bb3e214b225fede785beda1ce5454e36b4694c5e546c7b6f491fa1d7f48005387b2d307fdc47c61793e827caf4d1bfcae6b561b2cd401304ba6d5155cc7df4ca020a8e10f867c9553925cb6c0dcca8429e02531a5ef552c73458acee321c3ed858dca4f45a4b9b459416b87f72ecb1c549e65af2b3412dc1d1d235f82f1cf8ce369e86348c6de4fcd026c8fb727522497141a68c263c7006b8aec203e5551d8c94d0db1cbfc2135e9f0aa89f834917336caf675ae51e009b319b815f7dab8f08908171285ee86487174ea00aab21e667979cdb8d8f02eba57e7fe87f8a4d94431048909519209d9b77e91fabc1b9c63dadb6a9d5fd08fa1243aff53680285bb0e634c59dc397dcb47a234edb1cea3144bf14c029a21149b4100b102ef7e75618ce74f09beee4c7db1f70425106bed93fdd34740aa3169633837800eb7a8aae6327af10b572ec8a389af46a97bdf94c2b04cfa86e737924db5f76a07de92fbca3f2459abb3549f89416e238b02eef1e23d4f0d62d14d76bc1d34cacd79b43228edc7967cbee2376747d5c0328e4fef4bce0c8d0d8d19e58d70931f1cbc0998d2c2b63c6c63caf047caf08edee42391d211f20265313511ed80bdefc2ac1f79e73e2d39633675213269bac48c73d456e60929be8df8f9c5a0f48b25931ca7151927de969c254bad30cb325834137b3082eaa42ca5008670d2f15e593093eb06758ad15042f3d743286fcaca409a76228f563821a79ee947be75087979d5b71576c5c493c1b43f52800c6aa21cb71ab10a4d975a20b849a7b192220b783aa2e10956555999736688aa32c857990e278b1aacd0d006190f58adaab3babc1600883ad9dd4ed9c67e237f9b6dde51b79ea1d316a72f9ffb02fbf3de69532b0af3279d8866985f6d9409f46a89c8b7aef271fa2916f87d3856ff6fd91ad17d29fe3f7e0d52138f1eac0d76a62c5ad16aa7a26db8618e60d08c31cf84f60aa3565da45d22cce6653b302542f3277be61dc8adbfcafab99278d666c5bf3bae05c605be84107e73e5200914e78b5d034fca2bc4f5ce895e315a3614e367da3c9fc66273a6e190b87d6ad4009b000e8583ebfe77049bb43985e82561e44fa6d1b30e4f03d91e98fae716b9fb8b081c3fcda51f35ff373d14834eaca4b17bfa4a1b5c5d9dabcad8e411507dbe809ce3f20b237491c3fa3f4a4e99a9abc8fe1a3d4397acd21e25aed4deeefc4c5e7a8796bfc4e5f1000000000000000000000000000000000000000000000000070b0f151d232a33" +} diff --git a/api/pkg/did/testdata/mldsa87_challenge.json b/api/pkg/did/testdata/mldsa87_challenge.json new file mode 100644 index 000000000..238f26513 --- /dev/null +++ b/api/pkg/did/testdata/mldsa87_challenge.json @@ -0,0 +1,7 @@ +{ + "description": "ML-DSA-87 challenge fixture generated by bigtangle-ts PQKey (dai default wallet).", + "seed": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "did": "did:key:z446Gmp1fqLFYjzWis9CFpsWTLRyjcz6Z7PqsSBJJVpQgipNYowCYawJSdjs36awc6xAtcJZog9VLSHu1VHUom8eCr6gwzHN1zUNtqEPJKU1q6M5yJfvGT3tkhP2zoVXg5iGw8qXn9cFt6zDFn8KwTeXHgU8n12ijGTiemtY4xLd5QaFUjDfHLCVYg3jrdUSdLb8mBbH4CcLhJio4z6876mSEknYZ3Duxoo9755sRb4UVsajxjzX2ELxVqk4gEftgD26xKsi3SmZ3hnUd19wv3XdRXaHfqK8SV2SeGPX8M4wtsxyXWbAnUgGUPm62kbTSfywJY1hhhYncEkkrrWFiTdL7HMAXUWx9nEF3kknEhtubpVxuJt6cQvecrgQTmMVZ52troeDTaaq8FBtXQ6eku7kQRhMivURtZAKn6eHRTbqVCy8SNFvh3Uj7zCPUuSydf2dG2WbFjuZhcCMGrFAfDTtkcijC7jkzg72A2nbfkWHKsZWgyhPqPMBUf4chQ3WWSSwvpMfmUUHrEjxw8PE3wmMYB54CSgwZTgEVmtn3XHP28hu5SSZCpsTBmM3GKYymVBuKkFsX9vFqAFDV825ewN4XSem4o3tje6Q1BCbrJ3bkJ4thZA9Pxz4B4egqxJZcCf1zc9duwP9XU7LrXGuzYUFBQH5AGswvYYoyuqMYGSLHpPLihqPLdtd4pPCJpifCrW1UB67ABTFVgidU67pfurUquR8sx9HE7gRsTcT5MAkY3B7C3LFpA8JtUZ8wpkZMNymNYnMjB84tQ1xFfVwjQkjxh1n814E1gnfuR1UvW29415g6iCdRW3ixpf566GrV4d8isDmJawWap4Mq5MShJQbEuVcjhikFhGcvp4SL23YNATqV7wbDr1hc6XrMapwXcWZWBrExh3MjnMPuCTmyHQT79K8grZcDGpZMBdCqM4PewNe6b9gw237n9oHEDUUmpNDTa4bKGmR5d1BZuiwaEJqk5gWBbQyRMBNYwFLYEqBSebn9HsWFmif3aF56KUyGXZT5zayTgKTQtToVnGgWvXCRapDcYo5XfudcygZC5ztUV5m7FJQJ7AF2UZgZL9YMxekBmggzUPohP6yuudBaHFHTgMCgWkaimWHa8uyiLxnQ2ryPc4goMzgqiMG4z6K6VQrssP6obMZXYFwStcKKreHCZ4QDsMM6FCnRuMEUhgXocqBqTHH2Gw66yJHPi7YvYjSxyPgEtPzKs1zjBRSymZtGb2p31SdPfHEUVWx99HFxKMBykH6X5hsfgN5bxyBmDsqKTYW2a5Qeqj8DsWis7pYo1MYSRepoyG2Mzc4r3fvgWRwcCiEJrFKXc1ztSCogeVxS6wQR7JTt5fany26kcmCXWbu4eqi6Hpby9RyYTfBj2uXy8GbEQhYTu2hQDzLcqX8D8Dj6eLiDfGwWB9BauQpBJez1Ra5xHNW19jnAhpQzaBggPY8odvSyNXqstkwvnS61DGGfpe6XFQHmfkJxqPHrx5dXDAYxSwojNAsYBKozr6FbVFxDj3XyHDmd4SWVU64hNS4ybfnFVt8tPGbiKoq5uXcQCn3oV1ez7CXYrEYwYtAGqHNKd3cAY4x59rj9h9CYe7CiLyTc8YE7ZNczqwgMxds6fMKQnVVsMFtSfLMzXzszDbVEvbKJGNCQBLn4bNn8nkaDGgnypS1th9WYivMw6yxAfS9EFGHYYMWQJeJfpYPrMn13MhpGx2B4tqSyZYRPNFr4PKEcE6bAXqUJh5cnMRDWWKrnYTUJ9Sq72sb6LpSdRCzrf4JVeaqb7R2RSK6nxfhv9oe8EWPX5EZNy7q3xwskRRg8XnUmCnYovHrVkwHLbufKrAvgec6AecLfVLpRUVNgY4f32w6USgM6qLHTBsT3f5Gf8VSz6JhrHLPR9f4vkR4bK6BkM3RU6u3PRSzt6jLGx3LqLXpNfaobzpzoMLPWEUjttCLBAeeHp2pixnzThngrymLoY83hgut5iVwF35ffY3H1BVrJPsrnLjmnyZdah1Ehj2CeFYMEkvBDLoqhp1KxmXpbikW3jb1dpoTsC2ATWmaVyuyyyTxHoyjrgNqqnhnfWQDeCdFbQHHJVZyi4mbyFgFhCiN3186yEj7wzWyVVQDtGmqEVe9U82gnSdxxrf4D6xvFLVtmtDhx5i6Xy4546rN4GhVf7M4qrhbKj62iLUVYbxELYrnPKs8Z2Bdxou8EfEWqHgcaUUnno32Tst4TPMosYkogmMnsgK6jSdtq8TAS8LZ4xpTeViBxUMmx7LUqNkbF3NwaFgfbi2dzWGVckuqiSKxdsgfgiyb5eRUvenkjrjzWMqntMHxzo5QESnAKLUN56ijec7cT5wDTJFC2uwZFgicjCj7Kbnhrf1Ys472czvLKKk2SJQ8MN61gmCxPx8NrMA3J1XbjD95F3TpB5StXeVMf5w6jAvrDvTTeEuzTBQPFpSXYnK8oqD7w52CJF3jbR3hgxTfK2XEQkd9ewVDm1Y6D7FRH2sbSjzyj3zLM55LZM7E31SwXRgJ7VwBEUwFcaCSq6tpoSrSXZk5cWQGydVf2nnnnBMG8L4dZCukfDPndDZRNp7BALsrsaF48UFRXt2wY2ZcPF8NfNqUgwDu8eLYyq55hSFBHdS4gtExpz5XV5YqmjWCtevsJ1Ej1AP3ehoqTTnbnsRQHnxZMFjyVtT8cFk7QQRfdtXAnJ1MVnccBbQzRzx4fs1rPYCrFXaLxnmKY4j8tj7iNcZCDVku6STBdoNUg7tt5yv5TzoakxC64mTuNnGvWbvyTshZp4nVXLj2ioNW25isv4PSdUQmUcg69esoEaqWbXtZh9bs1BkYZD7wAbwExYqCGATgjT2afe7FRmL8gYisN8MXYToURfGgxbLtP6AWipHZrka8wergxGWftQu5TQ2wz74bh5G3mFupoBXCkM1SxzmBRg7NhqD8pHu2kNvG6aybhKCXjmXu3VVoFoQdHQ9ZcYUVhqUsGgUbKr6WCmtHsC3jWurBMwGyNS9UPRjino4idJUgGXRMYVEgFHWqDLZ5FjXAr64gyMdKLAor8X3y8nZ8qR7VpSk1V7b8osJqFaRtcDGyACNvWwUKJkEiYiaJ8SamauyCsrBri7cjuQZwmjwv3oZYdXaND1i2t3msnexeJFvsqW14MLd4vcwehwFjuRZRFwZf997QVGGChVBkKGiMZVJmxjFa5MuGYcjH1mf69wqx6EaBKpMFwhnnRgD1EmMTu1VMyBaSvatPeLSAwqqzErHzLdR6nihjewuw1FXQLoFxwPwant6mtQmwGsv8GNqdS3sbSRpitbqzPNA4ZMGpXaiVdcWe3akpdfoYGaah2rF34M5RL988eEoWU7aXjvYNUiPMf5vXikYAwf1qTg5Cj7KHZMc7h7BXc3JiN4K7VqroCr8xcTLENZdFAXpQXW2ZPvdqUzvESPwfpuQbWHcGu8HdQrnPTmw7ii1Npt7ZURdyhpi5Kab2JxNJsKPVwJaMiv7dSgLDXGzFRypg5jVmgy3uNm2h4jKKutupG7seFJrWp42KSRVAyx4uWncGohFwnaf", + "nonce": "abababababababababababababababababababababababababababababababab", + "sigBundle": "0101011213b4cf3e0ff91502b41b8711ae69df406e4cc934ab9e67d023c83a8ac2191c623e79be2ded5bf79d65f86e4400dbdfecaccf3a24750e147c76224eb54b89f85a469cdac9d763209ca8b09a6be67399ac39001a7594fed9d6c5667b775613ec1ca0b46694fa6883d0ec5bd0afcb7d42083075159cfde65e62ce9135c5a6e723ecc9c381443685bb2e0f9353d4e30c154cbbe899ce4f03b8ae6eef3a6580517f8d5e6762768b39329b5503dd9aa4ad62b0c7abd2161567592ed915434a24559614d276b930c6ed6734f65102d465c37924dd8804b5114de68de3a7cf141d4d5ec96cf548c16ae5596ebb8b54531c82c89fc720aa5a961bafc033327e7dcd5728d3855a502043edd46f87a7d5819dbb7c5d276255a18b7ecbca5da1326dcbff7287dbb95029c3266dce8ac5ed39ee1366bdbf4b77be2e12d206a510d5dfc590dcc03dc4d1bd97e148e075c9ef3b35ef3cd59daa507908802988be28d888a38ccf2ba06f7ef918f856f237cd45075c91a31c5c2342a1ed788ab8f6c0ead7e019654232bd65128c565e63285c5f6a1bb11e5987ae1870403f2a5503c08a51468a4130ab4acac314cf5da867731c421c953068a956521b3fae0532bc7e8ab497c286dad9ea010a06abd3f1b4d64cfe34012dcf00c86766af6508b720c3109d5a248a6c716be9b181c7693df67b728118543a31627a417b300c325a39db5071dbe0f855d9dfce755841f1d05e10f2c0b10699b2f7a9ce028a200996a2a21a678aec7641243be9fe1d47f57214a752d9eb074ac43b96601bd31a766ee73614dae7ef5b9c2e11b1b7c6a5e882b07be531f6c245f092190a0170db928e34ec3a161b5f4e7059c0bc7d35ef925f30cba81ec7ca97838c28d9ed0de015f93032001a4b3c9b79447a0abac66e871ad0ac7b9d69aa2ff4eeb919b3e9faaf3a35586528b1459cb06f2326aece5e3a3bab8e5e7b9ec551af1570bd72337dd89f93eb2527e4cb2b6ba6cd17e3c78905fa1ec9f75d4d714efc48a6b7f01aa3a5bd7fd4d29182b2de672abf345d6923e37a80a6285c06e74f8c8b7b967ab315ad0b69c48aa516e22decf2be63dda67bd079fca121109b2576214d8b76ad86f501734619890f39ab706c9f90b4ccdb5698caac964f2aa8be4742d37455b0ee130d4721df62c0067b1bd36e7192453de49a9986d055c1ee8beeca82c7ea5665b4a0db116e5c75e965786ab683cb803644e76488596149c3a879893d315184b1650460964d42b632aae1dbd1d413bc91176db99b972590c4e15531b6f7a67a1bc920b6939b9e89f3b04e6ec65a1793019557957a2091949f806c6e16d468debac51a99b0a0fb88f096f43d7f35ed3f8883474ec87aad3b899c45ca68bd635f5bb06da5f8dae410d74fafec5336b8b967886cbcece70e3b185bfb32a5d515d26ceb7be5acbf2aff34ca62f7d3ac42f71634db248cfa095aa4adc3e6903be3fb09f70ea0900478ddcd4eeb96175dd89694070d389b758f6d47c25b8965f1aaeebb0d2574c0b7bc7d6b047643e5ba682579e007aa94f9cc6cf0fd4476aaa546581846a4c08fd7805c0a3b734b715fa425ca31c0cc71701b0d611e3baa28310b5a142a58405668b0e86b8e2fbde151155c97d53baf663ae71a3051b1d4718eff072d2457aca758ff77443dd0b31043329332c5265c486254cca761bd20b4b6398c0baa23dda2c38eb0e387c851f3ba94520b47ef5e72cdf6cd9d4ad2160eab1adbace46b51b3874bcfe496e1d05372076ddae7b558ff28dd5c06d74e567675bbe43aece35db3d1b99a41b3b31f70d67c481839e2d3e2b8fed8d0b890aeb0e9b35e3101df0b24ca5227d48a31478e7674a89553a5b6c8f230b03aaafb3b4185beea010efff17a16dd1111dcf27079196a660b2c7e4089c4729c80bb0e05ce53bd03a8ed405ef01869a72253f88286329cc9de5edf5cb81f05f2fa232a892c9afd582024472eb423e9c40803f723f128b5996351453313c9963fe7b80a4c22300be99a06b81f252482d0c53166cf9fa691ff5f79726af543611f8635c1d92efe2b75e1f3e809eff93eae1c1e248621e66c2fe01ff5283b07f423f90b4c973a97852c81cc0c21401f8050098726443ed9f4c5ae85bc2e48087d5099b00777fa38f0c12c18233a1510730d3acdc54f55d3aba67a78335183d47f85793de4db1f6a13cf2539a8d6bfe1d78f0f6a11309b794ad0713d08b69b90548d4a61b85cd7d5eeb25fafe08482a964fb003db8add6d77579ddc947cfc492444e20e075a5b4360c8a3f6d3cfdd0de7e3efaf53dc1c9ed5b10aea7d7ec15bd78a2389444a51e45074c8f57f1a064690567d46831cb522209f54fd9173d18e7f127a6231bd7028216afc4413d5ed1ce4e93e1ef86e2cdf4a026f620e7f66e99509e29e66f04d06eeb55d20c95a82024528d80034b565de4e941174a82900825ac14b956280e8ed39fbf60df3e0ed8cca10236f2273baa5a2eecd3f1c822d2c6f0b15c6be622c31d7555c5a465ee3615f45f44301c575125ac1d0bf9a86a862c47df85cbd1e18fe3b766f4ded8d4cb45802a621192b8a8f1a0dc9715280a8e9416de348ae9bff2b0589f67fbe3b6218b494c52473f5a095e7bc4c513d286e9bbb92e9efbea4adfa17369bc9bbfba644c05e8fb2d2ca7c8360b8b58016eb31d5053620b9489d7aca55e270c33d92d647dcc02148a3519c756e56098ecb2158d80246c695f930255b1f27542f1a29fd98932571fe0985f3531417e875e095678a833fcba2a7e171a2709ba79b728fab791dfb106eb305ada9a32a05dbe304875eb8c3dc070cafd89b248cbbfaf93aaaf8002e8805eee50c33f3caa49d8dba58889c7bc704e7ef0b91eb4dc04ecae4bf1b3fadd83b6149a2025be114f60837581ddbe501a7f6ddf6a80a49eab331ce58d5a1f26044a4d7c4371357353a23e3ec86d74828048d76d3dc834e53c17b4910d76204138bab8ba45ba8b4544b96001927cfba3e6a60a8593ce527775725f3644060907204cc4a823852c33a0c8b5f2d751bdde2e37dbdb6d88ea79f7eb165c0052cbb49c4ad7efaea3f6327a1332e3f0b062af21deee61b95c8dacc0a7dab23739459bc0e709ff1eb41c3d9fb59ad239e0bc2bf39cf2e7f927cf70c47aba447b6903d0bcca8439946af3a55a43c86998287cfea700aad2293f276fae33d51c8a35dc98ec0d79d8c87e98ca4fb63f71081f60e23c8e24822cd298492f668016f572471031e27853de5614b18bafde47fd743ba2667325b47e6a67bb29289cba6dc83142e4210d80f98a62483aa82aa5f281bae045e0ccb44300e48f26fe44b4884c022ff871b8ffa8c4bbdec7662567d7131008d77627a5464949ebf2c20d367cae61ef0bcee24b3678a8204581798b447cbfca1e48384ce8e1a6c62d38f22de2e43614f87d17afa78d9223383e5a54a611ab50bb939fcfab2a379945948dab7c259fe6ad82650d82d964aeec15f26f15a380304ad9a08d01370373f154afb862bb94084687538cf39079366ad24a4303f8d28e30e0f69d52c453a620591bf9e3dd73d7999778660c3f0d1eef281341eaf9b69a8357bd4a603db44a8e2e4ebf3a8bdb42c63a92294b86995c7d021e02cec67a42ee8caa0c2e50d52741b24dddb60a81d1d74a97c59e2a559b721f184d9ec705cfbdbac40ba9814903f2d706c7609bd74dd36d709e32b64678f0582424dea5e99d06ee7da1f8050928f5d06e7aa4ab9b3eb8fa8d91e46cb48968719c14a4c47aefe9ed6daf58ca165710a48c54263169cf1412f538b1e8afb665fea12eb97e36d42477ae552162c3f7e0d4ee34d0efd1f5be5f4704bc6e9700737d25ae61cf871c1b60aeb6f9193083adce17cd867c6272089dddefa6f9afbab30716c1b6504c04054042877f7a0c1e760f01939334ccc4eaa05de59c33af6292a0c206447957ad29bb0eeece1dbfb1b8c4c019fdaf50e0601cb32fdf486081971715d2cf605ea628ee01d6b5bd7bc639275e7ff1ffd1f043f55105b76daef00410625de4d1fb7837931a2395108257647d395d242fb5874124d36f6db7b8cddc064c7dcc1fb0ff40062667ae6d67cd09c6fc1018b6dcd310527f780e57882739b9f2c3fd8ef702f8aae641d4f66e0beb21e04310f18f21d6b2a72d026964f1a5c3c02b72be61871c0fcc35d5b5501fb52da2ef9d3472a3386068576f3215754c673349cd4089b4a346c034ff02a7cbdf99929b43bb3958cb7e2cd9ecddce78654cc5788b7350fcdcae77dc0ba17085bec32dea56b46cd91e40fc7f81793b672b2fbb586f2a32a8d07342a5007bde7fb53831765fab7eb43e19e71ffdced5f3c2a505c0498bb261ede8d7f53bfa9baef58538bb9047f9df05cbe04b41f281910192d2b6c2a71e24d08a68f19c548ccd3e32891cb35024f287cf1c7533af56de1897bf30f4dd0509f6afa51468f07f71f56549eea7599e67750c79f25781c0d3e7a5340a99901080d6f12de1207fc85c9c0bbc8733a1ac5a6f6d392abfed480a5cd9352cb35cd140e8c9a904a05bf664219897a62ac19ca0f2f2e3812731ceb38e4a57fc66a8d88e3d277ba59ad3eff4765bf4085321fe88ad8308a5d6f7723d8aef22bd3b69a3d6893d83984760b9e967b1101fe4d584f4197902db8521a44afccef73259a45f4790be498c832b486bb5d9d2959613c1729fef44025a3834ac02f0388ca7c9521508bd38bbe074047653b8539bdd1a670ab2c1219608f7934fee6371338a3045d51c3a96e4551bf87236f165c7e628500097bb4f5914a08868a03ea837d7ffd8120824f13467a3cc0b71d877eddd9d69ef30b47a4b0ba9325b749fe609c47a5a916c3080b8ade08f8d41785e88d17a64116a5fd4c9294360e02a5d03d9a4915909d7afee90ba0eae5326d641e97adc88b1763e5e879f3f64c10ab59c2940f95a13903f85d2d19a8ce8c9edb0caf1cc6acfc01b141268df95e304d37d5e53322909944efcf28d33c3316402b90b3c16ababacefeeec0be9185938785ce73e0dbb72bb65f2aa2f4e53155d3951a6982828a76410b116efa86ef8d6d5c46d587abfb21ebaf9dae4dd93f299be6e3485ab802a5b5da09b3ff03bc362faaa716f5a47f4cf4536b690ff6e73e91778ccb297686436ec6e95756f18a0992256b59e618bafe5c214380564cfc06bc6a091a65ec463c37ec459391114efa8393b2ab668f1adcf21efbfdbda78694f9c0f283e9a45b0ca6a32cf785509c0b182dff0bd42af4625c63c63d4988c1957a3342f192197263cc84ad626e804c96f8b51bb2978cc61719f06314baa4a13cfc628b10970ddfd6fedab6f0e4ea9b6bc2470e3b78a3568043e1f3a1c3b99835cf66d77cf0e420bc01e6c6cf8b831453e7aa919204b488fc69e8fae0af17d514398ab1b2ab551b4bf3a8779fa47476e31fe120e1c71cc25c6ac2720c06ad3d8d4b8ff8ff61c2bb89db5b9d36d3e5fe49b606aef09615e2f5fc13045c30af18fcc3c244df78267a9e3b568e7ab0948de54139bb907d5b5ed3365736a544c8ac86c36be12ded1be6a4803201e4cba202914db7cf2d20ffedcd7281feb502f1051bd25889e1566adddc964c729ecda92108d4aa961f2870f66cbc4e11bd6d262e296526748c4046dab03cff3d06bf6ec6dc7fab8a672c0d0f17a28b644c58f48ab4fbce997b2c48bb72e167c2c73091b0ad3e8019991c38ced5795a07b2dae3d44104782c55b85c18fd3978d8d90aa2249df04b7a176ce5d8c1a9ae846be8fd851fafa831c809c8d797cfb68f9ffbbf3bf28af9c10f5690bb1b15b153cccaac0e5e1d3ef72eb2b55e6cbc2a4a58f1f5bc270bcfa7ed42bcc80d3d97aaeb980ac7ca4749433ddbf170692dfbae5e9e2cf8db7f3fcd23ea8decc5b7cb0336808033c78125a32f1b8efed8ca957cb209ee740d9fa48f7f0201b7da7f2ae41f41c331ecf0e4f03c8b28d3361adb1f4ae8db76825a873df6aafa0bc0eb1f023d1f42a13161883fc11acd6fd457893a96156c02404863395b7412d71e9848c1bb3fdc12263453345a7264b66c55feac176dc4983f4608c5c8be56fa719f4a105ce49fea3590d018c4800b141b4ef52a690a1a0642e399493c67159003e43aa0bc3769998ce8b7a14905bc397a25277f7062b8dac57430f2e74e15e28bf7ce1af509d33afd49ae42e1ef90bc4b37cbe13d08263489105dc777a5e549a772d0e4347043fcf0f399bcc14f4b21d6e3f64301dbba7265c073daf390e3e4d21f379b09cede2dfdc3d01d967069b2610d94120e1510249a5f69785cf24fe2dad2d77628cbe2e642eabd84de3e5743f3fe09c3809e9945ba7bb56796fe1e092321022e9c287c3b0344a8bc2629ba8ea335707d9a434ca58e8c329b1f4e851b3d70f4148ca4c6a186a05ff7e5ccbb65ff68af0045d1732557476c1dee34d5770a4a7a81e268d94bc02076179888c9098a1b3d7df3851535bbae01540a1c7e90f43496973a242438d9caaabace4f5f80000000000000000000000000000000000080e131f252a303a" +} diff --git a/api/pkg/did/testdata/mldsa87_slhdsa_browser_challenge.json b/api/pkg/did/testdata/mldsa87_slhdsa_browser_challenge.json new file mode 100644 index 000000000..fffe7276f --- /dev/null +++ b/api/pkg/did/testdata/mldsa87_slhdsa_browser_challenge.json @@ -0,0 +1,6 @@ +{ + "description": "Dual-key signature produced by the httpSMS web signer (web/app/utils/pq.ts).", + "did": "did:key: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", + "nonce": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "sigBundle": "0102011213de234a1639ad5e0396d8f8d5ecae85756e6888648ef03d5fdfafdb7043a0e4042b9196ee6c5d3152743588194894accfd173f31120281bcd09ccf4716f60b4fc46540229579f66631e5ef120ae8c775ebe95f1300eade3dbdcfefc06030f11b5c051ac9b3e22db427827dd6518c17a52cc75d3eb7d611469221fca57ac8a6383c9ae163bbcacc6235325aec525ed7e9eafb7d6c147803bc0e64d5bd138bb71408cf84f6b343ecc7faac9760728420d16ccab62f445937d3b44a18ba95ba6258e145807e826132ebad8bc0e5c3d9395ef4915c196b86406fbe115dc82807a9dbb4092d827d952380ad15856ff2679d7cdc6ad5d6e0d26f429cb68ca84752ccd1d7738339c8d8d0c22aa2f840db532e378edd2c138962c9b8df96c6334fccc1132f2574df81866b9545c59a7a3634f59602990f7b7bc54b8b5bfed3a7a19792d412f3b69ee14bd50fca9e548307b367eb4326b7e8e654c5b9ff2b5e8c35a4b6e7219a4117236baca6dff76da7433d647f2fdcc90445d3c50a83405a9cb79d492e7e64a959d3cc4be2ef548318772b26cac9d0772231292ffefc3bbf6af85880b5b35db8dab6f05eee824f4728e21dd537a4e5edde53e53b517c451f7925c496507b335e6ad610ebb544c7258730fe27d63dcdfb14c0580fac771df3229885d6901a591aacd191761f083abe8f0219af7b1b06dba787c3ae78f87a5c0285a3e3ce6a1675eaf38f4e8ee60352259c95bd060a31931bbf5e220289231f67779d31d87ebaedb13f2e627ed3a1beb165a77a4115657c276bfc1f4d2100b6ce4703b3f617c8e9bd1a657fe3127e5381409696f4cf8f4ab97ea5af27aca3184f8cd4c7b850b70ce1dc124612bea4f21e6244e112688c7d35b0a1131e2becf9382acc4105270df3226e0cf5ff46b603034a0bcc3363e3d6f708e64bbf3448cac36932a3e1ea4bbbe3cfd048ea377928fdd8d26931e496ffc01f3f1acac05ef21bf5498f63226b480adfef26230a40eabaa24ba12eaac488df4b5de18c3053bf559a45bf561fdf545ef8071e3d0e75b57fcc464e90abdede3b6e6976670cb2a0187c181f6b65aae24669224edfc9bd2d4a415ab62680e8a805c53a6abeeff6f744d15bc13460ed6d28a2d06a8432f041aa825f91d0a5bce35db3db0b54317217dd5cb36548fa2e1493b7a4f2bf0794d9cb164ec576639f55110c4b02219739d5d1ccbf93e405bb531db31a5413486e49ba5bb3e76090063204d738ab331945e6149c1a47e68402032e0fb2a39c93fcc987a874d782d354b46c36fbe7cdda24003cd763eec64947cdd49834f2240065eee60ee1867f80476a08e7ea2b1895ad33f2143291fd1fb56c9822447fa20e55215a4bee3b84bea021e5243d7569e5726ca99787e7e96697b0064faa038a9a5c770280735d810e2981e9af69afa5cbf79a1b84df21e8e9ef706912202297caf3cacb800060faff95b6655d5c58d67c2043abf010be774d71fc05212b2103d7d0f7f07bcf6cd191bcf8b7619d6f4987687170fe95f20c64b89f97479095943187c1761c552030e1c399a6a6d6c1eeece1c563adae88b6fbcd5f5754db0c824d93b5d39a1a33f6e086e96b4fc70ea07a6d3bbaa34e74556b1967b8a92d411e7c880876b9c744a2e223a9a02ef74346416eb1373c47ed8f99db1805d855fb7a991e8ccd6125340149358f161662a8ccea41b5c0c900cb081f9f76aeb1d3ed5a69c792a5fa7ab8cb178e32a6cd64d66fd9371c73d6f9997721afd9b3eeab7e7b4e6aa4f812f069f1f64093286d9962311dfe5a90e9ce6adaf31548c1df6ca94f08af038425f967226e88903b1a135eb9b729f382ceef332230ce7e6df16c27aa07b7e050dac1e8e66cda055d00ee999f868bbfdf68a6b126a1d3abb430dad12d5e47188ce641b5bf2c8fdec5a94d2ab28668a43d03aa5fce5edee14de67743259164b2acf9d359a37ddae9b4baa0a5f9294fd19693fd8ff3e021f1fc6f13ce22ad31b68d71e85b7950534ffc8cd3984ab24adc68b9dc656c2e589e1607201123f8e7cd8f235fec42cc016df341cf518b10898c87cc52878a1e9539641641057542750c17d16976471c6c7d454782dcd6832362d82221a433781007b60f4c1814b295e78d9f7f4ed1d2ea05b1c920b93a104d19b19906250e0d70a0b11d7bc384b4e7cefbdd65393d0505255d56995b9a0be82024dda78bd592def2360a184c77f1aabf05941ebbf8fd32c1915fe2217bcd01984f278a26743c77c1e03758bca609757d5892da379db63a02be8623d1c41ec98c8b287d6a69a83a4529e164d1f505b1b419915350a60af881f7493d5d5786ea0a4b7c2f33f401df78182ad8ce1e4efd4279438ac4f124e29876d6bc2e118712aba1586751af072084dbec2dbd33eade97df596f4fd542acfa3c4a6b92ffece3e0d2a6a396e7fe5d689c26a48a9b3a0ce70eee801f6aca7e04074be81b4ba3641a00554541492b62b053c477a26a5a3a17c6e4ea50cf82c0628e21a2eb0be0ddab44316e128062bdf51d0deb61b246f090a9b8a419a9a2f52a409a03d7c73026fdb161e8a083ecaebce731017ecb32c25e1c70db9d192ec43a55cd9b9c482ff6d7d637663029a025b4448e165a508f63fa61f0284eedc831abfc60ff77656af492a40fac550497bccae562828f9885e6b63eee32d455838b7b3c8ecbd03cd6cc9a89d63d36ae3bd9305e16d9d317e5924ebff70e6414265b29d0c405fa2edeea90ac8cac5a7f00ea6802ae12905e6b0780193245af48b392c5a09127e9ea03719a14f8df7783219d2d49ba69c2e3141b5db26043c79cdfbf0656b223e4e586fad685890405b8c65e24fed788483cfda7cf57da43c24469c8c5b0908e2cd6d87d1c80ab4e830e20d87deac2cbc442acc7772079b7bc1d7eacf47a120f5921a2a487632d1e5c201062f889c479bb11efa5aea7fbeaaa35dd42a8e2da72fc0ea204cf36e6fa6a1fbfece4508b698e449548f6985c5a550694c08d5273a05012b0610f64799dee79a5fd27be9a0a701fc60bb2a3b171365f447f9a06255fb46417b8f0cb9a7c4176cf16aab1c17947b33d17d4bacb0fbe57fd4b01fbdb4178bcd819c05c6dd3116ddab554f84692ebe5a3c32a6ccd68cb7d30717518b4112ecb5e6a079cd34cc1669f98eb04515a7fa976b04588a5bd86763445e07165c9f2525600db09e8a84d56072bbd798839618a742b45f65a72005549838e2af3ac4f5c5eeada4f6717d207c1a0ec01cd2f5724c3de9df8c16bc72f5d324c8d0b61a88cd6b23ff5ba1db1d20170def87b4436b60503c6306b8fc8da996c4ec139af88381cab47b7cadefc8c005ab36aa25a93664e4b667d887f9d8cc3339964a08b3b8f49d9937d2e30c96a27cbcbde2074394496f220a3fc6a01319c9db405910ba8421d2c2b46e651328d813ed09b2ce5c8bc2bcb3028b76625a208f9169507c855a1fb791b35c2b92e1045452afba72d731fadffd87c7033cabdc8edaf3a6bb44e1b42ee233eba41ce4d059a5a8508eb36c2b5e980f72197dbe9425c96a4a307cc3cf3db33d933c0fe7a70749e8b03611381dbf8b7fd8a3ae730ec4a7bf6ecdd330758d6e89db014b5ad96f26e4c126dfccadee33094617716afc85d4af4e113b5ce94318f941d08ee3afa54f424cd153f4d8b0b805b7b1ec0886eea7518c2ced5fe819f2b642e5d6d735b4e7ba25ed94be200615603b92d7de69ec374c58491e6bbaca12c420950d78c9b2fb9744495423b781b11d66b49c52d2ae7cd19bd50aa9594e02e5884b96dce3e753e479db70eed8521be3dfe08a881a35bb500b51cf62e89c0a7da1dba59525a092076f9943375106f6fe0b0aa78d46454340ab86d3df7068f801c99bda66893cda4894e6b15accd984484a3abb6710dd26b395c15f0749ec774f2654087c2df116a9d65d1d5498475aee9ff70331e7ab1b2af0ddc6b461853b04931b2e58ce34c1d7eb1b838f0a4ea9ba21090e8ba585653110625305732018a71a9889dd8a43e1c7698054d8a55b0187e3a4e1e9f844904b80f33394c0894c762469fcdaecd2a0ef2631a4569ec2811a20adc6043a5947ab2f384c13f6267eff1bd7009804c2ca9f545850758662da9b0dabbfae7627e3de0d57b02f7d7148fa0a169b1bbbb7e5ee8ac4376915d158a53aaf7ee3305bc394b23520b2731b6113716730ba581f787bbafd7d87b7723b88e1cb5c4c0ba01ab00f0d120f66f3267db22c0cb3cfd4a045265bae335b9a7614b8fc9ef1b8ca0326caaaba2bf42ff246f4f138ec1d7c518c0a1cee122de98afa076a099d9d3c49436cd08f235a3dc531c021c8dcf63b82fc431cf269b5e7a6075a8fd68c012913d8f91bedebade7b8b8f23a5bcefbd6045b6b2f4a70801796a0d0ed79c2d9944e44b1da32bc50e2fe23c48913b145d2abf6850f3e0110cba090f80dc4709c3ef5ef6fee4eb0a09484765157937e10ad6ff2a0d74bc3c2252e11651cefb81bc07f4e9f2d465ca9521d74c7518225743d00fdef806e3b278a4c569cdb1ec94b843306147a90c2ef05c4105c42ced7ff3709401ca71e0f232cda1ef6eaef5a8301442a574b55fdfecc78a915f7224d48f830009cbc2b48e1096338b65c4795ffb86d9947d7e7e49be445311fc0db24e1fdf3f1964d90853f746c9a47234618048e37ecae1cb62b4543c431204270899d1243755671488397415e74398ff2871715ed943c55af4ef06bdcdd2175d675e86a7e51ba9cc96189d225772b0b2a38ee32184b2820800c29ad2826c64956c0a5a738ea897652a494a0a9daaaca585cb9af75d262fd69aafa33cf85d41a5c2a0b58a7ac5ece6205a20ebd9f885909803234934909241d0e3972e017d7cee50d3e6fbd6dde924dcfc1234c8f6096995ccea22ee2ea25425f6dedff808f82c126c1a14e76b479ed3d267dd622f662f246255fba87d89998ca726cc44aa6a2a62762c7cd1fb83e179a7ebc5550d6411ef1e667e08fea7b3024a6fe420a7bb90579f2bd6d293e5d245d0f38e62ffe670cc0294534dad75862dc875c1e09f8834efdc978f8ee7ab6fdd69e17af30bc10291afea152a5d528492d4c651f4f0722bce4466637f2d4b1ec8e7882a6ebec0ccad4893c2b215153b6f1785b40ccb1c4827a4136fa534f40ae7bcaa36bb998b4a0a4c48591fff68f2cb4c067f5ade25c97fd9246febd4e72aa2a8f8134d3e1f10175caafd2b012ce9bed3afdc5ddb877aeb67ab1f7326b803ba6a982093823878a1ceebe507bc3ea66ad826b6963c8249b83e994bfe8e3e66fff3152f83a281ea125fa07270560b8933d830d3f29d572028216cfca97e90374a90b771aa256c02e9f7c2d5c0d5125245548b2a094e84041f75939c99b4a4be1cfc80135e30062a9219f2a89aaedee51caf52dafab83d530254412c0b1352824cc8df7925d63097ab704c850657751bb8a21e836be46e3d2a98e6b944516375dfd8c84199fd73989256dd90d1bbb1c51f2cc0bc14b978e1af3715dc367f6f3aa15a33adb71c4a335e54f3cbe808d5199e872934869a2962dc7d742c226311964dbc91fc12d38c40a388ee7bd8bf2d1f88a915238e62fb41824da8f572b8840ba38381785d752b0469308732fb10b3fbc018158bb3158127a7c64b2d7e39e3c09ab4e4397a572e6e0944cb2d486010cd177bbdf8190348d1efadc9bdf5296e931e4ebc3332de4522aeefbe24522e31921ddcd0474afab9bc48a4d415f2e8eee4dab04c5dd662006b54af4997f45cbed1b1e1c5855803889ff11818ce656f1fd506b9f3b0c4a14321d473e326edbe06677f2d7bcd1397debee4752f8450628f924f60dbdd134610d69bab082adb2e58f7ba3489f0f4e13939af8e7d7cc565481d967020e2f14802dc0d580ec2a60aab7a8052c8a5319913a76d79fb0e8a45aed2230399ff4a67267ba8f70bf9ac58cf0349d93d4a1aa5e7d9abe65cb9d7d05cffdc8f70a229d000e8b1b5f23d1f71c505834c63bdeb084d828b155554b62016f3913889414667b755a20f3acd3ed1329267423664566f839ae76c7164fd96929163a9f4cc51ee3692ad6b9b14f109be26413ebb1e850ace1bc9baeff01a6ff382992b999144e83d6b0e87f5950a9df76fcf505596fd80101338efa4c8ae4a6e0b76124a4e2c6c0b74c2fa71294bcf92f66fceb6e04e2b91e58da7f11be0c6cb5cf2e9cebf5abcbc1be4d0470e4ce501ca81dbcad65690f8ddb819bada4298857699d97bf287c6c00fa46685ba818320e842420a1e951ec1eb08ed8cf53e400a8db194b951e758f718c9375f0050eed810748600206a33f5c0ee3bfc468f6e2190a1fba4697409063e1beff8d782c257f1118e2a9a3f4222774be7fcdcf11e2cc82920ec34a3821575566de9c46bb9bfa5974c4bdaf129f9c662eb8002337487191a5e5f1254e515389d3d90993a72fa1a9d9df020833364c57758292b3cc303452879aa7cbd8e0082a515972a7a9b5ba0b216263c9000000000000000000000000000000000009101318232c353a02746008ec4b4cdcac1fb7e73ebb685aa6917a77bba82d09c04c4a9b1d60cf60f566f74a6b81ce71352d99f2e1d15ba0d147181d44dad4802e2eff119aa77d947a290debde26bacb52cb8d461a24691589118955879b2a418b2f04acb9b3440d021c57db9999c5520facca33ee0695862366d240328de6815180ce612fd5dff06cd8d9b63b4755a4afed9114ebe95a959a634e923b16bbf1c338885b3bd421839dcb654f1066b57a11d2c5ef0b95ee8a9e1cf0423c2c01048cc3a6899717d1833fb044a723469913eed033f5734c2a3ed4483cadab57be20d276934bc16f49899ec23138523cd1dc87c8bfe03f030fc602f3d4227a7ec348bda390f4c16aba8f7e64ad7b859202a7d2d99d4792c36a3bf18f2efcb83055a28d84ba57028d75628527ca923031e42611f5741b03c325991bb1a5e210b6d8f0644dea3ec71860387161097823d50aadda6830efa64f04a590378d1bc2788740997bcb28f04587f7625a51cc6b250a0aaf70c9a5f7b119497e123eee67b80153f237365f12a3927a3074118ec54f0d15252b5026d4931b5584e57ed419e4e18acee5ee7b4706359968a8f173605bf32ca91bedc301fa869db47b38522bbd59244b6089518554ce21cbd76e06af7f485c5959f4e6c4f10a34a2ec700cfcd2ce61c2df6e07103bbeba31755d10018d9863b47c0ee83f081cdb02db37b80acbb8f5442ff05e207af6d7994ea92ea918da3dfe560a2bdac4f280440d7210cf98e3733498877b9cef6c40c94bf3021ed6911522ab9e038157bf629a137a7d4078bdd57f1ddbc67f28c6cf27f75f6c821007ffc585cd26e1da9f9b8b38bf40637a1c2c409e37e996ef1c9ea8bde25aaacdd886d0ea77b798f1be8f4fc720b125cf9617a449626b00e80c327a884eaa845d3506ebbd794f52d6c3df2fecd0e83f1fcb4733f0a5ba93fcfb91cf0ea5407e0f893c2f628304cc6a6706467022af32d2f72be6c4d8d0a7830eb834b01118db0d1215e4b8cc59ea04d9f2de53cfb1221648ad88c51482eb660b47f2467d19a411b654d403b5c765711edf982a9ace747215f64401bb69020db7fa35653abc6efd0fba61061db884ac5ec24cd2101c9f70a812a8d616a9880e30e1f5236293be3c97352a6142c9962c9580f7a11172faea82f3f775a3b2acff6b4e6c1013b980874ef9b5126933498a7efaee07c96859c1fbea65bf559e5a220f37e198b2f0df6661843feed84fc29213859ce6b6cde5eda3e85d3191e1faec49ffc542daa2f15c46675e66f3b59cca955f898d6e0d7af659322fd0f0a662f4cb1906e52314e338aa67e1fcd0c75e4926d81d83d1fce44c11eac344832c696b778440fa41500be09d3b9b6987e1f0cbc872ea3ce0c0701b005a972d034dc0a4749b05aac339e2cc7fea83bd8300c372d0137fe9d6f40a05555119ef5502ff4194653a5e7f5f185e3167e9eaa85fa945c147304a93abfa063a3d8d6f6be409adcdc2a4ce7a0ffa405af8a31932b3ab83981de1af91c050126b4dfb446f7b4071b86ad9927a7d5f60b724d1b69fb7127281c3089aad1197c84f58156c61911ccc9c2231bcc5808a5b1c70cb86424a24ee403db3a20b7dc07f88ae22d7b8e786c488c8a59093ac4a7a26f0be47d89926e5a9ac4f8086a71eaaab93241e0ab7733c7fc9cdf9b18f61322dabb525b177a41ac26c0ca1172f44c227752c7b5ef2052a265455fc63d395e9da16492694f61d9e043b41dd29fbe0fdb86b7bbda94d571bf3d2fd70a474ff041be5e9a1677134f1075bab246f85d19b031d2840eb6061d430dc967244c89fb237d59a50376a87ef30f87f8413e521b1410380c576589da0809262e422a34d2397904ee6b1603b183a672ab13f84b5055ec9e4f9ac8f22eabe674c415c70ee090f08684f07ed0bac41b339cadef84ac400ae2182aabbe101862e0a7d3530a8236be956ba2609bd8c70f80fcc302cb181c8845c939f46e4fdafe67ab0ecb4cf9e37c50bb542fd678db06b687e14b0542e286169a694529c6b07114ff72b5e9be3b378d4f2970bf5353367ebf6b92bfaae9517ea1f525c5fe3281cc9292fec3b2eb8d3bf3e91f4e9036488e0fbf20b595d3e80ffef2427b76c02a2ae6b75f01c84a5096b777689e98827869943189fde7bc4c3c1fa64c07b3bfe851c78b1e8d74e4851edbbb2571cdf9589255bd01a55419cc1804850294fb935a19777d59b09193c6cda878e4eed4d455116f8d61d57463217bde70f51fd915ad6a8779f9c80007d5beb538db274553fcc0db754ea794419382929202a4126fe9465976e3a17eb43384c084706993b1f9e046897dc245128ea61c0e2eec85e468ac52300a0a254a5e91bf900426b3698f89d40251efddcf6ec0cfc5f7aae97a61387fe20a47f428c3ca77f4fba0075e2f727ee5389074de6e6328c6e332b52c93fdfa5b5f8e1166d61648794d90b3b7110f12bc04aa4d336fae3e8d0462e5908a5fe465a81b106fada1f01b23b2b06cf61fd8e2c92ee2cd9cfd6c5c32acc65725365c5b28922810f23c0a1b7bbcc8e8d29911d29992a942a310e121cb99b5646af850493d3afd031afc084e63165e3cdd7d87273048e0bcbda18fdd4d07ce4632cabcd84d2ae3e44d235f1ef41b6230f8006edfaef0dc0eb05dda97db26b7c39a571e406522472a5d323d5b338416ce6d4c920da7a53f9b53ff5b1375640016e927e3837d5c660441b33bea03ed260d5c72b16e6a36d4a6c22f643a01bee3c7ff59616a2fc5065e2c34681d003f19f59da155e4cf38958b801cc3fe48779870d8d0db81483d0d0cc024167900149d29be815560ea27f61ad2d7154fc400e3a479853a0d0dfca18b2e2dc3f27ff0592864f72834743290358502d83475cbeeca122ca859995a8366377b520cbc8d04b8e42350f060822867a8dc51057cdf5afc9e6407c5afd50ba1f277deb8cc071a76d9095d822a76fadb672cd04585b45ee100d7a83bc09cd05a904bac7c67d34892c0ddedda3656d1e78bf922909e889a69e16ba994a1ff5173f9914c6b8d5e0c32d099a45c0c9166494ae2b717240c9b2fff8663b0be0c22c18ea3414b4716f425e190ec4faf1b8ee62969bfe53aeef09303d49f99e0514adc8a84cfee8473a89873613f45c90440ff10bb4b35dbf38ee14e01bb4d5dfa8a08b5b1e9196fa8b614b6a54b44c5d50a6513b7fcb491b402efcc33ff591c3c51fba671bef5f605e0b4e0d32a6ff9b80f7fe6c87019c68638405b79e027876d5ad791e93746c60e4b5bdca92c4ec4d2f129b9697e85bf33adbe753164f19def592cadd2ea27df6b627b1ff410185209ad435e49962ee108dcb1bdf548720810d818826abbbc66f01289c0390a142c89bee38d69827027d6c51a62566ed80ef1327be9302ee65c73f3675f1c81ba561a677e7c5a4c6f08d9172019b9b92f1fc68aaf7c5fe9fdfc62aace8b37ec6fa659dcfedc3b5c376c497d75d705c71707d6e6862c57a913e8656d68a9df81066947d871e361bf8de59b1785b502d3fd3f39612cdc4d75ea0b0b74d19647adb7d8e05583840260af2174583bcf8e942290070766ec13dec9585a52c24c0c6d63b5096c7fe278c31a18a5806a23f921181653c1d348a7d3ee9e7e1a382965d9242e99b698bf0f2c3ee9625cbef21c050ea175b3581a4ceb98bb5ba1e144ddc7727c939e1f343375cc1056c5de8732c8dcb7692978511d914d98e80b21a4c30ce8bf8f50cb75ad99e6d89d8ac8acaa58476a10c56f1675b7d0e31c71fb13814a6c232fde26a5131ddeceafb8aac3710c850f009f818be248417f4053c111254f00dbe116a448fb33ec266a23c216e7775013a2430742a750f0b2ba41074bb5b856e9c1c3b0aa19ca1fda84049bc087855948164ed060dede3e5035752eb52bb28f6876bfa14a660829293c930992bd85bdfafe738328d130972ea601869a5706a7bd398c9b63bcd990deac369fe9f45b01a7dfb9c50e46b4ea677674f9a01f7e9d9f569a0048c6f52a1e643330c8758a62d726b4f17336114700838c2a47924a65441a3e418c98ed6cd24df211f66813a832335be868fbafc2209eb1e2fa87551f9d9b20122d57b7f60e52365367bec354bfad8de10d34162ed7edbe96a0f1efb681d3083d88f3eafa06575a83d9b96e78a30088a575749c6be7e302aa74ec00cead2a1419829d18fed216cb3c72d961ce24645cfd47069bef95bf9f7170367c51c6105229ce95d421f3f820ba042b92979dd2c43c691bb0b6cccc929f96a551cb29b48ea1dd72e2d35fc8e4c2b1144febfd4c52825b985d911e2ad2d62e2782f522f8277726f441997b8b0ddb0b1b0c002d954473fa450be84d47a584f313fa852731a17d75d851c7c20280b66dacf19a9ddc7105ba40d2a4a8bfeb766e28eb1289742ecc47cd7c5d7959c82d3b69a263b69794cbf34a9271aa9a7b7b5edc8aba2786b94c0842cfc551e8fd866689b32b1dbd9277da75db384a756848e1b41b2c48a497b6e59db2d11e12294c6dc1b8b54b29a761d76f7b0e2e3780938359ea59deb8327c97520721eb20e3f488c9d1d9f01128911285f19e476f1d67d3ca319d846cca7c0615d29c34200f0079480d460859a99edf13868d1c085549c12252ac2a76cf47c6117f2ef453f2b918e036c886086f400af19f9855c47105ee0a90d2e758f5bba649529247abd08be4c20d7ad20631bfc8b4d45a16d61335d9e9966199c4276976c38d49728a1e94fa46b287f229220288f15320425ddf072a4f37bbbed8781d973bff9ba3def312659934cb161ebe9265a253aa784587c57be1ea0261bc02ace26cc700b8644cbfc54d468946dd2a3cbff8d474700619f60ee7a5df734bf3e4a7db819701534ae1a79a84b34f4b7edd8518f1b9969978533c3fa31228d9d108707036c8b96780bae62767c99d02bcf412f31ed253adb6deeb4285576317ba887c9121a46da5e49bd1b3c3405746d0ca8c2294842f6f54e739ae0c498c98f55743dfdfdb06f57d8f547b3383b2a371e56569736e1e928e1e08eb1aa1601806a495ea141f22c7eaab80fd7f13fbd10ca74620a2f1f268757136c4e9a161801c9ead8346cc883b8a3d7207019b483c769c7bf31d3ad178e84b992d96fdf3f620c78af969316cf7ff73e867de92a2cb16419ab723ffb6864b8cf0d59fe3b5d26b547776bff0364d75aabd77daa05c6afddaf277c7c7b1957dcb1f6dbeba5a966ea7ad6dc7404b360175bf4073f001c501ee23919620d09bdd4e25ec90338f4904f7db870b90a1ce353c0d10611d2aa74ac03ae739eb7e2e7bdd8dc7313ab6b1662884fbf367da96301e9d13b47d3f691c11a236a1287c710aeb167ea0ebc0d375d663ff84b938593f2077819bf50e4afe814d7c26951c6fccc76e963cd2045ef2fea136c05ad5777f78fbe93646df2ceb107e5e38b03b8ce4024e3b5d353faa59d314296cdabb4ba4f7a70c3549065c0f5ebdfab1804d3fd5adf715eb0c721a488f1a7a5ecc615d003103e933da19684f31aac759613e7b61dfcb26db3ac8986f1cec97dc8908f009da7fad76a5535e4e26e5fdd1c1ff7b801b11d2346bf7b2e98e6bcfc4209110194f30ac0dc89a2abc5aa254e0ca92dbcdeefd1c814c122e350b11e520ec0cfcaa4cc1f2a6a43d9c77a652c0d384d33cf64b0624138255a619eb7f89633156544d7a24dc743337e869c81cf39709e2c89ed51c6841ecf61b999344fd6c00eccf243c0286e86e6ba41334824e27c47f07273e8c92ecfe3bb64bbe426b764eff843d659c4338f5c8597114baa327e8996fd909dfdbfb08437ee983bdc64e112bb517b635504088a09c040821175095aca5716cc9c6bcc9e444e2fda47ea9a0a3c2e084752fb4a362cd6d4fede76154fa6fd99fa20d865f7e37eee763b9ebaa6bf0c9bdb511099b1b6147a0f74ce1f5b30a02e6f9ce7b16b5abd86c0f7e00c976cd4d1d79a841fcd168e8b15649fd991e1b3c633694babbe7331e7ddc06fb2f5805c5cdad9d694bed82660b29cb9465c5a481685ca760b2ed74c6f7dfd1a0eb805467969e41dcd7b2aba629d8582ae5ce45322db756a47983007147bc3043d893a815065b6cf79bb1cbb559d44efd403adb2b2ff704dfff18dfc900057ebcb3aa4626c9187fcf76a880d5668c505cec95768b87f18cf7210dbff6e6ac576d8701b31655b27d4e88d854de170940b18f07b6512c929d74e5cf4d4a75f3f63a6fdaa6c7a321ccb607736f0770d04c1e688958dc4c4823b5fb4ded92ccee9385219d778cbba41fd5c402b412950339fe9dba8b3e6bfad431c7a848feffff13bf1ff7f51569dd7e03db6b24e01f4415c0800433bef7dc2ca7adf05bdc0530c53a288f6567602cdb0773386e7a7ec244f48a6b7c68a6ba65ecb550249222a779a515821b9f9b9e01f224b36859c54234e128c4ac0783e4a8e4bc395b2d76d677943bf603cab4e26d897c33b96aa4eb7b49151960fdfa7cf91a77de1ed91ce119d7b76d120e2e5677f9f309d9776b2b5063bdbe96068369f1bb3eace64eedfb8a40a0eede8c4750335c9ad4894a6999e5421802c8a9f76095549b2a322d6927e05e716d5cb111380aefdf60b21fa1cb2f6e22a9c9747d25608f8edb321cd79b7917a13be188f8214641ce6b134aeae158c8bfc10023e83ed4d33eb3ced39decfc7e04ef6653212ee1d4dd8b7683d467ffd70f2e776ebe13dae410d78145ebc38436fa9617515690d9802b396659f508c09f8b97c9cb3d7f36823b239aaf67147e41416e57e472649a606b7a7d6630a0d4b0bd178227e8b06bf11e30f87cbc128bdf32c04045c6ee7fe1672c80c3c38fea7410d0244f680e65df44b03b8cf79a008e22fdf0395f3e9c243b01cf73dd4ba2ef1364f8491ce571da936e1705be420a2f7e5ca026b5231b90d91b3129a58e81c7a0d15b964df061a19e0a2ad946e7c260c1b4063f06409023644581ffbf2944bf23697992b28abb0b1c3d2687c2bdcc9d2171a979a96a66da4764f7d6c3a0070b9fe0b547088826458297f821bc4c7e0054b8b8a8e4e1d78abf910451829dd5de3628f45e8626e8754cdc8f20cdc73875fa146495cab1f3d382030cff2e6d3b20ec4770d03f8bac23b78aeb7430efe5d7ea616a13a70112e147e810e1a6a11ea1b8e1bbf924fc4834627dd03e2db27549b320782b740f972f3db69b48f42ffdebaf929cfe82b3c26dad238cb7ef1d0cfcb2b97a012791b816ef8047bc32c5044120259a02d12b97bd7fc2b97f024ce3270457ae488c42a6adbfe264eb070c51916ea3e94ae996bc88a71f30a359ecf2ae8d5c2899f87239edcdebcf0b2bf993df4fd4ca2bcbcedf8fb280a42cef1dd0a9df5274b97a626bc3dd5cc1c7722c510ff0f76ddf16e52b658aa96af24e1792ca5596d2f5c7c5d05b1a22d21dc9a88d7c90379e0604404eb481572c6d61f1d7e44a7a9cdbf251959033e5969296cab1b5e3bfbe90a0bec5ba05a61e368b1c23242c2f399504eb2ee2078ea831bdc2992ed944d3dd02e90efe088731a088309a561805ba5c5be044d570ec99b87d8cf65e5f3ad3a1ee43798ca211a87b3a5c2e0cefc23260669211d3dd9d3953a4bcda38c312eb10beda9a3ae6f3cd437dfffaff8624797b2d3c994d2f6d661ee439166df9af10c7436dd64990898604a58892a41ec2959286a626a81c97cecc56d32fc7dc1de87faa106fc9f69057e4e1043ad9ecdc4953687bd423c6062b9648abeb4df510ae82a81926d7587d701fc995cbf7054850810f6eb86e3da1bd79b9baec57a7f76ba729498ed834607fcd565818953cfd5c886c3b0834a5421aeefa1c897eba73047a7d5b357656b02c13dffe0b257327cd2a0a7b0e2ecbbdae7f3eeb5e7a0eb3d61225fe2612237592963103f8c2f91b6305dceafdf90d8f53db57aea0d0b0f810d412f5a4cbe28b098a7f9145ae80ac67a316a441489d5698e99d8a5f07bfdecc66bd76c65b538fd69c3f5e86318b15870a3c20cb73804e00520e3705cfdd3831ba013e99d735eb5995fd697f234046e4ed3b21f83f7e0a3109491fa8f048eaf9b6859cf4a1feb4e8b3c1b1ea59b53024a97e681afd513a298acca2bfb9a57e4dbd8dec8ad1191bf1a4809042287c8d7f8f41819576dc071c2072221f763fafb8a5427e1e2e0ce9d6987e4ec709a9f8fe9c8b62348dbd6238e6388c17b7bc4d9640ec44993bcebe13a4a0111e00b3cf96608d3a0a9ee11ef53c1996422acdfba4dd1676ef2691baa1c9ad00bf22075abb0aafa0dc218aec320293a6a841752f814d0e65e3ea9a1b1a8353224a7dedd32dfdd178e21da6e239b995fd7841289a93a0baaad63d8bc680dc350a02e907dd9a1e4701bfb563332bf6144825720609eb5ce0fb6d0ad0e60813cd36627c8332f29e442142d9e7d210fe204eb9365f4cda3a5a29857c5744b7be86ff4b9bca5d273734d3364beed57fc45dfa1112893f54d8e222e47cbac952f41bb4f31a88dee5074366db72371f823b7c21c7f71af9454234e0c2f1b1f4e536edb814178006c9fe803f661c4b55fa01b34fad64347661cf55642775e014d1c85336b634b2873c64c83ad80c58d2d9449a45071371795f899bf0c776b7d526064939ce0921028a146c10abbbb82bf4fe92b5c6056ca7b4e604081fecc2b932c25d89984dabbe65375a7abaad51ba7a4f935f806a34c4bf41ff41d2af9f1e804c2be5dd8e07ec6cd13cf2e4fb87841fab084c15af0a176f0761b0f0c22a3f9bfde044ca93e4e8ab1b2b8928b3bd86014ca2c44733f88a387da1151e3c514147f1c0308e8d0951203f6e9744a7fe2769824705b9b97639ee15e78719354006b334f0e4b94dbefa5e9eec82d42c605570b8edc7e51a581ddc15264193d70ba03412e17206c82ef2718115ffdf9f02685b04007c831e347422d2d8aff3847a80a488c7f3441e2a8ea459ce9310c02a3e86604c397a91940f9207e0292c02a1ed0c85195f204f5978624813edfcf5667ae968d9584f328c0bc5b59ca87d3c31d467ac72b05f59a8135ca33481895547301d61ecae2627922acdb45f0af4928c7076f4616536a65f3a5b0e9cb90993d13042a2e804116e8d0884f78ee594ef2e8a3a4a8d9e67b2e466e64c888996cc76c7c775760c40c2e5e21a4fed5c65440d69f210f9f851ae8dfb30d4ac056dc5da9ee6f64c2d13a9efa3149739a0689d59ac896c470fe6526a329a31bddcd23ba021f0bb7a5f767ff4cff284e808c07e36fca124141952ce16159470145694b80e0c68ae3b53803c34ecefecc62a6167c8f108994ec3d7d1b7c11b865b3cefb26966583fa180d6666c9045bb7f61ff978afdce977c3b145ec08d0b3f8d99430d1d1efac7a5c5a4dbde4516331f3999059693299d9a6b649adccf3bb7ac55e2c3d936b5860f71d493fbefd8a078c491e1c3b8712e3a31bf850c62eeff7f85283e5205189832a5bbb66ef7a428e92b67a2ac8ca4681e23d647f16f61b7115aaa6e06feb86e26b500f098923ebc6205562d8bd14821e67103a8cb191e7be04d6e29559affac86898d4318786c86574f015aca4ecd20991b5bce04cdf7386609f80de58a11412837bc9715931cdb6b47c3f41e99c129e0215ff5adcef0a79bbc5ba619ac2fb470c0160920110c4f019728c705893b673f663b0a9c073e3279fa8a8ef5e6a27b57f3301051b7e69a2a0e38dc8d48624d2550096df920b51ebdf3a187078af4ad5b261c585add66eea5c99f0df64cac9812f44704ab21addb1d21d9f660de6b75030717b73c0f527106e50b2bf7f3dfabdc041e7452dd74077ab23425f7e6cf84f24d8915e7d1d62c887ecc540158bd7570d234639f8bb22cedc5076d0fd156cbfaeb673d93b52142b120d1561b373f3a9f9a19a3728a656db74affb8046e392792c5139634840bf608004236eafecd35073bd248f50c90186cfc5178235414d899f4f9511667eead1a784cff69aa300ca28e057f0556d3157c2f7af581b83479d8a5b7cc5de3f42337de40a3ebf176d90d57b128f3ec907856f38279fbae3cdf410ea667f8bf7fdc6107ad4d7ccba5da4109ce268aa6e004c841fa5fea593c200379d114b575babc2b7d3a368e4353fa9a2a1bb07b410af5f27d021fa00ef62149a508cf1fb2bff088a2b006050d5fe8d5a1b871f4091262cb76859bfdb7536172f7e782e0b18ab5591048f9013254fef6221879f40e81842e80ab6195904e6ae0f624e810bd50fc987ec2166d92591c5ce2dc803d9b201133afb73dd8086d10e469bb1832ee526761949746bc3d2fee483baefaae3c854a5f741f5f3835f8ae3852926b1c7284c9372f5ea08ff9ed44fabcb540b7c505239149c7de9d4efa2d950cdc906fc62e01724608788a4f1ee0ad766e0650402819b2ac9cd4600042ea6190ce09232fab0cf2b5b472010f72e406492e6340d7953bf8dfb85a087b5d9e183c11d1165699d81f0569c771921ec360610feb02d13dbc430974f73d5bd05e28f848ae4f1f8f900d9c7e309c1156dfbd6d91ac6d20b320a7c22071cb2983739d22b3998270142b0dd6379d0be205411dd62c287b75d6ca665a8d4fa7ed864d1e59d2d27fa872d4d31b74a32550f08d8e264c5a5659dd241343eebad0f2a4cd494f35d25f78d984c505a0c17a373073d2d657f8f4c114148b9f07f43e3885e110890086c55ad1d6eaffe893ce7f52fa71277894fbd3ac3fe30ceaf223850b0fad1ab1b67301e69d83c128f944ff78020afec5ff9c138140dc72938aecb2f04e94c9084d8674ded7e430d22e6fbaf95e52ebfd4fa5dbeeb77454bdcf4ef42ca57585f7241496952ee52a955ea7b5176e91df70c646ada9bea3ebb0b6981db1fc2516384c1b295bca195cae6830093d574df11035947c4f79e277a05ef83572e75535941e7d72537bea7aee97c25ffa1d9f93a8b4f420280e59e3dbd2ff720a4055daed4fae560c8c4f73c51c07689b97881b19fbb18000a38e64d71e49bbd6fba302ffb8ab73f2c165c9a81a88a1db57dc992c0cb92244e4a71b48be0731a4276ffacdb0d67acbee9ea01983df03026a4bf283bdd84960984ed416b35e1e66b9f60476b005536f9e88c1cd9d86d69b457dc5f181c3e68eb4c4c2579ca3db7bf834b6d53dd1f4e1abff4f3364aa158ee07c387f5086df3c23bf4711ae8943480e5f477afe7aff3a1d9fe4ea6f3f7c832ef5270fbca5ece6c4758c9b76d67f3b03e229bebaf7ce12ac888372aad6b35e4b84f77ef4e980ebee7f8decf03e723a017cab7e5fa66fc394709f1e492ed229c8d4ede0865f0d314e807f8bfefb80f42bde2a474ab3fe89a34eba9e80a2a5a8844c6e760946ac6332546e29e24ce53f271b1f174e511d4bc9009addd630c909a11441b2579af264164b62a9dac79f7630e427bdc264c0610cf92bad2f0486f4cbabe976fd5c988fa47c6549464fa6529d173165999b7b8e8bea4d2b8119cdbcf10529c16b8e2639787851d0246bdacbde49bf3da4f39ad1680c3c739b5327409ee55fad0352c79f169cec01508746acf06b9ffb91030a13a21d55dda289091a2768cfae60eeac230d46bb6ec25cc59446057d8d5ee572f02974251503ff9579046dec07ce308ce0887382a7b30fa2512b32e9a872dd18fb12042b0911b5992f75d287391aa22b4b32ab8fdcca8e82f8977b36e5a8a3d96112f6a110f84b9b6cd7cd8a3836740b75e4797bf4f2ce1639da4007b1297a028e38bbc28f1bc8f8dadcb6d7d360ddb2723fb5c2563cf34f0a18f88924783e1e3ad7e1df2b60c2dfaa19050b97eba7f0c2039bb082ac15d070d97b18225c3318b0d53eccfaeeb040512a72f2214cd79c8879a9b9dfabc3168d76c18ed3921505d1024e1fb88af658a8d302dea74e0ebd58073e1d282956b694a07f1cb4c724691872e2b880214456819bccf0ab0e3e566b7d63e77261c09002ea3446038926aa3c51db17719efcd98e3e36990f96002876f457924797b53fab7de3a81735cb5dae0462523f7f014566904b9162021031c1867d188b9fd0c7d3bdbb80b1720579a5169d85f3e1db91d6916ef5cb72a9f932e7a6f15c2457c538ce9414502b3d124a07bf50ba7a17ccd2a50eb7f44befc8bdf7a9d91d1de0ef0a2f0eee354566684c7bbe52f50203f2bdc2a8ad3a95da4d9b3845489718dbe0dffebbe7ee7fe10bbe96b79d6d27c871b9829e36f924164a2f41d5c3db264c92a341da6278472360e1a1a40f5fa7d84e66160630a144d00087e4879158511e7c88e6eb53fcfcffded8b314fd35fb7e04e657eb44a78faf5ec877bcde30d9675658c7b4a00d98f019e4e23144b2a97c6285ae496ae86da73cc6f8b3667d0c380f61b088cd9527ce62228e3486697bc9041eda3ee8bc61a8af7aecab83045dd78a1dfbbfd7ad9031de06288f667bea16be08198276b768b06ff916fcf076fa350c0d9c5c6e6901d1e0d2a7983963dff317a2ed960b3c5dcd6f2f7e3236d8d9a1795058bc9e787d5c68839326dc5a62d0955c7b7b37036263cf93990ac6247f09b8ffd79d26af882f8f0a236b673b4fbc78fe0ef417fae08f9a15a265f0cf888581ad92bca97ef0fc5ebbdf4350be00759c4a9ac103d7fbf12e3876c0ad8e2eca5d766499f1210e37f0220bbceac20bcabec51303a481c2aba13f43ade0fd8078499dd542c5d1b957f0a9e00a4e042c7fe2dd7fc01b719d87c5688ef39ce6ff23b333c72577dd96826a9e01c65b67a4d65afb832192e6ab7d7f0df6a7f4c7b0d1b1a92999310e7991af4dfa62feedd34fc8d3eb1d7668437944978dfc9a88c786e20e4978c216a29d1d40d13709a4e5ccaa83e226845cc977fc5395347e6f497596e219cf04451caa85e67bee3781cb8cb0171b25c7909f486a5ff851419256d40caf6f072a3c1e0d36bd05290768ff3ad74dfce5bd97d0b291091857b79b1d9866879b72a2173272c97e8bfd3ca5db6a3008e7b3b59a417899767e07d70458e2555a3b8f9979443a7644cc33cc3fe2d764a86719ec9e7a70898fc0ca6cfc658f9bf4cc39f851cc1cf2be12c42d387ea2d93be1151aca770dea2c37fd10cb1aea42f672e06f17afaf71b08b5f27be1036ba8a57ffa0023aef380199484615f901a45e63e2631f88bccc9cb753a821f0360cf25d82b57dec352181913e0bb810428ce72fdf78339c241527d2ca7cfe5f947311c4785c506920afd519c0f21827e83bf280bc5eb253a017ef039ffb553a6584b4bdb09f0f1166d450167c8e8d0206bccf0aba802a5c2479c4728bd64bfa4ace0b4d30eda226bf8eec4bc317add78a09ad992793c0b5f2effd0716733af309b569bae53528a5a9ab9da4f3d68fb15c2197a1583e1dc8915c80bbb999f4bdc2e9e2836c5b7729105dc990c683000d836e59da4654a32c0794353b1234b075d1b18815fe058da2506c03ab332dac50d41f4d5bbd3b2359caa084f8486d4846388be0e5269dc9a5a8fddb2d620fccafd09725545a3f3f2d280c6b1c4a8ed33c5f89ffa9dc657e5196f023f4fea848db7fa7149a192b77bd6382bb247151446e6f68474aec1d9724a96483f3961027853977b631ee97f796001cf7213aba7af5279101885ec96e9ab03de798498ed9129ded978ccf3b7332e7e6d744891f0c541728b8af974b98ac4cc3e1b367e827cad1386747b1130ad2aa6da9046be89adeb024d938d9efc64f8b51e8108f803f6f3ea8c4421d8e992eb5770e095cdef8833ef3276393bfcaea54a67cad8a674b768e56839b4c5fd094482059cbfe295ad1dd29d7e1a22afbc4b55501bb18239b69de3b04679d0edc54b0e3f1e33b6831a26f3cab281a120e78328f655bd1bbda28658cd44aebc43174557025c13a1b6c95a1b242dfd87566441ed412ed992500f774b67c9d92f02533fb3f99b28bbd8ac9fca2fa1ce832e5ef04f95e9a5751aa160ce534d60e39dca297f90ad06e14644e03c8759836e6cf14ee87a74e1d2643950dac792f3e5d901a78caa0b64541275a2591430aef6f14af3c575c0ad4c45af8e1bbeaed497d99dd1faef16a2a15cc9dee0922ec3ab28aaedbd6554ebb8e446da5de8a2f6e9614b6adc4f0a676302c5decfb2f754828ede71df6fc75837cb89d2846d1edc9ad5575e440aa15eff7651629aacb474ea04675a1df7b839eefe12665f07c3a892199998599606a2543d3c884c40484ffac3f9de32e1de39a5c7e17642a14da3717b9ce1c2ccfad2590117cbd4d17832f57f9f99692dbe8f188bbd70002f2adc7d82188132b4327718c14251a6d9accd48d5c751681dfa53a052e9c5a2d24860e61077f32c80b2cbb1ad9fed536cf98960a131be3ce263d66363646a9c8a7134320883f733040bcb0fc673e8934e89ab365ddbdc9524e9a3cde7ea0d12b4f259faff458b74225daaf47bb773809057f4c582cf51472c51b4e4b2ada6fb9c79b0b3b366e093c9c70be71bbda7fe658d0d37fdad56310de5a145be9631e8b9215362e7594033feacfd1411827bf9c05949f58a48a301a412eb7a2f14c0997e7e26afb450ffc0a945fbcd9ff087955f1446bb156bb4ec51901e5b7f644945be0732ef1dbb9070cfe3cf1e4ff03356c32ac403ce78c9bc26ee8ae23bf45cd5df01841340fd35c79b748ed45e4ca5b2090e8758efd3cee149e55c7a2695949b92d8bb3cd790ef41269b1d5924e9cddbdf0066b2fb7a37c61cbe38e93111b65abef4fae4ee2cf64462c0ddfe7bfedafef1526e9b542b449d17fe71ca620cde5a31efb306c537ba3193af2cb269eb1636c2ef5219a7628be34df637ee9382d8ffb7db2ac9a1b1021c68454b4a679ca646174a3b93a4bd2c1aaf2d9b8a189a2885a63b09a51f56516851a586965a2883ea848ccbb3cd740322f33ad98effb636b8427afb23e5d49595e5c10ebc55727f3e138481fc0990634361dc690cd1869caadecfb4a584bebf1717475572f48e6199859c87d07d395f0e2bdbd75f5b8f893b70e3e5569d1326acc17f519b39a65c0ede17a759786de1d58df1f60ee0c414724e250fef7bba89285c18a2f1ece0735dbaf68ead409f2855940ffd1c7c1536340157aa35c58ba09e5421149d074239e6738a54f1032c2436b49e6c127e615cbd8ec599600e620c53c8f1344307523fabf8f998e867b121c5fe79285a8300685f463eb46f83d64403717d43570fc86327cd4fa8aaf02f858f6d50775844520f916cf46e07ee79e2a752ce92a52c1ef9ff45bbe3b5db8c4a033750aafcd71027be07792da8ee231121ebc001be35e16e435e64e7c921d9ef242865cefb3ca0bb95fc2031b551ef7239f0cf4ff91007c065670a420e6c778e4dae772dc53f17bd709f286242cd70f7607c256def27db79aa374c828e46d30b586b79c6f78af3decc00e261cb42b11088781069cfb8108d3467859fe7dbbdce608edbadcd0e8f6b5f62bf92a553581616c1b78d7ed1f97e67c9e5f6c80f93d669af6921fee5a90235c93fa250f4ffc63d70b2d21277966fc27c21880993fec155decc56dae0017c4a93156a5a7221809d07ece712ca869ec1bfe437b2e6716ad4d164b385da72b5b0cd4b08323aa56dda39f61a642cf95ac0e27488774f5cf6778c2d722fd46d554d30915c195940f38a7197bb26f7d145ea96f6508ac63cd339d4e3607df12c0aa8ae2e057fdf586c97adf19b6414a6a1a17a53275a8832e6da9336374a0c8ed37058c0c51f9e1be5ff8878aeaa693879093210f828cc3ef1b38f9003a9c32296b4867fc2a5f8e13dfa5018568cac3c9c85ec1ac5b3bc9252198992f6ad5be25e51ae55034d8a138e6debf8c00667ff4b82d6b9c5649df7d7d4361121d416c684296405aa462a0abefc3d0c9151cc6de70db615eadb28eac1ebff98b3f46a229222ab938a55d0e51875f01df4e40f25d38aae1c5e3f926774e2ee9fe26a5c1ed8411cfe767b5f471867e07cbf571c29c3adcb8f9079897014b006fbb26b5806bb4e0065ca9b66130742e97765bdf469be8297e6ea8aa0c3e907e3f166936ac9c029ebd17279de88111df9caef18afd53b7fc2562bdd9cae7f6c47e78716c98b5d59e09fd716f3b8dbefafe25995f1823e988ce80ec42b043b98bc44b2a1e8f4c28dc867b324e61aea8dd91894bda1159032d97b96d63a47eeedd960c0a4ff07f5abb128e8d06ab418800b02a5617d26765fa1e74c66c314894dc66318d14109685eaf13efdedacd37278d096adf42bafdb2240194fcdfd3848103cc884795e8128c9219cd22a15fa3d52efce4a9587b99dd36e7b76309d21e2e5619ff7e877b54577c45b850e7c205a20d761db4146231d168b55e35e8ca9de78b0912cfac4c765f91aee82b1d66bb1b0d272971bbdea6d29bbcc3c65c5a18be4edc9f9285755acc2210c4c5aaff14a09267a7c710c96755625c6e01443a0f54d418ba2027c6fb03b08ffcdf50c32de680deefebd5a61346d9ac34466ef813e0c05789dadb5d002be6813aba15fe82b299c904e786438f797a810f99ebc1510c3a73684dbd3b8d805649e50f0850b00b54404fe8c8938f40cf29a6a1ed087465a9a9ea1a9b4f7359af348032a018275ef3697eb71d312446d60a0c4ad2402f90987befe6da6627da5e0ffb46d4ccae891b0b93dddfe21b94ccbe83ffd5757d0608c17d394d260f8272eefa0d0310c444b371cfca031095f35593682ff0144a99064e1910c43ad39b7761dff189b59cd03b5909c4020dc5fbd8202f4811de6ac0dbbb4c1fcd57085ba535d52e59e0343f7dc1b6bdad9b62ca033f128b9f642a7c0cd7d58376173bae59e41a7d62a06dd492a2ea853fee9bab024a16fa419e9ba3dc8ab5a7fbff25f0f720540e329f9e9b1ea34e3a779ebbfe512e1b6ba52d9f75119a3071a99bfb3e4d451023efecafad0bbf923a2eadef8fdf9e871cccaba6d4ea2f077db6e0fb504b82823d5e77ccde57adf734a14d999a1f2cc3171de4444ccd4dffb1b1ad73ea8ff4f1c0723f7693867f7bf70a3ec699384c3d8d2694515f429fadf4989ef25552be8306aa03f5271acc454916e60064dd6c5a039e31d0d071e1fd00f56a9f9f5cf3d210e19957053f7d860dd482b0459affd1104413aa4d103c4b2d4f1cdf5e897fedf6fcf6e974168ae09d2e4f70249fb12e46795bd935910b2d51669aca395d4affeab88827f74862aed2bd89030f04c39902dff10f45639217ecca5bd8f43e792a2d8d7b959a3da74fa8b34e09f825dbf2a16bc010bc2037efa7906f3b62daba2f8e9fd642b690cf22e426f255deec64bc03058234e06d7722032762c57d06d4b5ff85fffd616d725e9c45b3808898e153cf1c60056a19098f5efaae19c293ca9afd7426d4e01b6c5ce411b413c28301926b3f3d75b563b6bf4bcb6b4b23ae3be63fc213d0551a5caf98e5b56c5972362b1a0bcb154110cee1cc77933da94fdea045caea5798cd27f331a625d1a61d5204783e0a0c5654f6197a16d45d0a87b204e45a13c510e5012dc77c3b6f2cbf0966181c81442a62ac17e86bb7b5c06bc9d15093546985fb7653e4f42e03f47561ecff5ddcb85787a97e234b5b3caf69537edda440813510e2a10d522c19b329012a30f316c10d06727c3ac9a17c019eab566f55fe94401cd91bb3783584e49464226f93e1b6fb0df2d92156ba6c3c27867437954825ef654c98dcab81a1eda0e630f2559a25f387a455cda32ab74f49a8d3147d09e60231b242f47122cf11143339e3ef5bc9dc4a3520a0183c0e008d1fb13208d65dd940847d0cfe9a7e415b1c570d966cbbe7504599efca5053989844aebc4794b60586aef375f10e5fa495bdca22e8c4739e8989377e5d16d51b8089e91eee311d0ba62bcce321641be2131bb4bece1ce898702aa0519df709014207d7655bbe0cb5001673abaf2c7cd0d4cee2babe3ecc6917cf46f8a75e919c1470c54a9fbb978e94e425eeb56d6d2a19d9084504485ee020a364082c8d4cefe8b9edfe8e6b01193f3d60b1d216ad2ab783de8efd1a0b2070e4648ace642667982e5755ece10612229fa4ca61fcbf5e82ca43b70a16899c60fd564affb07ec607eb092072e1dfc4ffa3029013eb5136c4da7d74f8f243aa5812384f41839f19ff47c1689e51a0f097f652883f82071518777ef178a6d0648f21ea87158015b6b7471c3b99a349e80031097fc01fa4b9f893dd5eca762e26a13b352668eca083b89cdf17d957a7f53758492245d7579b610a3a18cddc193a515def74b7f48cffeed62de176df810a16a9ff756f3ffe7b7206db974f1837da3290fde50742ae669ec2deeffc5fd22247bfb88eec19979fa1c9bd3c2f76d958ad3bfada0db698621f60fc63b2d2d9d06c6c110d3da45ed53e0cbe4837a3fc52d4244c320382f651fcb8858772ad3050ec4d8e9136f0a55b36aca07183192d2d6e77fc554fdcb04a1e8fb82342f2fe4caac1b088c7786d45196a21df2cad26d77d181c4cd4b88a51b389cc4aed220140b2228529216c12e2ef0d35941542dec6f040f3c91569c9541303b3755833e62968f37755ee40a37214a8c7329961b7d38384abc3b1e0172050e79c175599ff5e8670822c3f4dca33a718fb9333a6277a05cba7722b94b2ede1842d14c211b8fa41499d806eee6d87d4a315b2e2bd3f12f9a3a573c367eb8963af412b99ad730a3553508b7e48702af9bec978ed9f91cc7684ca8520867f49324a119fb09a09dc6d51a506a5de5402112b6267f51e19f9174d3ddff353e49c53e1404d7da2f9d39a41dec9487b0d816ca674652aa83a798e9614d6150e225011d5a78c85c8a1c74c4662cd3a0c1fadbe74a24547ddb2f3a9615af97ade8a9fb0a7c4f95b53b9e82d03ff0b36e9c83d1c1e12cd1b935ab6954cace796dadfa47ef8ea60d96cdd437fd3d487e5a9f93ecbe738cea6915ccb67107e5f9c5bd716ddb6e492ab43b4169256468970a6fe6d27691a636ec7bb43a846e796f3f2dbff726b0b25543a99a87e5d090adc94542c483cf4cc33f81e914e2a257d3dd92c22fff4f37b568d82a44734b2fada702bd8874bd603f35f7147a8daa7b05976dcdbd870dc1a8c63a367cedeceb5bbda1ea3413cb4e305b8a3d06ab37a4f633baabc12389122eb847e9afb82e756ed32873202588caf4a8934983f3bc4fc5d29671b8cf1f495fa45cd287e0f0714de2552c7ecc9cb2580006dfb2ce2f0b348e57e91f8065bb9d4e884acfcc56927ac113b89e338aa0dd0f1bea15b5c95abb067975e0c6606f229403d4c5fbc2f9a520491607024784dea8013ac2a603e64f8556a6d1e99bd8a03feab6a4d0338c71bc69114a866e668ae29ce1407492391324e019d41c98bcc5badee89bb1fcc11bd17c0d798444b21bbc4274b1cd5d465f1ee22eae097c46cba816a159c03d84536d6576c4854be2c03dfd87e12db222980904200969f11b4e4bb71e7e78808d5f45fbaf119e65b53a9b54bdd447b988f2f746042b36fe7dc87199df4bdb43dd5ed11af15eed7dfc6bef75bf86737cb91b88bdaf6e76edf2024dcad96dbafeb3ca4f9a48dde8d3367571af50f107cb6e5934e39a5e19bf1420449747496b274e99ef81c8ab391c39a32a1b8a6da7aa913b0b7e1de83bf7f46fd65dff5b03e4ba0e1ffbe168478d3f3b3b8ff73aba9130e18f0a5a0d04d1d9dc66cc94ad300b78d01ed1b980f0b809a70312c383d9e21c07965768793290d8267e0fdd9505ae09dbaa096069158f61226f17e1ee1b5cc3125df043663ad796eaf0af3802bf762b858dbc794ea5bce4d7216713d732efd91e9bd43ffbb5099cbe5f63a4cf09840c78a65bca7f62c1e66f53592eb5f7f8e67f478772ab04e51095c4232d06f03dfcc94241696e94cfe3ba35a165e42da06836afbccb10d209de6a75833746bfd1bb65e0af2a84210113dab7067d6124c501a22e59bb87803503c296534be04ab5d396be6cdcd9f3924026f89e755c56bcad1e9c2323ca83330c2538e11ccb1b789c87ea46dbabb484f740cab929e324b501e046f3b5973ec4730b52d85b17cb8b8b8af09c91ec750ca1a58bdc158627dbc68f4b759b415697c760e9afe4ff60dbb444120912556a0009da186e4b0b17d0997244186cfcaf723bbb9a38f68ce2a2408c5cbe1abaf18928ee6d42affbd7b2c826c65b404d9b0e3e5b39ea144c90996ab155aa16cef5842da38f7c138b96bb59065eb432a6c653479b35d123ac8cf57c3de395d5a0bd6e1b3f86875063b228a0d9160878fcb1c3de17ec098b1ce3e033a36705aff096304430d5ce24baccd29f867ced1b1a993528ceb8cb4c026e63d56aadfc547eba610d04407c88399fbae93fbd8c637afb98f1a88bc7ac16b30c6c9ed7c0b01fe9311fdbca8e99c746308513a1c0b9850721d8ec086e0e5abfae078e0412d7bc3caeab50519ed779d02256c3df5e29e70f00fd7e18a08e32c89657032ac14b47a85af5939511868d927e4acd602ff98c7c634d8ebf48a3d056af8ac76ecfc7370bb6667ecdb7c44be4297d7a3f27ebfa86723ddf0848484c70e8c486564a06128008bbed1f19721a8ff45226c64e1db8539ed6bbb1f0e329fc39a8db94c98430a6792544c3eca9ae4e140fd2c51da50be1588323299733d7bca79786573ee5c685c78d784ba8187bb06193b4797ca529bc83ad750aa034ace0987be8939218c5015355a1a842625fada4b94ad04db9b3968265135204614b3a3a52f1dccad83c357d826042e516866a564545ab483491e18bcbc0b913026d2dd56644c351e4821f28497a8e8f8d6c400db5da98e1236683e4894e912f9c6a16750b4a390879014df1c1255d0567841bba9b6fb541546641aa1142373d7533d0ab707e14f6e727c906f63ce125275b908a61689b0417c7a251e7ae9bf9c7f9c8c2b52e5d63ff911eb2e51f6a941f0fd8805afbd78ae518f0acc044fac976204798041c2f6d0e0a6b11d00fceb00aabccf3f2a83c6298a48b1b6d200091dc3ece959e8be2feb5d246ed425ec278bfa3dab9748772b0eb4713601cc06f0921ee836d879d2a6cde6c229927c6bfc3f222e368816c70f937e112dde11afea356a6d166a0149cd937f880e80f1361602b1b10b15cae9b5b301bf496f78c2c27a4861225d3798ab4b4928ba58cc6e34445a0fc631492135b910d90109295c0de614245a69b1dedfdc4d091853255b523aa9410b06ecc901d7c41c49a0ca9aa93dda4c276d2b60d467924084b19f46a7d5593a2b7c2f838881813907449dbb45a4e394206832e978fa863b51bfb6cec64da283eb1161af6eeacd9fc64ee95ab9a299c25ebb03c8b8252e7a164a4747181fd269647c998a58d586368521a41dd24bba3c3099e6fa8030c94efebf8088c47df1538589a716958cf3e689e0210dbe9305e7011388382416708a513ce4000397070cb13510817322522bd73fbd3b9dd5cb19858b23d34fdacbbec82060c1173021274a0d46e6ffe44536b9ab5095e4e07eed273a3123343aab694f4b95034ad71645d0b7e3c339e747c740bdc7c61d0e9a5c616e6fb7ea76d39a3ee061cf7773a1bfaaf3da1b79c76147187e4fcfbdc077fdc3a469c34190d0a3233a108164c2e95955d324003d24bd4a769a0d62a8718c7259b13570a20c61e9316261cf60a92831103330472af0aa6e6c02e051b5f15228444313d94dd3488d32074f0c2560f4e2852f5d6be880b148da5bc0c7c2d4e1812e060fde8640c90d20e90d2e6bbcddbec3ae642e6b647d7d76e1d0ca0c67f89dccb5f80930e5761979d449d008df08bbeb0ba2e358270371249c201c09343d61114000a513fc056f1c8aad938a016626e15940af897aaf58b32bcd359988c4c7c6d1d97655064d653b20767ecca7a11627a58f1baf4d5c45b68d36ed20cb824a1f554050fed946c9ccb00d1a7a8830acf5727111ce61df3f8ca72bc81f88b759ab5e4d00ce97000da8b9b75604129b99f50457b7387604ca59d7e43d9db700cdc68e4effb0f22f3d7aea052b31da79743f36f564731e1a7ea9af1c5eef0dbdfb60f44f8636fe8a6693d96629134f8f8183f944d12185a1370130a7e59b1281b2d4c3128563db5aefa9011d019fdaffafe5352cbc38a582af65bd3217e099e6cf812bd895bac1a2f04d989de9662e7eb1af98429645121ddf646f1716689c6b423255ae28ec455dd6fd96c5b520ca7dac119cceccaef3eb6c37a74f00edc6b849b12ff1b8ee99ca36e0c2d368c12e5f34f9bb04487cb612baf191f5fa92ab543ef29c7da5da313bed0f9fc8dc1a986451d19bff08dd804de0098c21b6cc59f23d5d55a1548c04354e16aa73388b885fdbcc0e07358fdd0d2fe840b8711ac112c1e0743782b7f58d44736c4ca63f7c88f93b2121e90d5b4f1cd44c4632f352a790e6413bb3e64aafe83a3000c41efc512e6f1e5018d441d251d82a4fafe6db52bd87e8944daa7e434b41f0e1e9881b240e71d92657c43df738bb1c5bfb094b9e67d8dcc99e37c0bc0d27506527975443aaa9b6d17c34c266d18841c36ce06048d6d5207d30dccf226ee755022c1103fc3324a0404a52dc462e58b90c0ec6b31703bc7abd79e0222e4e3c41752d6414f0363f51ad6153d15d38b14d83272773482ec5d0d8df32e6d83770a7e9b9d7943e7eb736d808288fda01687aee1d327514c9bcd8734b8c7808741727b37e9d133e41a4d05f2909af5f0368b6b934c6a4967781c04271f940dba986bc681265bcdaa68ab5b73158defd1b24426f62dfd7850763a06a380a3ec61616896bcdbbabda100a6bac9df795065043df3d3195a522cf38b61fdb8fbe14756e31033e22ae8f2e4b135629931093cbcc330605663742bcf6e1b2f6d4e4e3e609f13e0235208d17395ef98bde434294d92aa99b4c8adb7d585aabbbb3e7db31a2a1bbf5e90c2eb833a35ae51803d1dfb85c841233eb1f8a8c3efdb13bf6f98ea61890e3dc8f00fad295914cb8a7f71c8d940a7c2ebc55d18d93a20603986aa993764b142b09a9ecc18bac568c61c55859568c7c5ec8e846ff516c138ce7de4b2636a1935d0bb43c56e8c1bf2e317619f83264dd04c61d66b5c5cdb07d341bab2bf0950935e4bc2093904f3e11ba52ccf2d6f3f4f21137105a925f5ba37e1531a87dfe334c200b34b0407841d2d2f8019ed0e07b940497a70e3f9e23d4b93e6d56c6b1cd462d1827d5a7711b4aed346e802223627d82030780f68daaba9322ded142d4e2f421493e1cf41b848e3dd5955aa31f460d46a675f3b5d909e38cb9d3d0b745f258d13ef5ecb4612020b6d1d1889b6dfff2963befc5ffdd1ab991224d2dcdf3cb4adca1b3501e28a201e4eaa2323ec17d3519a21bb59f787172c9b84b38d1ca3b9741f8fa9705af0723ac7f359fd28fe83b3016e7b0220a587d60995817f7ca40f916a0ba5815be2362fabdeab91582d23993441ca019a74b86be67c7b96e2a0f63d1888a12906cf87304bfaaa3ffe5ae1c83354720d176ad2306fe8e7221fef3f0fda0f12414f7c2844040d40c972671b5208bb912619e478146ab6aecbcc848951e61065c8012f9cd490a5231baf10205b98a56ba871f77e914dc984fbed0e019ff178c889af8aa4ed3c3ee6f6e675e0a8e108fa573f3dfcaa92afdd8a56caaa1ebfdfe8159d27791a9f77e6625d1ae61e2721720a8672383eb5272440c09052e918f5f0dcbf0b601b3ccd7bf3d50b2d97593c1d77f8a68383ee229d8e7a025bb995603b1619529c9c5a5cacaaafa998931a4ed54ee5f34222c2f28e3dd51d433864a763c2c0294d63e40ee7b9416a21ad2e370e7f642f8775a585f70f57ba96091aad83d7c6ed2e57c8dec9645d429e04eb3f4e9f70916ac4f1ed1576bab2f89d9c6f4e528e93c6b66994ebe6292e806f53613ee6a6f03c3b8d83eb8192b00f437a8395746e4b04a946918fff565d103b80a18566a7a50075ae6b48847a9558deff029e63e3c10dc1e7fcb47f31e7f90942a1cb213dbdaace919edd1a5d26e32ef4c37d2d7df487a047963e53fb7cdabd13ee27a913da4617c9c1760b2e656d1fa94ea18cae5c84892ce10a25d958f45b6dd873a11fb4aacb05db79db9a35611233c9acbc70e0ed21ee4d68d7f34f89d944d6caea1837abdb86922ee383e1499408488b18e8894ad3dfc217a939cc8e2c5a9813578bacbff6ee844afe79c565ffd95db434675dd488bc191a8343cc1eeac3c5d0d797d28d56c5ba0399cbb54169fc72b5c930b13f05d212f3acffed6ece75c63b95fda134a1986695c2cb40722573dc9932c7ec73f8d278045e3222bb9e66308551729ce743b75bea21cb7e7ee112ce330f3c3309a859acb247fdb44935d27682a20f8375cfe888614f71d34904ddd4ad40d6d3a2b442a2eb21862ded3b30714993ef62466b3a48fdf8e6e348d10a2d6481db98cf3ceb40f561799e7e086caadeee46d0008af5e39140153b21df10a363536aed1b16bc3d088c71043577e5ef592e40b54c2a3708701c96f05c95853bdc4bff148930abbb5c4d15a7af976fd85d5d25edb1c96c26168597b36584aa33dcac3884b22880a69db98097a51d73dba38fcc054959083555a83ecdedfe5279a2f27ade66a1629dbf95f8af84568ec956af2ca228c60aa3b2e462c5ea2fb7e7402c717acc8776a6408ce928d9bff091d9d2daa33ef1bbf3801b1e2d3802296471d88a7ebb1f1868ff69fd33e66e419979769d0e260b906a8746c707490c3d38c565efb8d6d4484178e73fcce12818277c6e199da54e84adb84659628aa78f905d39ae1a75e75fd4d094da4d29b667803ba3bf4b8e7c45fb947eab96be019d1b64bd4233af4123f2bf8b98a7d3bbcc411745772acbe5b9da920b5558a96cfe8d6603820c8e22f1228ba7ddfd3da9840484ac2e158b42b88df341c9609bbe04934093b88eb86d0554dd7d134c85a54a061f706966ffd93446e96240c4e46e50f4b18807d68411a6344a1a29e92e5e8ae14cbfcae3a938f1a9de8c696bc87c284878b3bc5fe639cfae49d2de08f2d62823ef5b865b1ccf5f3bcc2a0895b92328543193b1c3db19daa8e19011185e9b0281b780c1aeab1c75031811aa8fb9629118237eb1b7c43a342b5540ae0581eb8e6a7ae4f1ae8f09e5b85669c230a604a4ac64170dcdc83470d30e4a8c2b7e9486d04d7cc61501511f7dbba93f6944cd8ce18c60208b72576c1c132ccb6de3aefd04c540edfc7cadc39ad85543fdd13ae90fd4caab43b8f4aa4be7645499fb1f6435eba0b1328a3bb1962deff6db3bd83edf2dc72960d7d34aa729ee8f4dbf09e805a8885bd01a88f594d6a74c040d7653fe89e9e7ceec576f3c75b5fb4567d9709628dc2c311dd954924645b0c0161103f1c7423287db436024593a1f6b7c6e8933338b8661a15eda944cda7740b613ede1550f38e52eccd5431993d7e57010d041ae205ebf2b674115ed128b764c1a11ecb4e7c149dc7b146f7849bf53ce4632885253db249b25f42eceaf00d29e748efaa4f89f5d54637c4ba9017caff9ff8072e88b1d0595bc1d69c0703f20449dd2a70b9fba1a79415cb1290ae642f08e40c83e22c7d7c23ed93e3e78437f2c541042b9dc7ac55405ee741d0421357b4e13104fbb087d2b80d45d509e11836de6a75212ee0fba4c618327aabe684b1d1d801ccaf7ef3518db9f56efc8f7b2eaec297ae6f0faf5237e2d14352efbd4b220e47fb34629d1ad5115ecad8a407b9e5f99abed760f2ffbbe5f764fedad604476e0bf9674869950e054fa470c6d671cd80148e843e10731005f4ff9f8eb8cdfed3049c99ee0ba9495a85c1d78a6c8632fd1d57bbbd1c4ab9e111ba67ed1f0e66feafdb4f4eef2f4604ecb6c06780d48a86b622a54efd1ddb2d792ac1c60354e414d706c2171fc4317d9300f6a327846461f7b39911e613a91d0ac91a99b047c446e70b183ebe4cf0553ca4545ded090582e7575ae12a225ff9662fa41377099db0d480596ebbe81b0e299e79c83fe8576177561eb4e981ef720a306608d215be04ae0831f58458e88f5aa7dd5e8acc963df09020a490b52b837a2796d7f4823e096ba52ca860cf184a43f8133bf62fcd5c8a57f76e42b173f35d24fd8e1b0c1e957f160a2e7e74a3550df3b645edd6deb06bf25aaabcd1515051f7f0a5f575ca337542b0126f3d51ff0f71c6ded4531ff9d758c78b065961c955b16ba440c59516159bdca560d991d272bccabf577a14a8fe4d7c410baa3d11ad4375582813ad49c3a78faec02bc85163fa74dc58cb3fcb4afaf06ffcab524d33cc8b536b33344f4830f5ca2e3602b1cd23d8c76b59ef5db0e1c50703f4d9fc0f975ec2680a3a658001eaa37402d208d1b82e4f3d1133d307fa82bc64891e837ba6e628c43a7ed846b392c4cd6a7b892316707043b83b39f8c14063c4b26829ca6796f8cdeafc1b8fa3a1e852e4c99825cecc3bf40ad34b4c184c59b1f37d90534bcd1d6ff17647a4d17293e296f44fe1e0a0ea734d249fed563ee39e977a0ab10180f1593134b271ed16b9208cb4dc448b2b419dbb640f7d4b7e07677a5a8810e5ec1d5347f3448eb5df7964936d20a53fe14864f70d7749a3472a3ec56724384f1f33049d545cef3404738038e105ec26fade2ae8e860995d2d47ce7221c832039607364cc2ca157e1594c282213986a8afcdb82cf6ba3a81c3e43968a3edab431d3eebd552808c56ae982f057bd81acaab1add8b7e6ee52790ce18ef20c48d8b2ba92ad5eab579a45c84a21d11f3a141efc9a42bc48194873a833cf145e004f5c5e621f32622f0352d27c9261daae1f59e2d1fef3d4f074b1c50ddebf2d2cbb060e41fe667953d006337615cdd1b42945bae26c8add66622588ac0e0510923dc06c97f05f50b30e052876d978c114ac49a803a43d81375378f82496bcb20f1c60edb190ec116a7d61aaba8c97e172f89b1ad737e04aeb6abc0ba3436756d35f9fdec100ded3233df442c36110e1a5fad0c4d47fcdb49bc16fa25350e9560e232bcf17da781f30dbb0b48500b38781856143df4930b09132a7fdef5d9623557b0df4bd17b995ce04fb3f48266eb2f3dd5e9308efa4b60f17d98afc5fc8c49fbaf908697a18ae4d4f40f8b956754328e0626103cbdfaef97fa0335e7bbd2d4b7d9840dbc1bad87337462bd7942f180682fe15cbb52fc51ac1df9c90b80b5ad0d4300ea3170c8812d788f0c36f928512419cee14d7b04129be08953d65b1a36bf0c731962852de13cf3393b9f2b544a1e6032940e524dc5946ce2e1d624c57f904c1696531aea1767ed2538ad1dd9268c86494fb9dcd89dbbf58efb5b20e14ec6c1ed432a291c55a117af7073ec3a1d229145feca1da2a97e89ff28c9a6cfe9245bd59a2870c44a886f7cf11045277099c5e889bcb6f7097412e3dff62e1614b96ea8650336ae2c2c4e8a44425e048668c2416d5c3e0b002232867e771ef0218dbb9b96028910729d7d0e637cf946894555d49212d64c0cb4513e355f69ed3fc07f1a81e1f6af8a46da570d5b7311a0f745317350841f9344bc7be6e4976dacdb9155b1e67f36675880dec6712fb773958ec04c0d4dbab28ec72f10f2d97c7682c314569d71dfb80eba29c83b950356307e0a9608b55312010190ecead89323991443a56ce66bd35add605c46aadeb5a42f3ab81f7bf45e1ab6b97457522185c0aa3377c96298ede3e4e26e3895e2d895f5d7d09744ef10625dd4af5be44826be38a35af4283fe0ac34ad4e63e178189b2a819af7de3d9e1de0dc183ad97085876c0d8bc28e8582a1c0c61f71c437a4ab355d09a92ec060023bc0517b13b3b29607b0d2fb76a61a3cf40dec4d70f1a4e34f2ea7a30e1c0f769279b5afed99fa916e160de387b1a841adfbe950f3e0baf99c05b268329ab5b046701182144da815bcfec48985f8952bc7598a25e8fb5532ee2c93bd0cef12305535169c7c0bd28498962eaa877cf00129c4f57e49c9a96f60f4fa4d574eb38fdbf1d986a9ded8159a63c288bd02deb58d03db3761b44267632913e92b89ba6d719e35031e470b8e7a0e37a5e3dea175d3d171ec03c3683e3f183b9135cb4cdff906dae49f30f4e38cfbbc292121a1341addde06a551d08ee5386c32cc7c88bb9e354040cd61673c1e45b6da07cccfa82f45ed5a04efbdd6bdb63ed412862f5a39f04fd473ff10415c9067a1bbb2470bcdc4e6caf2201d564a81a36cb9e9f35a41722ac84c03249a92b1bb7c86c0006dffd14f71b1382dc4fb0aa3046ff33f5157059ffc27d125d5f9e7c5722fba0eb4e328cffe953ebaf3f4692e083dd269429e781ecbb70c8a0e10dc6a3808aa0a93c6f8716b9b07b9bbffb78e951532918c7bed0a73029c8d1db53dcff4a6767dc4cd715abd9a392d6109e3f22ebfc07e205abc4c64c6c18fd570313689a8a59d9bb7dffa49e7d182555e6764f60e50520644e81d7e85098ffbfe775a3383943482ee7fda90e162f4bda2964ed719e0795ee8062ee785c84e39b4422cd7995106c30c4851877efe0755911f42516f3dc3b35c35cf3abe0df10f614a70f9f54706a4520306c10438d8055f095e50efc7274033d7abdd1cf1b1718678a370f3cb2a8a40cd455944bf4e3e3a14399752dd42f540e36cc544a7776c36a4d7e0b7752e86a03a0d4f6ba5203ea8436374fa77181edbb6de03357ff013b610c21345391d2302e9c726cce70e812daed7f59008b275b438bcbdcf40fb965c9a25950a4b8a3bae0ba7cd14fbd918d887d19bdf5726956b44ac641b4a8b2d4c00a1027f0a9200b5cb59e0edc4948ff738cf4eaf3a19ce6639370c2e51fc5c5e5054f6ca59b4be155e95c2571a63e173d1edd8bb36acd8fec9b423f15670270f6510a57cb48b4b9a900b41d74756e0c75d73a4595aca2f272dff8fe12e97f1af95bfd25203da521b766d266e0eaab51f3fb25927b459a1487a0224bbeb822a8ef021eef8304554da8c122c1337d89dde9ac6d05c12b71c19a53084e399f1613b91fb527590fb99f90ded369531570c99f3ba51658c748e714d1150c45f7dbcd46e6dd5d4d2c45af07b4977a5cf1547e64caa0dfa67d611a76e54b16c29df5e6e03eb75cf4f7fa135e4473254635a3a207a7301c0dd17b8b9bb53b2bceb2994d84a6bd742571079fc6592409c626eefae56d0e56d51feb4518d5beb074778881716cc0f84ee6fc41d6410d3f688184be566d301de02606a123e677925d8643356b9bb4b914e56dad97ba0cf219beea084b89874456e146c809b7058dc8f783129cda537ed3b8220c5aceada93af4aa8f8ed1c549e019e78d06b998a50bc7d68f29312422be94e2b451099b41c07254216610b1f0619feaa79b5c19bf42967be13a3ed711e59c3425eecb84195b0fe328298703aede85b0ec9762063418b958c6c58573b576e5a3f5ad71c641a78d45a810df41d89dc71760e8d19cb66597c168bf08df9a933f2996eafe8c4798698ce6e834c969b08d20bba2bf4356d8b0f2de99f1cff1f4d80015216f9e66c7a8d12abb1f0b07796969cd6ab5120c1d2fe67dba7aac43d586b9375f6ceebc311604d52746dd53f8735f1a2fd083f8537f043801986a0418a0cea95f727470c868beb0062b99c5fa1e57cc583bf23d4690b688905965cd8a2f9a66e1c8d872650c6fd8dcdc2bd209c91ec296d629716f8e06758207699fd08c7f3bf93b1736b76845fbeaf7e676b4f56d2aaf1e44847e4dfb4a80b162934a1889b74e17c6e26c5b9a842f2348a5c83e0d10d1fd6be17aeca1c4b7d68c1e5369084c185d65dd6da21923f261f971832283ed19f38e664bf82552e0bff8f5107b28ea70913261d407fb0f86b7018780c877148db015d6d5cd4cfbf5022aca9d1ba4f0a7703ef892c3885231eebf473951b313d95ec03e501f561b9f5c1008e7caa97005c2a3e39e856829c717644321b171c8c2ff865feb9dfc453231ef64f5a5853f4f8ae935f254f9930656b2bca02867a5802dc9e240d12b64b5755291c290050432208d91a002a9ee3733934c74b06ab55f780a6ff1b9b652d40abdb8e3eecab73b4ec9d5706ee6eacc92ff55fd8f61b73597a0c2dfb0696f9c1b6a162fa22775c8812b60b5904eb8dfa319ea521633b74275495be9f1068996234929770a6b3d564cae99d7d826559d3023a632ac66e6367ba261634ccc97f3eb95ee16dee1a8cf536a0ec4b29d7b58ba5749128d7ea9404f2fde8989a51cc3c9f182e3b5a2e9befd61bf846e12fbe1b37d52b787266cefa8a7f134d160b04df8174463c91c533cdd88f890b39f74741b50711672365f9b867b8699858ff722b5655416dbb2c86dcd54ed8e2632ccb4ff7748615955fd5f339027c9b049da1d6dde78be030ac8f0b956d93e52469efe087ea6bde5e144683e34afb4b090b436d5869ef7b5413db6686daa67465c59f9cdc3837d7a2153105be02c626f0b2d3c2dba1d2e10766a74103358bc2bb56bf79837cf171aa9cf3e7154bfeba3333d13fb1897c8248c37d1873bcd6fa89ee315f186e2755a8ace67454a32a5141764d1b50dc99d8ee95a8ee9eadf4d5128ec4607c943651b1ee9026c4ce65673864631164965ea81b0d30c34d4e3d008c856a6944ed829429223ef51fe71125ca6dd46b708ccef6cea2c284188a47f7c6fbc8c0e5b98b605184859b8ab8bffe8e49ebfc208d7dca542731d1f56202d59e8d59e3ab86f580a3f364fafcc15a96d1d19eba5fa5ec55f06e995b93c1e7225f7ab6b2e14d261d6f718ba0a850e26725c150cc27a644a1f0c37540b6bcc6d5099886a53a0a919bc297e56e19842c94a6794964d9a89ee059d5c2044dcc4eb0dd87b7e91d3a7577dfb67a25492f9e9f8b7210e8bccc62a80b47eabf2d9b68e9bfc082ea15fdc164053e9f58b10399f0b405d34d0645db5c33ae5dae34574ae33d2eb53eb947dc2553b55d3e18f77eee2e475b94ef91e8b0b2a8456a5165624e022328d2e995768a1c019313a5341cf28d0254063045f48b9c094e210b4c537cce22588496cbb150d7059c550c343dd043dabd64175cfdf20015ab271c0c6b164703b42e40b88940d79fbc5a9956692420de6cc0e69d110d04ade80559af41e52bcd0664b29e8195529abab79e912412add8f5d7ff847c53e6b7a858e8a0d1cc8b99b2ad3fbce5c382ff80938a56db320054633a1a20bba72bff5fe22285a8882ee921c88a9767521f50f282684cac75205e80b13747d20ca0675b17df4a64e7bd17766b68115aee1a1002903ce93cb82ea88d841760cf897742a7c7082f1d5b82b3c9fd3ef8b38952bfff777022a2be82da346d8547cb9fde739b5fb5029e733c823e7c353c139c147287076a25d76c84bf0db872041c3d8278a4d71873678fafe7a514427abe16b7d151d30effd97cbf62b877703ee719efb720780a4c36c64d09a9c3732a374f5514cd1cbcea21b2333a7ddd6b249cb832a2cef0ceaf62cc3c57bd4abc18e5728ad4f62cad6565299038db4300bbbd356b2742bdd6a865b8a81651052b0419bec7f567325d3775fbc3a35cc60520ad91c2b31e4813fce3459975e1b68d3104d000ff04cc5115dba3df1516289de281aef2f051b6ace3906d11d3b9e66570cc43ddb515f33a3c3ed05903eaa8270f1393a6fbb1ffdd53fe4c1668aff1f37f08935e93b0825ca02b9e5d4024a821bdfa625105b63761ef61501c5ad90b31ca193840d13d940f2abfc0df6fe4ce65c3f1730bc976cfb535aedeb356301c5b656a00ca594b6ce40ca6ed59847f748c71158ffc067bb6db7ca02560ee18ff5b92ad33bbb4dae445a7f60194e7235b67e02d461b905c2c428af91824afe69283ccc1ace410cdabe58d3211f2910f3a9ead96c8ede2dfd702436dd256add7eeb3ae0b21dec6667164931dc84b9a9f91cb35878d3a7e5e225f8027a5cb9258b4c606f798bb7783deec2b47df50cca19aab274b6368d4504f8274a8dcfaa6cdf630a944615106b048810110f6b2081814bdc7578340bb87726ae17fbfb31b671549f960503077665d6423bb4b1c12068f6e3629237f7d7da31b2128c661b7f1e8f43f5b249d8f2ca98f76599fe01a41ad79c53e93240e2f35e307a03fe0f497af48eddb75b1394aa0909f758f1b1df2602b561a79639c465eed554e0a1e101c4b179f08451b4f043ab29040b21180b8e64d34d0d42c7b6cdd2adfc2937f6476c8c258a08baccf1a00efda8510bddebbd432f628ca2d297876f7588799be379447d02faf600e56daec317364b6781513f18e9ff8231a5ac12a7330f79732d553d3d5532cc40591fd69103b919cfe6d21a08aefb564960d847bcda6fa2f2818db7c37982ae3c3775952cf0258e6fa41d416897ef42c561af5752b2fb960b04a7523e09574ae66c2f620319a7b13f3d3bb5029bea0f09996ebbb1fe5e8ca6cbd4459073fecb54952c0626baf0ee82fb7ab835afee6fce8a26dd3e47030f4342c624d78034058e5c698088039ab30335a56a43c84d32eef82ba230012b5b41b6998b7831969c59cbb4d1704cc6f8d74ad39226d6ec015d710cbc9352b9c233b7a3f58bca66281d8a3965f4e083a8385f12fa9f01bae6a1c846a6fc35f082e5642773be7ed49c8e120b0e5fc5e73ee264a8dc2b26308960b35db8f461ff58e6df9103572afccea3f181aea370f3ebf2b3c6f10bea01ab0c717b4842b622758694127927f24d32cfdbdc47306d579b4b9f2d14f3d12af4a4b92931b0f2d94db150a7a0758e6ed587bc41ea6a7205fc16980d630530761c3f568b09170407b5b7bd01b7200564c832ebb13c22dcb2609e5405b92cf03daa8c80d403fd89a8700062507f34d10abc89fb330e4a4bc2e01c2da81beedb22ef50733514cf8867d0fc0ed7b4a0264325b12a9bf43bc93ddd40d3930ae71982d2537f871be5bebab58e7fb2b7e638b69347354e7f245c41de5e4341fb6e91b2ddfaa5de249e1880449073321b0ae2fa10f0889f9599bcfcfe817ce28f1e8e4a9bc9e16e33fc9a45398f827256891de99e6512afccb7c785e2190e8a4a2bd021f2214e8a8c9bfeb1dd1b5a983c1a3ace4c757cdba705f15f5f32489c0b5480b908dfbb483029324a6af651578a7241f5419338d94bf098f79080332dade49610e2159645cebe34a07d3b389dcbdea948b6cfc8340e7e1af379a4670637578933ff4778f3706d81f4e3b28e2462f74eb4b95d0f109a27479c207133b0d30d41d0caa7d10c4dc3062096565fa8c866a1b77a18c89427ff348a0f4b1e53ad45eb54f95c8683bbfb06f34c4e846fbeeed93372b3d430f933defe0dcb34da656a44b6b472d804ebc76aad9aef2490b60b220235ebc02acfb6f6f0955a4997b967d84180ec035ca074a88e418039b727686389272484e924fa3d249f4fd725ff2579dbe6190f412a8539eb8a9f1155b0ec2be8069f1abfed17b6aad37caba941a3e35241b59e95a8ae7f5d42097d38ec09bb3fa703ab399aaa192f16283e0ee89c0d543632e6dfd9344017df3908e43abe6916a2688ec5aaa0d86668de17683cc75e030bf85e135bd8bd98ced574e1c6c4eb382b8c2310f44ecf6a7ea043e8b23c9e720648d54b86737a161e5f2d3f4461c1dcf3793ffee078f9ba70a05279c135c1e8c1b4f2b43a6e95f89a340b96177a474ad0c7203a39db97f748abd735256a91777311b861be3c4a6f233b93637ab7f704d189271a023f305cb8f9b7d269da3c0f27a9298a63faa4b5f119b5081fe6b82f233c471e21c768e57156dd4812964527345042fba2c3e4eb62000872c47f8d4016d69437d447bd7837234e9a0d5e55ea85d4b0a5d0c388b4df82cd8bccab15c3347ca69a4211f7182319f994c802abc82d61e6a378afe6368f418dd97af35c42fb61fb17a0604aa9dcc179458848147082aa445081dbbc7bc98450eadb09c759841650ce2c13e63b3966fea5e38175c9ca30d6a99db301fb068ccec62e1c059f5f4dfd20d9e249f0527da8965fa9ff702d4f28fbe1ef0bee9eeaee2c71c51874dcd40eef2b3f5a15557175fc9cee742c69f1362095a652ff63bee579d0d3cc8c908d2468f4a1446385a8ff6f854352a2b33dccb8bc0817b2ae7f4f85019b340c668323762f06553dd2f16005420746116513a99e58fde937d0b071838e00a030b3f76f4f7a3807616a86b912b020a6efcde8cf2e1b0c10b8aa2dd657b90c894b216196e9656d312295ca65906763fcf685a70f7653d069d6e1afc707d49b8adcb893da848a7a20996e4efb156f7bb1b7a8bc9b4eb1f5a09a926e82784d9dd6a8b6d6e49b2f8b9db2bf16c8a939be5216ec692973886cd06c102bfa500e5ffc7ca90b98c15a45f97ef6ac1c795404e6f67715eb9a2cde97c5e0b69f3a7775d6758151f31a7d326536ac5f2717497e442669676939d6c228cc68b9d1ce572d302e723e45d3415c20e6113bf631c9a1564b78ef77b2e5ea712e37f804581a0ac029320526e1bde7d1fd549f7ebcb5b067502e21fc0ef78409f21d1fb340d7dff9367f14962021790e2bb2982735de5fc12dcec6e629a7ad304b6ab07f55bde627b3ab30b472e4c2f215c331974233fd591374c3c377ca6cb1060fbc97d244302791ab583d2a55ca4f0c72c30e638ddfcbfe72e594ef193f226125c69849feb800c454ffe392b0078b8b0266821031d4fc4ce52830f5e0fbb95e6612c6873e8dfd928f04a370b95589aa95fc47cd472e1952abf6e329b71e49b5d3d091649fe58cad91118f5116570d6842e9368587fe45e62135fd7389692643cf683dc994ddd01613a7f217ad06698ebb67e540366bdbb02f70ba660f87f017ef71485e20d0f696dc103ff5a20a7faa8317fec02306bd7cdec3734684d63b1d46dcd58ab55991c390398a2e8f2abc2601191560f7525d1913ca494d2a30732f6a837e60059abfd22d6c70867ff62cf3e0f877b54a418aaadbb643a0239687de622bf2a80574900bac08afeddd650f1fe46bc20224d69d8e72ce5e1061156fa12f0f32341312867a96f3aa928428823457ab81a87dd23b05ef3d579b4d12a23163ab26a2f492596e575b492918e5000221a702f6020d35079a1ec2be2514dfb1900c87eade36637ca1c76ec1149c2d64700084e8679ad389d2e8b11e05478e9540b8f36a29769d7d7c06f255aa3094714590e610e2585523a83559a698213874e8aed21c48e17837087e7d7538f5f512c69e0bc327309924c3636e894285513b681b2446468f86a88f882fc72f0192f602dba8e83084859a6d722a55d51498c2b421aee30b05cdc96fc804f69636d38b2d4b7a33970006ba4503463e28baf1416a311c6361cb82022e3e2d326b4b0f6f40041cce837e7764f09c9e978c27da8da247e349b4e92697cf4364eccffef6722890c85c658be0232fd8961d0b5bb9506c079b6d48a4fda154d93a0f3e6548fcf91cbd7a57d321c7a5fc60423b96922ec4fe13e8428d310462248d45cf8e9fb59d05ed0f39e7c7532989465d321f7e8497401cd74f6a775ea34e1ef27dda11bc07894f4f62349d3297242a279204add02aef4ec31ca39bf1aad3ea3f7c7d13131714688faa4e34f4d625686b63f036847bc92f066cc56608e458b05a4541dd4c2c768aa1b62779b5f075028d79b46a851ce382e48567f8750c6d738c3fbb94d59f802239bf0eb1db2f9643473b17e8312d6955c02571074b1a83be958893c94d8c7d38e4b72840de827a0107ea95e206459817d3e67be98562285210e8e6cd2fb8089d134229abc3a654396512a086f5086c8b25455469c61a3704d427137787c5919166184f8f152202f5af1886195478e59fe6035aa3616734dc8ae84de5c9618ce4b30363771ca2a89a54d49e600c29970418c604a6941c14763f6f87b0234a8cff9a67a78501e230994371c1ac419f0ef786ba5bda7e89ddceb9c11e36dc6b0a9364d83146e8e4526c86165010183d336a0f238ac33afaaa186d8922a7e995de0b30af6caa7f7f94a09a4e5ade2202319cec541a35e043a3f82dc82c6aeb716b8d1cd617ae766318dc7c23906b39c19d5bd1383c6e5113a2f7af530d3b71c77979bbcb46031ab512a53373e68023fb42eb9f51dcf22cfff32052b97f1acb544ec790c4f97603ebb3ea048a51f3c4531fcd0da7c0e2cd6a794c6d52c244e4e6c8ba9a7e6e76c77e9d1f35d60a5cdcfea98436d6bc26c640922cd440c935b51f4c81d23a730bf60100eed082b1f4e98a0bba57dac6356122267b6fd110bee26e5e80145e78d2f53721832bfb6ff988a0e8d808a68a3d52928eaeb6fad7c2cbcd0b670ea019547035d4ca7e5e408703050362101eb25a9b6c83ba24845d5a220ee6d6b810b50f58fa6d01b9c10232b9d9babcf22b49d020e355160c9eb43851891052dcd7639eee97eb12538077fd54de9e0ff4ac80e41c055ec65435adb1de9113c936841c0307f5981ab575a2abe491eb32817e012bbb376d64f7c41356f1e6d4e7c6ac53252fe64cc73e0e0f318e9060d125818ed9ab3eac6d7b021ad1a47e40bdea917dceab93793963bec5e7391532f2e7bf1ebe8bc3c29184c02b025a5f040584889a4c9e21ac9288fe6c89feeb1201eceb2da53ec94a9f798a7de465aa027e2731e8d8244f91e636f34f40b180cd59b98b0adde0b0a071a6f5f59a059266eb18cb496a2a1b648139b83164a2b37db65769db28c7c7de7fc6314f3414730f82851831853ab1cd7c0e29413b0bea71cccf088a143934709fdb79955fb8ed2cb76c4055fbb14d05ba18dee717330d2b822664e990b41d50f5379c988dce04f9b703fc703992b654f22f302fe1e4eb91627354b293721cdd23d2ff1d73be906f646ae27af0dd07a05e6b321210a2b018b4d5b68f38f1e6f069f48beaea3829c47c86736624273818d359ce3b8b8832cbc682c46ad5af8b035d4225dd5b2d05e18b59626b6305af5808379e44714174255a245823082087092d6e95e77e36cf94ce77b15ae287270fa48e0c499aacd85f97fe632d2cc2a5c10355e397bce65386cc905dc1275c691385255bd88ceb947c7eab9d59c6cc43b438298f94b167270ad85f2680985d3e409de6866df5d22e23334677a963b26266abb8ac0ad67bf56d22674c3ae3f7a8ca89c13b076997ea34aea654f7e8943e8400fc776a944c45317f02419a6e264a26677e9d534590030ca82514f764cc214dd5833a9ac71c588077e7a903609983965ba87b6b99ae462b0d5f6e842e9839b74f8574f7315e0da467d580dd7a1e9ec41d6570a0ffe7707fe4beea8c9831eb19eb21f8c836cc0ec853a801e22fce541ed29e8d236f45b445c5608b123e90eb97c8710074267978f0a76aea9dbfb938628735ccb213650ac6745c0fe5c48b4b29a2893a5ad30f5390208f24a304c722b283578b8d0c17309704c34e4854f61193051cef9a036f8cd33bc27b999d9d5e48e30feba7e392dedb56cced08d57f5419c361b01d5112db689416883ae0291d00b069376ade2ad2657d9a0d001ebdab2a4bdb7a6b604e884d7f5e625e6d1a1ce37b17bfc77d5063a8f32dec4312d1455f636defff3c7d76c5038b0d474153169c918ad2106fcae4400a281048bb099ee75cb7d6815f1b3808dcd63fc6d32f684ca59508f5537d93562500fb20a0597ff505a2b96f454b87a3e8310c0c5956b95333e9ac882dd1ae199e51fef7d20ff29061dfe58193b6f217e40e9867ab9da40dada3f4c6a1e303bfda745ad84270cf4ac60357fcddd8a46af374cd9fb9b37b146a5bd05b4eb250080fb3c79c6aed3af39db7d666dab509085f7394c2751fe3fb4040a705178219e3fd5c18dcd4e5ef20d26804a02727e444a51afb470f7946e510d570eed2ea3ccc584450e97a320f3cea73608fae8d049ab86196fc1d1c0de61f3a45c909715e8c82dca1484f9984e38e3cee67483a84ae8980ebf1835214ba15dd7d9952194ee1f4a504dc7a5743fff8f34c0d7630423f72aa6a2032cc5559b753ba5346e33d7c812149856860587cee163ef4d715b370adbbbaa9de7571de14f296cf4197cfeed03437683b3a835edf76b29af552ba61da8676fba0ab8e422ee77617a2da0ea784e89bd5db15a1e6b337abe3ec1946648e8d53dab8cbece081c3d3836daf1d7c57ffbac7538c9af6405b33c1e6f3ffe0b218488a22459ed36a4427c3456d2afd9506f7b3baa72022f9ef3da942d847505a4ec12c28e7b880ef658857a981b12d538d3a61b63b050aa2e2aefccc9dda21ecee5321a936f621d2fc9dd61ba7665e8af4022746d72f9c5c8624fc63c800688615a491534295937f9bcfebc613449b7fa82fd2c504cbebf6b07471d9c77f30f77ee0f586d5a85b3b3d4358d636e40a96b19dacd1e322e850e60934e1019085a40f4e57d5b8f68d471921355150e0fba08befcf4fbfb6d8594c5cdb3ebbf9aaf7dd81147bc4cf5db47b0217f15150b1d50d90041ae8c9fa6e9018a3ebcd9129da4b28e5e4b98db980c5c9ff730f9a5cd13f820a5afd89f72077849bca0f09b58b27281e79680a6dbe942ac06240f47bb5d11e63d79295157131f21f633bac9a0ab09ce4b744b1b5da9b788e8d944b0be39c523534f65430185caa6230fdadb4ef8741142b3e1b923c0d43ad062c6714af329ee9b7ab3d3f2a6fdfd70a1b083a3e5300f07cbd73f75df95efd53ec14484d322f9dbb6c70b7d82dc9ad9191736a17cd102380f4f9bf1b06e41d838c9d381c9a115b312f0ae985e1d093bc508eeb6650c9f7a4638e155fc96aeedeaabc84d701baf2dcc7a04aaf495f4aa6e34e1533871e31dac841d5b6a2ce1c552844871b95a6ad3a2f4cad9e091d2f672f4cee8dec1991eced84a4effbd83a104eeaee75f7e06fe8b3471ea44bf12aeab859136584b28b32c9d6cf7d14921e5cab7ef932023d442d3bdf3b13b2c9217e78035ef6cfeb2ba2211eac372fe1bf449b836a42cf74798f97738cec7b055410f40240c075c237b498efe69ad05e905a84a95d8a3302686d2f9d744b4ba021969bc6194f1a859339318b507687d5c5baf93a971507e6a9db75c4e17fdb9f8a8d211967fcc5d1e4b4013641ff4a8f9554fc39d86852efcfa4b3f64a768c1b01f5b881097408544ea1843366f3b513a873082d15ba150d6ad6e2ad355b08e2408ba43943ed7832140604a5d06091e3eaa9d1236a891133a0c2b8384a22d58f6c9ceadb72506920892d35fb08a6c7d84b4d7276532aec68c74383bd8f4ccd7d7a91633908f0037bc0eccf667fb7e9249a6f8ee4a3f40c59eb59761c0f40776c723073096f427022292dafc953c250316dac2911cf5597560250aaa572f4a7431673972d4d2d9fbe5d9303384514c0671ba1a5841ec7ce22f0920a40e67266419d4e11f42ba333310dd0ac68374a0945cc999ffcfad4a3199eeaffff46265c3ec43d90463bad850aedd6232d6de5ba3646623fd337d55ad0d7caa75a320d7f8b70e18c0b25c84eec7a28e5f1b94de629615afdacfba7ec1c42cfceef5ee80c0593861c2597949a3f0987aad4c3648e442c8fb89a574ffd1ea3d6f7443c185fef221a294414b5f594736b782fa92778268319572003d272de888f597fe7d1e5505d748c19d2d7a64a62dd732b5cf4efcb02a05338afbf3c48224895a783b9adf25a0f9fe7480686dc344833d0af781c83c8830b9bcd493b33f3e75711ee1ed287399a08f5da542369a814e0b8f5530ad85f7f1bcb1f0e9e9187304cc0a50f36b77a0d56ce0f0eee9245286378cb13eabcc18b12bdd2894fad077419bfeed7315696fe726cf20a1d8685aa1862485ff78bde2218c57976ee392718a535cd67df9d57c1a2f84b6d8a4373083daff3487ee97eecdcb9703bdee0513be37c1a51b17429036af41ee516af9319ad1b38f5208536fa8ecc543ae2c98713bb47aed164e4f7d54797c96700f542eac42ad2f37d41ac27b83cebf9e5754f91da53440f18a4a87a58f7021bf57f7aa1db899eda16e6a4cf16635de8c7d3f4bea1fd8bf065eedf00848a3e287a93cd504d4a040325a76eafa781eef195312e174af928515010b0da6973bc101634aa4ff4a3925ae660acaa2f0e46c0020f372e0206187df3b7aa346d09fb85e4a3e2e9deb4ec985426b23e3568ac8d99e4c239724ef948f29244a1fc366635658b527ee321798ea686f78af865ae47c2536ba1d64955cd6162a64a6a63ed07a3ed2342bc136190e5dae5c9980127c758aafc37cac50694af0d10ec25a2696e68f6f6fbac7cfb8906e0fd1f8ab134347485443766e4603f9f936a1f0495cb47866fad4204d0e54cb38e653f635236fc36175ad5a4e397beadc466215607977edad3bdaf0fdd28f342aed1f6e5f3556e64a27d21aff0b1527bd7dff474b165e2993096e463201583bcbcc3ab7222ce011d79005919aa30b6a065eda3f2ea027314ab1ed465104a2622045e5a5a5c76920396f0cebf6f09d7d4a00376b672e4299554d82c5f7b2e3410e89997a9ab9cb5b3c71d29e7ffa1aee21bbbd78ad3148a3b25bddda9c74fd47d29b59b164c58ede96cdbdf607fd2772f40f8667fa39a4b24482f5e7bbd7a304f846974686b185967942c17642515f3c6d8b73e185cf781458766ab70fb517c00a5ecbc2ac9d220c268fb743faea96df79d43ff04351c1b270a91f31b14ba74b00033a9dc351fd1ecaaa5db475e168dbec0fc3d62d8770661aeb8d9e70f4d1964ef5f1aa7df5b5f52af7d1a31d9f78785be8321ee8cf71524ab6dca1d723a01876cd5b376c3966fca0472d584a2becebacce0eed00737716638d7027dcea2d3ee202e0efcd94cf228f53d817aafea72f6b757db5f3ea46a6c802ebef59047dd7d6fdb6c535c6bdf77d09d71cdae95feeab5ef996b9ec04ca694cc68dbdf4eacd7708e9e278013824f64e0738e23a6aff3b0a12dfc11e6ba23905126449309f44579bb0191e37f91727debdc015dfdab97fc64e41ff176396086ba079c57c8f4e67df62bc7fea9225b78d3127b1323d69525a76fb0110f95f2b5f6804f547155fe5befe745bdcbaa61d1b12707755617412ae49855c6aeb7c58d3649a05420b6f70caa720cbce1c42bb10ffabf5b7283fecf32b23929a36a54b67e5d267880b57faa712ad40f1a44d2f86de65f2ef38e4f18b0efae3b0e39d34275d17a86624dcbe9a095cd1c066ed2b0e459dc0dee4a667c119fbb456b900fd7444966095f86c62bfaa48279d1619edf12aae3c66e85cd95008dcfdfa6ffa4b759e52900c29b9cae7e1d8ccd6cf20e11a6d4c04ffa835d6e8dca92a31ea91a425a5cf1b9fe1097f87ffe14b377d5e27e32da2c39f738f30632187949bc6c6194c401f74198cb9ce45f1776339085e15aa6b01e011c9b5324189ec7cbe682f3fe951403015c2d66f66823eabac73c5757a950413b009203987bf9481d74fb99fac8126d381d7984ddc58e1528696002afbdbb547e96ef1c63ff0378979f76e170a15fde22ae8a6631bf6e1daefdd94a6019198f2f22f1ff901b1d091ce9b58f59e58ab9bf041296c7bfe26cf955bcb7192845226841084d1cc272d6e5a1354adbfbe6937ddaf2abc3bfd2a0d0dde21956fe7399b19666ef0528f68517e41a86c338aaeeba48e7803dabbc9d23cfd101e0264a6d47a85a0686545807a69e2ea706c56b72c66b7fa636225c2ffcef6181e603265e4f50ebb43c0f49a54cd05c123e749c4e3964f354d80bf3c35590f975217bc0c2196d9ee9675dcadcb34adc140b09ecde915a5d96ad364b9cfd155341260466bc848254791886084a40c4d5aa4cf8a7aae7ef2bb0f78a348601a49bee07be06c73d81409e294a44b6e9b7c3b8ace8ec075cdffb42f181365e3f2c0ea70a40944dd1d80f8c9a43e53c723f099b44f7e79dae93d9824c74d0a00d291fbde5a98c3439b96bb3a46cfd5ca3185ffbe626178f292ca1dab322f2b8355352c6a159261b3dd2e613b35d5314f47847d10c9976c6061716e14f89572542c6d48e8019ead907185289e9123b8b7f492db79196997725fadeefec0089e15b556771e7635d610f65bb870ed0e76bcef4b3c7d96bfcc8879ef669b553e5eb03306632b3511a053c6d06ead69558938b687400da019799b8c4415e14464edd6a0372953425bcb63ec863e2e6494540f9b249843a5d175cb66c0b9b947355ff01d0cb7f6bc0647086d954ae9e856d5e8bde067" +} diff --git a/api/pkg/did/testdata/mldsa87_slhdsa_challenge.json b/api/pkg/did/testdata/mldsa87_slhdsa_challenge.json new file mode 100644 index 000000000..b12331752 --- /dev/null +++ b/api/pkg/did/testdata/mldsa87_slhdsa_challenge.json @@ -0,0 +1,6 @@ +{ + "description": "Dual (ML-DSA-87 + SLH-DSA-SHA2-256s) challenge fixture generated by bigtangle-ts PQKey.", + "did": "did:key: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", + "nonce": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "sigBundle": "010201121321d4dfd91acabb3ac8dd8722b6c1fb8458e0acd17f8f934afde24b5d564928bdf63751459b20dd487b2d506398eb2192f9dd004c0e957181a9812432a89c9e5cf4267702cb00f89df1d40e38fbc1c4ea3339b2f4fcb3e5f34ebb3dd4c6b06997adad0dbc509094c9e8e296841aec02828ac0a8717bbeb8793583ea260df4b75e004cfc3fa65f3bdf22de6010d695b157ad564da48a0ab44a4de752564c2488984fd9665f4826e9bd042309b0f5705b7633d966866df9fd2cc91783b01157dafee27546f26c62801758c519b8166caef33bdaaa707333d394c5b80a0d5d61b70fd09673993717181785ca26a9d85de8c6c2c1a430381db71f43bb31d5b412718cabd0e7557d75ff7b6a650606b7b1dfd40ebc5a711d53b8cb58d5758178dce09007cdaa9de7b6bbe365f5799330dadffe03ea80a7a1db69a89573020d44751ebe142a4faa5eee28e2d1083c6dee8033d46dc06b90204d10c86a9e75912bf79657586a22b7189deb310d5561e842cd594aa68cda430791a066cbd31e360008fc0f43d91713acb98fbad4ea244cfd931bafed25141cc2fd82c8c2f03d2f15d60c9c820dce67a1dc0fa23a023f08153b195f3383abea3bbd9e854d0fc00110dc4fbd887d3c15648ea18b3246b7d636ab97190cf08a14885df65103f6073c7b3ab8121a05033b4db73c44241d2960f34c10ddd1d1927d2e6763797eaf1dee2cb9718ea804b4ea10c22e5cc4e29c7c342bec0e5e0574249ec53dd069e7dc605b441ffe4c1c8112b2fb4227f997668060af62d4df3878dd76aaaf25575a033d038fd5a4ed613be1d92898f7326976c0f1c197e32179bc6f8ff9c1d7d9e1bcbbb077dc15d7566da24d4b7a0f834a17975a1ec57aaea4cb7a5703da1b7df058c488484fc89eaf7d832fd543053cdfddd225fb0901997ff075c512977e73d2b32460e453365af61ab2203546ba99095015541521d8e0e4879851037bdfcc0eeb4bb4700eaf61f8671ae7674ebc47a8c7c4f49b181bbaf6c6657abc08180eb8079103b3c08ce82a906f4213051123a4a1ba28de622b79d8daccb5eb22ea3fc34a95b22302be11db0ca9d3fb91314e1b71f4ada9c67203e1d400f0a2d2bbab0df547d4309716d125dc72f6acc5916af598fbd6b9be1e38c1528281954d4158acdd1215bcb28671b16ef7df505c7f23c675a295af985b9f76a07ce07148c92e0c40e5ea578ac323d5b870577a156933d1f837ebeecba746220e1a720ade25bdb14a024fcc307101190a874b89edeb0622873d920b293801fa8325b1064b733913e3623ab6f8304be97bba4a2bd1ca90753c1f6b9913c783915a2154599e42e9b36e66c183a772c8f68292b0869f16384351bf4bb688394d9639f0fcf05868ba8afeaed0bdba3a716df3e4517db6961da36824fc5613848e951565c9a85903e4c84fd3b82f95a90d5cd52e3d7ea5000808b4c22f0bf72888bfd930623a148dd1f9eafdab4f01bbdb7094cecf998aa1dfcc8d8be9101b9075d8739684994f4532d5937c36f5d0da3c703efe9826b25faaad08d544b4761417b1d7a0aded06315170b5bd54209bb849f8a60e6daec91c98908b8f99223d208efcbdd95b16957c2676318e3cb1647a5cd825fae4c802695cecea005a58396c76652760391943daf887b4e11691f139a0ada5d029fa75a5852fce046bdb3053bbcb58f2f2cc09983b56f6f822c36c82608002edb92ec9497bd3391af1f9b6df7235b032cec4663e76c6ae0a77a33518a015ab88ea87eba82da5d377bcb67b7b2d2fd0c60cb5b8745c293287db23c8ff765c3f1af6e69f7eaddb45f17f609c6e3b0fc5923accbf93c214fab2b3a3bb7f7d03c24d2ee82932720ed8f931f90d891e0badf329a273e67dbe0b2cdfeef87ec4f4e4986902171ba99f202949e2e0558f144d4248c147540c5db5d2df4dae4c51b036c682fa7c20290e54032a2fb247e1247ad8d540b1664958f9a43d33979599696b7e93d66a398d09f580a3fb27ca0b1375aec7fb0e6bfbd3ed8b1a09f3906c24d5b5868b6f9b51ec3a5d4c9eb329e95a7cd7aea184ac11def6f7d17c5af27c6ad9c0edf87f1641448da1ae0f8105e8eac5b754fedbf14c526abf7316d4f571b3cc988a2056f438250223eb4b1c95a27bceaf01bbefdf8766bd13d2a50ccdbab49c3ed44b188c8fac463aff720ed06711f95941ad675d83f76a83e59daef9555e8a19db113462f6f1f890ffb21a4f0a6961ee158c81b0824b41218f22c714459b2f456feb7e3063a4f5c78cec67d156ef4ba847cf42a226e54b8f0d4916c41801b2798f6bac712fc5836193586efd611c97cdcf747ad6c6f3febd43011c924ad676b5d282a0fb95c9e1352f582ba770b4893a03947114928ed74f0fbf1feec5779e05461530ed034dd27a328d1822c1c7606d1a90f94f9384966da0367648a883c6e2b2695d1c05c440910f8b999ae8e622b4890c7d3754483a5a184dd92bf155f2da04ad3480f6c00fe425790991bfb3e4c18fcf85c7e0ba113a2c7747f05714b6d5f9594cc4580506c66049ce2cdab82da36913b312794873cd209806ec1d1566b922eb70651b40d7cd0e4edac6f25f038fdec7c839a081a955fe043aca2b735f3e702a910c75de95b92a46627e2d39b6e566fafc69eb975c1c3f510017ea8623eb3bd6906d78d90092f47504089789f0c6528384c6018f2b32e34896607e5d495e64b312c233ad2028af072d21dd46d0911eee7e52c65ee8f6f5de4d5b872d49643a87925825b7fc2df4c8cab25029b69126fa90f8a7b52ea6ff6c5dd673161422324af12b002b917466cb69c824d8e154c4f605e25bd6ed87786f55022ec5fb4fe76f22fa95fe11454e398939a652fd3b5c4b54f1e615cc11a29d14aa0bea499041d783ef865e4b4c8361cfb279ab57f683a44e1b5ceed81d409e3a0ee55b5cfc93b7962171931b7da50845fd4a1cb10179299d0bb3be8731b74acdebedc416d055145d3615bc0871c597b7548f5e8c05bb242fee4be46951b24f1240b6960ae92625fe7734a748a5d847cdf6d765942fa08022bfd5bc321720ebc29ab886b9f747f35943829be0c6967c945eef1d65098e255126d212e7acf97e683963bed1a0edf58ad35bddd01399953215da41b4035ee9f442f06133b7ab74fbf8323d51b51bb1be76e30734606be7d222df73e9560ac12eda46504ebcb70b5abe1c12725b1d47ca901d8c3d41a2ee45c53ff7cbe8388915c1edd976cf3cba2afa07487af92cb2999feeb659724279bfc26e4946f9fe06efd7b997200484dd80076db54b90cb270bc50a92ccae8935da3029a4a1ed33e4775e70e67d8256d75562e57b89ffda87ccc64a2bd98925e28074a3a8ac27ed69cad9759404213e8fc3a65c6a1812b7f67716c8a69d4c2d3bb646ff339ceca87d7d0da4dc7042bd218204d573fb9f04548d5f4cf5cede0ef9570307ac72e58717564bd613fadceb345d6d9dbca8a0592f0a9ceb11c842e9b5d579d704eae142c64b3acf5fdc8aed1f953e9d5d5b179f1796f9683ce7886194e0bc8a11821065559ddd5215e80e87d2677635ead3f5cdc880efbb0332e587881329744c349314ea9e05bd80b4bbf66c82bfed530a2164c62d8daa768b6721cd8c469f2a8c556a3cd3c80d27ec4a11206c6b86f6b1eeb95d212ce8a535a337a957e89b6c8964de973c69f8e5edb258d86295c864fe78200008b067564ca569f73ad7ea8e7b6f6ab456e50a5168b292bcec9c72a71abd51fffab4580bb1216c05dcc85df798e376f5e534e6941723100a412a538617bc5d58102040dd5b92a3ee66e715417a4e23374fdd925bd7efbd42c766e674d7f2d4c8aa2f0ea88a9f0ecdcb160e56dec3cc8abf60609869bb55cdfe8da4f0194bad376f9d3bd07663a49771bba6ee6a89a07e7820a501b47895246eda1884de5e400b36e440449d165c77f0f118d044b94ba96e1f57fa0493cbddfbe79c66994e76f7b7dd781f8a57926c097699a1432c6ba3b141994966fa1a55b41f228581d9d18c2a1d8bc47f7f0a711da7a4ccdfb7a6bdd3370a9ba6b02b185eb3a5d7b549b67045cbd993109fa706f30598bfc665d4d9765daa47978325f1f72be572bd35ea67c0c8d7d280647cd10e6853b08cba92a57ec90fb5f5fe22eb1390839cf5c776a628e02678a4aa21b756fea176445103adfd82f61f75f48b581219167e655d26d8646a571e06eb2d7aeb9b95d910821a1c2fcbbef95d4a12c4d9c81c6b090317e1148323ed2e5ec551d8f1e313b01a6edbcd5ed17ef6f516277720f070183a751b028d84f56a88e4c357c69d29c94d826ae1ee64b97bb429e425255ba55502be1180cae1c9e01dbc6fdb9efe135df4622a526c4bca73fbd5266320b52dd5590bb8754a2eed0f1a97efbed6803266c19ba8fb9bd41e5782dbe430c0c979fd02da7ca0d114273f8d966052db8f5ca773c784811c1ae3660c47f8df0d28188a3d02cbd4589903b6973ed23ffe22dff9ca30d43f946982ae45bb94de9768e0f7cb6d17bdc1c89750951612685b1aa1e76f24ee9458d9a35205ac6740364f98e2fb7fac0c3a41c17d9e8dfab1cc0dae7e7836c151ee8fef46826904d9237d719f4021e0b00fe8c6ee92af87561bc5cff188e5660da11003a52cf33bf596d4f45613dc9e5631f8ef581019310b5c0acbbb5ad8887faa136edab6e80b4b24b0c2565c74d056290ee51803b4de331421b3410ad4637c6ebb2d821365945f2f739a8efe5486be86d93fd72ff42263b63e216eebec18bbd65e6116a57f6d6692884b4ab090cb7a074ea0b17d67a6e093849ac9a7d4f0860d5cf5ac6db5940de3c8f4abaa4dafffae9114ab48fd5c271671caf8be6df846a3f889a9476f3e52d8974a4b29ac88c594328909e547d53215ce495bf8a5f2afcc3fa98db36d2bd1d83327ec2e6d247903174da1003edef423dcd7bdb86d8b695071bacbe3e7da4d40cc759580113dc67fdc80fe72d3af66f059c4669746c50fa53d33f7577228bb2f0d23be97f4cfc855bab727bfa4d6aa2431fe3ee9f46b85aeed86de9c0386450ab26924dfabea50b4b31d8fc5565983e38975a3f25e3b6cacc219481d6055c5b87cc95fd2a5c59952d0431ce2da70cb403638714b67cdccc3dacfbd397dd6e156d890ec93bdce9b7b0cb756329a8b57ab5f5a7954389f096116a185b390fc2039517372172335f9482802a7487993d5bd4f2806014bf872a1758e4b3f47a05cfefb3b1ddbcedfeac3e6e90549948f5298ff53761aa3ff94d3bfeaa394afbd94ac62975e6ca1be2678d0eae86a0a0cccadff07f4527e765662d42d8fc6cd3958ca5ba63cbfc1a093d674afbf0aeabd405e6639d8d75338b8491b7ab632392816975076da579c2c486527c3262daab951f8ee47f0280c8ebcfc9df9f5983ef7c5ab2b394ec40a41f9ea13f5d5afa76c66aac0806d6a0088eda23350cf59a6f1476ba78d52751f0e56d75153efe2db4ca6e2141a960ee52a3fc0026395b39b8f74f03f0980cdd3209254007995b9f16b68104ba78e81bc0fc26e130b2e1593275510b1ecdc54b6ba27210472d784a609d4e06428c9e9042ea7a39721701b7887b99425b995afa4b024d18e6f37ccb32972e146e7749d4df102e460a1aaa1478637f79f136489c669b52e080f94041f3b555e8e568f1aba167644b09d4d00b29ce84ce97689bdf55bb4bb2ed02ea73cfe9ffa77d639a3edc512a4fd60b736c557a4be101ae7f77068c1c543271551a03cdfb84556fb06667de5f88a2bd9d06c71fe7f5f55a63dfe3321aa066e36cf6a3bb4ae442fb7cd745f6381f80324e80680748f041142e4d50a38b6604fcfa5c376d16f8c64da217e173d93c26b96887771bfb7dc4d59a060d19d9e09fe049fddb8b716088b9fa6d5b331a94deb5da163df89798b528098fe1ba07c53c388997b1da7bbeb57e132751ee9ab50eff8f7385e3eea6f2b13e4ecefac52e00ea7b1842392f06d84a7ca45cb47578e57eb1d047204d92191fb4ca588620cd021c36c151d26f8887377ea2005fdfd8bfd3b7c52bcbbcbf17ce877d7f3f2c2b8c10d6f9a5cc7ec8b7856bcbcc368c228798707281088956dd8a302e423fdded6258d12ec5291eb5d534ca1cdcc34124fa446e2b90ace63679932d2e35400b1be32fea9a45a86f2b489fb7d1c0e6dda3f9db6ed6ccb9972d178027d0a2c652d8066da3787eda3dd656c9c8e78c0aab4bf8c412018d6d42904bdd165c4fe75845bc904edf1b078a709ade09cbcfcc8328c93e3f81c78d4cfa261f9132f80c947b08bf881e8bd9174f4923cfc7bbfa7bbee2870acbcf412d527f3ff739b5304979479cca5eed473617460600004c4d47fe16adc6de38ad754084c6dc6d51d56e769ff179c8b125110528d286611d8e689b268b482bd46d6d47eeb06096d7174778cf8256367afc4dbeaee3c767c7d8e97b8b9c11a72adcfd1f93e6771a8caf4303b595e62727ab77d9ca6adb70000000000000000000000000000000000000000000000020a121b21272f340274605274ebfcf9f39866cd8a905c7a36a7b694b61592f74467e3ec5a5b83211e6596eac279a01f0fa6fcc003b019221e7e543348edb5508352bd3adc2baab994c80dd3ec306be162254d3c55a78e6ee2bf3b4882d8c7abbbc38086d87df7580ab24b2775a1e6c2165639d1c9fdc4e58ac9c9d7821a0923dfca380aebef74e907efd889caaf0b9fd81249ef73b1379ad5899537e3659a0d65d8a68180463dd8e6323da5b9fdef6ffcfe551758d069b0d24760d465abd6cdb22adc47d00da8ce55cf15ff157b8164717e5e8e2f77f18eeb1f90462829dcf9cb043dce757215bc69a34f095532e5946daa2f5d1d96deceb338ac17d31e5bd83312052fbc78dd3fd7146bc2a21ddcc46666d7557194a13313165aee283416a0951ce56356438ee5a4f69fe5883ba94ac10797816bdbc1018c22090a5d1fe03b4aa8d0e07f265a055a74a30a6ec853339a1e4d78c2c5fd78e2024a4cd7ab499a9fc11b9ed413bea12d39a35daedf7366619a47763e29ee339fe4c3b0a21084cb993cdf1d6d4fb55d1c2373b98ad8e095131e5b70dc3aada9df54220c01cceece274f1133bfc804911b0ecbb8d5a82e8fc36a7d7d776fc8c2fa4adc703794c5eb61c43d95aab12c4131894e85e283fe5fe96e791aa4868e182c847d3b0277dbcc8a602396c1cb10480c9030eacc69e240004a4cdceacc8c406975ce4daa429d2e59e42698777f632fdd72063938e93a97af6cf4fda1606a4304e8d0ab020f9a22ef48a1b935a494585c38adc928b7b19fbba6d637cb53d9895d7dd8f5ec58203e643cf202631d32bb9d3e765c8d03c16000e2a36f7be7add30bc9a58b258276c0cf6d4ce4fe30e984f42cdba5d0b78008c2ab0d08a84e3297495cf565dd0a54914a34dfae236251eede212a54a81ac55e63271baf8340704eb39655bdcadad76c012d2f4111afae7a2b5131ce9b92e5e0da71877de92daa0ffe5408b01c19a3f1a0a94aaead66e3f87a65fabaf7950e01ba97ea03fcd81a387f16749069084d9e88bad2951431b1d09ea32c8814073395e6ce1f6d27668298f8f2e410d6433dcca94ef8657499736d29c6da6eeb5d70b8ccd595a28d49d6fb55673b1885aa9a0ac5fd207acda95142e73f8155f52fa7d896772d91545307103f5ec24fb2279acb5e62864c0000601cfdff638f6a90c820abbfd255f255db429e9c1d9a1de61d88e5a45f077401bed0c26bf66b641440aed3d94223eb5f46e6066653485e7d3b5636f33e8aa4a8ed4ef4e71446298a2b3e3fe0e6405ea733183d3b9baf495b836a0a97356719db097006a47543eb3cc4d8b3dc7c48b867d46bbb423594c8b3b6fe8839a73c6dc498dee48d093ea593f14ac0ef2199b20671bf4580292fa287a92a4b7f93c8fcda3663253b0502fd9df1aed1ebf9974b78885184c415d8aa35f16ae33101f7dc54992d8235e7b451a93e2b0c1e302eecc9876b15e03aaa44ef41495bd3c123e71d0b1bdd4c1ac29b5e5eee6b3f377b874df66d36fde0e4952f17ce61c3c271d394ceb74ffd6564bc2ac4f81c4e3c83c9fc2a5f09a53f6874e3e12f99355727ee6f9d5db23ce5c0497fd40955ba9fabb3cc7c021e9b8181aae8b64b610844c8e6ac85d759ac2527eda791c3e16de74f934f82fb4d60bb07b70778f577ef2475a228457de41df8ea7caff0f95e19ffb1aecf2221ec056d1060037195fa4463299b40fce3b08681ea23c7288e3a6b4b3f9d7142b6985e80c749f81fc3c902905746bf5b9744b85fccf8b02a55c0118def1f3848db6ad50ce1bd6877ce620cd4c3dcc7180de0a8954b1f919b5105ea6cfe2b54c139f86d2d36ae17163cd9bac26893a8c3774bf5373b8300c237c9bcfab4c8beb13633161adb44e7b36d6cfdf7fae998a8dfe5c8c06271d68479275b99a63a78b4d4bb20abd42d71035b418cfb172e73e196e7daf9f45c0b169205c0632c3070148ad4bd137b179482bb022b27d9292c29524a8e7fee49529767fd4ed86be9d30622d799e82e5f6f61292b300213bc68fb22df50a0a0bd5a42fc13ee33e4ab2a406f6efd79e4d6198657d9b5facc42cfbc25341cd88e3c15b3f4645790dc059d637aec4c17fa8a5d65c53837e143f2a83414e0a1edae02d55980dbc5d92cba969a444d08bf4440afff329898f0e25b01b04133033588bb0776903531c849881fe945150252eec6aa73c600d9150ed0abbb3153bcf7e6afb64258a6619b76eca75d3689cdf5f79542581784705ba0ed574b08b1b9927e897fa59d1f4e21fe531e1d50e893f7566f521ee4f79f2cbd831eb0626c322de7104de0fe5316967d46e55fbea4165d679dd3f34e3a33ec6665b7b6d91eece5ad90528ef917f667fd4ee33f497fbb9f06387b3a56deddd3dd4371ea71a1a6a99ce8ea03a84cd307d32edbe01f764d141c8572d8f47de9cbae4c5a27ab0cf103e80f8bd795c31405da8fcd0401006f898596371c1058162a0b678a55e6918be8bf3b8d59b8888dd472afb0f16af10e4cc9d42dc8f0119426b77fab14a382a7c1d1944e2bacd74d1cc6dd0746046356851ddfe161303627e7094ca324e726a34e25c26d1e71e73c32620c3c038941f8edd37ee84509073622a0c0f364856da3dd11e3d9fa9790a53fc77bf433df04895971a8733856b1b333b4b6943d0b40a7b3f57ec7f1e06a6abc216e50049975b30dd883d7d33b6e409f63a7a7a25ad47fa6d68853754dd101657fff3da32950be3a79fd98803c4d581471be0c7094b848cf277e0e509bb52387cb2791009b323c129bfd74e905d4e794f5b9c3dcc04d836892d55e9d9b68c1e650f3e3ca7b13b9c717dda85e26a40215883bbd118104e24d1bebddba5c1b055c148394dc3bc5a45a5031da74c09f577a6bcf699c6a75fd7b8b645d51a1a642de98217d3ebc8b00035fc1af6fd69895934e45cd9ec63297cd3855d49e57ec9856c465eccb2a67b20693d911cdd0a123d5cd45904f4b10f7f389bdfde69a643fbd411b5ad9e8df7997936392567f72ca9faf43128089925b65cfface92fe1065534de9d8b73b132c6791ead12e09224cdaeeac3cce8f86ee899507bf88f81f860c7ed9b401c4564c4c0c012746ba0eb1ee9612fef8d4f8dcab0752649e5d9251f2340abaf6c6f152c592a256b884e5a04958e38b0a8b7fcf429d897d0422c2a7f23f56325c27f8608e7112ad55a38562791c6a8ecf8d6077603d0112183150796b7a8178f474e09dedc4348649cd351bc886e60e2c916fc0d6ff7506158a3b0d9c6472a73d2b5763880361e0b924fab72e66e352996ac48ba05e00caaa5622ff25b438ecd58ab965a0752fbbe522a4b75b6b39a2a273895e18acb10b2df77c99088708456c16c1432b52c5b635b18258963189ed53a24f46d1ad71038f14ba326949acb4b73a9f323bad449e096b6d7fefda04cb28c621d67f0ac4896d538ab8319e71d41668ea0cd331883ceb1e77fd74721eea52b9c9d79c35b38d4d484ca576f961fb91d77919a3ab72cde3a4809cb99dff836bd0e927dbd92231ec774c0878f046524444c6c3638bbe1f3ef537cd82a7cdb23c8f5f7274460d482c72bf1bfda57ede6bda65d6cda55191e4f9d3d700e82784014864065f9f44c0ec4850ca4536e9dfa42ac560784596c764bddc1ea10f6ca4b424b925d7f61429fd4e8555fe2eab0b5d792880ae3717e5fa5545dda73b422cf5fe435cf2555424fdffa4f301e7f48cb725cdd6976c62e249820c52e756d00d89964dfdac3d21e51db0956e37b75e85ec24d26620a123792ce2a97980035ea81619e46541d38f859e534fc197eaef37ba601ac9be1c9948d1aa1076da38dfdd44ff6cf2bf0843573f79b45068af539df8b149a969b4239005edb350f83336b194881b63eb0b65aecb9c9d91fcf2caf3b3b4d0d2b0fa260e748c9af666ed7ab559678194a15dfadb7264b80f0911863cba555ed3d9b15380d7c5c3270957b18b759c95fe07eeb2b24bfc891be017926eeeb6abcdedb66d23daa2efecca0b44633ca6dfa32f4c2921de6f7b6ecdcc5fbf7f5e902d1f03877ab00a90027ab91509bded4fb0b691cb56e1df9194a5ad809af7b469a8ed8dd48d33f2d242242e278729f99dd3ea166b671efa05d6eeee402e7398b64534df8ee3fe488b60e52c72f0ffd30cc2f575c3ca0814db6f9759061f206deff382cb268fef2bab4a481f77cb120fcc1036466774c5daef38623f83d691360533744dbac2fc439f94e9c27f5bf2d87c5b91249dd9ec607bc8c7ace455303016c8721e208dabda73e847815ec599d769de2ac0eef8bbe053b36be7660400c2aaca5ab446d0dde76529c9cd20721fa2d1b3af27b90bec2dfff65d82ab3c55830881920d28b0e1414b19792cde40e08181aa0f8c1638db2c35694d5c9f78c103786bbaa45f2245221f688d6b16f6755a6f5b300a86a0867d4a5a689956f7c8a2bdafe28a4cd60edd3321b183d2a2947cd4ec886427235716661170ed675a9ad656be422d0555597022591ba3fc2b606d7fadece6a2997df03b28155568d69633053f03a275d0273a05acacd7d67bf5ed9511b1820b76bb28b8f170e7bfea13e8b83678b2659ea0d59b1ea8c80e6fdeb7692cf125c5833d0c23b5db0e66e7a11ed9906a962fe5651f7824fcaff5345c2fd289fe9e94bec76c4fac10988f994771cd1ce18cd095ae6b66b944bb691821f6051b54f3c744d8bb79f3a991399e037b75a6b72962522fd6cecd50783d78f1cbf8c9398cc96245a76ba854346b170afaa4d5fa0ad00a97d8e1d0c67d5accbbd2c6e0f4fbd7c89815c63062652e4d66e44396bd898549de29ca5a43d481e620cd2b2de0e8a56ef1dd797d1019b80b06207aef221f7b7adedfcafa9a28992fd2386312a5b23f596294ac02161f4bd87245ce59b5768faf5c30167c93c4722166faa73bcc713ac1db00da83b70272ddbfc373bfd950fa104eaf70e2f05af8d1bcb2995b665a145a526c7180c7f62df0d6deb4261581a3465c3647dd151b3e7eeb16fabfed5b3693e0287a73b44b691b6d5c3972042d922d87220038e499b96d236a80ce60272ff6a42e7f74291c8c1be01933ad79cdd648886a39198ce04446c3956dd614b4ae542b09520cd6fa9577129c90fcc25c6f35de500d65113ff29fe36c8f4c43264bc111118eb918fd74c76847ffc2b38554eb02f0189663227e531d229bf94c2a17e94060b175632596b80fc440f3028f1fecb92713d302054e5adb9bdf0cdb03ab98b5588cbb1a015f0ea27a776dd23e02ec7628b2141735ee98cdb22e55f2d71f3a825818873d415b8d0564d42094f4407ea44c2739d952722e569f324428376fe9f0a42f2c82f23a62c5c1e052d474027a0ec7f1e00600cd1d1b2c9e4d573756ddae06ad22d53734dc78c9523439316830884d7e8fb614b7d485c5e9f728b19cd3228b873d59df701b204691b23fe82c487df764fbf7c7416ead8e0ae5496774e2e1f9a808c5aa1b362f6b71a49ea5368486781820d1efbe004fa759820af8c10b199c84d1a18d08e65bf7a192a07b88b9e33198bd8176d19d452122695c791939f21f5222314d8d6e8d9cb8efd8a26a67c26e7247a77efa46af5e4fec7dc1ee97e82bd9609f2178811463c97573648e826a719e6ab213978415ca758bfac55a2337347c205e85c876f5cdd0bc01437b4dedd4530f2e182055ae5728295efbb4c157e0aeef580a8bca4868fb57586606f3836824386efa0f423fc3da42e0c70050ffcce4ac71789fe15706696d270363923fb6e2ce760b1b383be78d6bd0f7c9f857f0d298e92c5fcdd30c8348417e593c651d6300e43628612b3d2e6b013429a163478de5a2eb6f38b7f67aaccccfbef0e6cf7bf223d68ca16bc43505a293f71220ed3af3e2588cba5f4e358bd5fb386ea017feb805f8e0affb621fe59b7c6cdc00c63d3afff03ba82830ec2631fb49ff1c37385c615021c993bb1c3f48836d359039c23a0efc0db4164b4c58adcafeb46727ed01816dbdf03e6afb53a856ccd81e02dd45b01fade1a3abc14543ecb5cc8993691507aa8f8799aa9b1607d62cb79003a6eb37e56a0413daef014e06adf5b33d1f272ecb0912491afac8cfd51e31ce4e48836315d68f59cbc76d1447b219beb43f83ee8e305712e0733413ec62ccb9eabef3a42da78dec4ba9b10856baf8b2551d58a0f27cd1ac85fee5fb8d8a17ee9f4a6ce814964f567d8f977264029c4a45e2948d597e9b329210188a42117c11c77a98894c3a50a103229265df795919ea84e5f5573bdeb4d5739e275484b39cdcbe51bdb0499bad4e6057ae8a3d7697adc3937828c02b86efac366c88dc8eb49978294d0d02955aa76df6e7324f3e0b40263e334ac9d3c391c5a61fa5e91f047ab58d834feb865bacf8398491027be1a8d23a5150c8b2a8bb1a44acf5c17a6b7419a3c7948d570c7a3a665855e81fa781a4e4225cacade56c46339e27ce53130e52406ff3171f3cc79cb5eb8c5dda8833a712d1621963c67facc7f089aa0f04431da1867d994aa4ac3577f84ca5296d963e739b1293f34f1507f2f78be55001e2b47b20d9d4a3e40a1a5e045c9325276398a03f624db33b8407b52a82b2cbd608f39eb68cb4a75ec0fa220409fe9630845cc1c0ec2dec824e5453617f4e79af921f96d2628ab29fa29b689304caab9ab1589858c21adbb53d2c2c81c750be0d1e876696a6f6f8ae11106d1ef8e610616465de4801570038d8f79b64bb9fd3f8d3cfe604f1f73060b7b434d7359d8dc2b8442de3fc2eab6d044585ec293366bbc22cfba0f80206a2a9c4a552f2850178f1b9cd6a167bf0834e3064b64ebb8ce31cfbf94c66ea4103b6d7da98151bc13873e3c85379a9be9c7540a0b47e0f49efd7179e8933000786b6186e7e2de5fb0a397b53616af23c8fd4bb43967ab4db1422e70e3fd080afdc3b5cbdd1674c0874b152c4b608fe9281b3f760bf0f5fd5f795b7865c32d5d8f8c5fddb78d1bc7580c2ca0a211dcd411376245a20bf5ea13bc2ea2b3d0a8ac1a64f73f4e1e0ea56ed4e2972c1123a7691716d09379f98da4fd120329a58ecbe04cc7e117331fbff1b2e3216bc8c0b4cf5882a5f84cd6e11e30f8531781e6fccf566049fdafb3a9a506e0ebea422b1d9422151914f993a4317e3b032fa98b33bd22e6d455158ff19a9ae777eb013ef5bd91b6fc34820b9f76bfb386bcf6f49eb2ed4c5e3cd9108605e52af57408ee25cf250cfd04ee9d12063edbf40b56588708383086dd25aedc918240bce8e506c8fa97f971793a6180aee9b0109d61c19da061eaae611d3bab8a0677479638fe2bd9cf0a07ae44b98a9a1036dbe388487ee0c59e779c2e179579f9792d3229031a43035bb11212096b116119758ba4a5038fa4cc916fc0842d63749a0e95c0aca288d19f64d461e69c8e705e16325edd3faa215a55bdd7f9959d46224f98d65f50274e71ea8f079cc258ef652050a83510f2e5c24054531edfc6868569869339a7f86283540bed1374a311fe9765b52320b695d8e0552ec29914deb534a6097db60ba1a07684b0749e6b008234440c02c33b7a5a3299e485bb1563ad6e3e5d94c666bc7ec26fe13456b769596e56add91f7dc62490c1a01625e7102873abc2081b69eabfec9acfe6294ec6e80f8237fcd556278f871101054dfa9bb60033d52cc55129fe628b803c4f448e62e61e40697afd814322a843538015692bf5e7d61b19d31f53afebe2a674df941711fbdc0c31adeb3f59b6e26649bfdfa58a6b8401b46f0483763b89d05e9864aec9c25424c989c438404f7e3ec8733c7f4bbf0e6f7480445bf200600312acec190772086fda5397ad2e45f7fd635aeef933d1413a5de773830ef8180ccf046e870b361b86a8638768a57a5f8b92476dde0c150da411426c20ca65e238fae7c1848ccb0b4dbfb7fb1d88de039f4734f1d6c135f33bdc733d16a0c9f9becc39986e528d1b223887d9c61b6e8a800bc49ef5d5d48e3e6119a0a3e15d07cf2c5a99c05505f0334353beca85d5cb6c15c6c0f3890f5a4ef03ac073afb7d5cb2dcd7678a1d1ee15a8e344aa08e2619d7ba0ec8ee05aa4c473b684ca39d32d18b29c311f700bc307fcd2e1d0fa7eac380bab91f57e8104c85037857bf1a5c57890c1af602f4c98abd9d39127561e51de884631b7986f0815cd0094c35b9ef273e2e1ae22f5808efe75289e36f14fbd678fe4760cfeff6c52341ecf4138b2316d59a9da7d1c80a0a84a1e976f235c2110beeee38070e13622b7ae0c50c4e5fce2bbd3a854f3ce398ffb0e971ed7134833e333db3f6c50d2927423da640633493dfa00325c0be5600430e2fa2f34e76f0a2c3051f0281a28d0a42a090c8017e2b000441bb33259929bddaa48fc825f931c0f719201dee516069111fdc7a0f0f1c5f807552c36adba25e9b9f3f70158c42038b674e405aafd019d837d5987129998807f4b0c9150604ec3ccc04d265bb84f3e0413dd966185c5c0c1727136bd1739350eaafb60fcf2a00f43fd99ee696689c34f36e53e286c5cd0babe00b6914ec255a18f7a4760c8ce15a25a7a123c93a1f5cc6ee595517c454a1acb861934342b74cbd992f5d3f33f84a9533f22708acc4174c52df31418d943685bb8d9381947920f44cd877bd774f18351b5cb70f28ab3427864fcaf71a47224f93aa708fefcb34f91633964454939c0ea6ab4fcd2a8a15f7d400fbf85637121923eb86f60ff196350cbdd021a489a2cd0d6e773f12d75f719d2d55a721849f11aba0fd9b548c88e7e94a0096fd5b59c763a0d86f0f011eb8853c68b9bab9759222e4bbc32cf418e6ebd9f3ffdc1e206651621acd479620d412e36fb4c311cbaeef1547c0de522d57f577d334c230c260c86447baf6915054fae9deae7b1741c2359fb75eb79a50c3d487a52d834aacfacb19ca783b19ff8e9ee12508634dc83ab4eca6ed98e45fadb1c924971c93e19207dc39854323962509035d7787153cb3b9e916787b23f5d506dc7bb3b349d2e2dad5f806bbc6bbc1b19bed9704377a832064afe5e6fa65d728b6291691c8c61e010a66298a31a4d466cc8496ccea64a4d68a0dcfecf05a824de1badbf2d4c34d3f87a69168099aa396ed291fdf629ae64e894fa7062c1e799e5c0a5f1d7f33d68dac371e38aeef7ea5662117b0f06b743368f3148ef24ebe3a5f7272d8a480babfb434c39c6569708358074cd6df4170486b01b83dd8b9adf5f2f353ae359f179946a7e62d6e1250888a714f8e53cb771ddeb7961e2a50c1cdc6a853181a9a1c3a451c5b242731cee7b8387e563900b5bb2745910050d4c82cbf8f6648154e4692af5339c24713d6b7f54dcc1b631e088aca779add6b4993fbbb30a6251eb067a512959469aaa46f53db20fc823631ed862478b32ee0f785805801374263b8129dcd35afd239fa26622d30f27dfbeb896ca5c9a867f8e789eee5a0a9e6bf5d940b9593e10d7e605313c3eabe4c8704d5760b24ce1cc42579dadbd514bfd44aac7ae4c6a655e8fba5f63ade575581236759da5fb1dc2f834a019219b49405bab3b340159a331443d081b4bec0a4f0d612b220bf9d3783fc06078fbc4d00c98c5df3559bea4a2f41a1c89a4bd2b64710d5d32af76be50f8b36cbac9c075650d0d88a282115c7dc21ae4a9e6201f9bcff58dd21cbd658cff4376cf7d2900d5ad7c8f9ffc0d35f36bd256b2da423827783f72fb9fdc87707ba30169e7495bfb33ec2800ad1f12329ea44a745acf75f895564cb79a3ccd006cf00e91f1d8b91349645fb9104bc5995c8581e85dd2a9a2cd4514542e0bd062d923c8d17fe2e0734d76d2ca4b7e48bbf13234b625756fb29539ee518a94b91c97358e32cad803d277fcebc26a7076c2d83c865ed3861e668fd9535fc020fc03bda4f300846bb0296b5a5a606254378576190dee4919d6449d58da81b913b407493b8d634e3c89917e01a8d57cc48e7fea66f6557231e690a229d4366ee0633c42e5d5789bee2b208095452055e765edd9b5ae2980223dd253430e6c261a9aabe383f162fef713424afdabfc662d8ae2f7cf7b6ffa096f4aded7e25f3a9facc7b705b7493ef96218012e3e96b0bf8b447fdce105b22a0cdd1b7ca0793f3d74fd2339680913760e41d70fe01deb71e29c52b9e1cd1d919d938c171dda68cb7a7bd56ab1e25ae99325a7985db6b22a836a942021f878f07f1d2de20bac7d06394c157eaeee9cc5688fc6756ef8952b29c7b12bed53794b2712d92a1881ca00b1e4fd650c6ac981d5f9c25dea64d3ad76e20061bf8575edae06844f9d12c60c98641d535a5110c50fe890f474e24846d0d6c68e2d26cc3a18530ba1294b87369d78a8fdf899adb99ed0cb08cf8d03cfa20597c3f4f6562fe98d594af8bde503e16d2dbb99e6da6d5be3fb36b679f0a4933c785725d2806129d6760a9868739c2f2a15702d8fe836679662b398aef9f00918a3836da4fac22ac51b786276b9fea79dc8ea30a997ec9df7d03006d875fe48b3cd9de98b26198b50b9ab13e8d136700ec94f3e15731ccd60c08d73f9904507c38a464c45f040d48e9dc70a9ecf22cbcfdbdcb7479a165d0ff6266a3c48090b474a8a420b6f3ef65669cf775e65e00de6e69cce201bf3a96b2dff48c729c1a679170685b828b115e152f9e24aa478201cb284fee9fe5772e47699f80de2e19bcdbf18d36000f1a1d87bf9b6dd5863059137451c6fad5b393ae4f7cb99b43161c173445ee997604555d285bbf29571b9d814167fd3829dc234b9ae11fb6215ed2c98b4f96633e9cc07964a3f4c68ab008a7f70799103fa02142be8f95645f713b678dfcddc0ff60cc7576ff8185f07f1cbee86a9a5a508252eaab8ce944d763edb11221d0a981ccea93b7491bf7a60961f658c175f864547ee90f6ace48f179470dec190b9eb866a52b811574b3c938888167281df5fe17135f1c3eb885faafa198c71f6482fcbde92f7d8a9a9f4155c243e898cfa047e2d5dd97e2bd5c4a67ac7460b826d12c126ced9ee2c9cf99054af1655e836adadd12a5ee1408ec32281765774ab3584da0215fb3b399605efe4d3ba11adb1166cde956f72b5a90cf8deee20b4e7ec06c27d422447eba18405ce44af6967cbcce21199443b0ff448cfcb7b5ceb300cf1cfe4d054ac349c8debfa874f652251adfa666ddbbbbfa0de48e22c461db8337cc382d31b3fc689ea58a899dadd5770e830ff96a44defefe8a915848ed3aeff4f6ba36c2a0f442c4fe52a2edb287135b12f91e66f14ee65d8bdf8adf8bb3e686d1500964fc5aa52fe652d79ba67efea1f5195fbed776b52ae1029326edf05200af2c16a452f199f9bde72d9c1c1ae8c5b1bd4ce9e9684d87611e7717991a0f3d7173c0ace3029cf72c645ce9f3aeb8765a3382513aad3a2116c302c19e9b06d7949cea6e38d46fdbdae22540bfbbb096e4f21bc6f44265983c1ff2b3c428e9a92043c63855be8ad228b8d193adbd89161bd85f5cf2aeab5e70454cead7d9d49a35cb889c04f8d352f17a40b540212e7c41b6b8f559b5ce0742672377f28ee9521af7ac2bb92d872836d7297de53796d36e750947da6bf88ba65c2a77bfc7eb2975e815ef2810cfd86159baa0b7e1f0937a2cadd3c89961c01ab09c7115fb23776e4be3b038339f3cdd9fa468bc6850051f86643d4d27e7a6d4423343867c74c59694837d883f758fd03618611a2bc5458401fad97660ee8f648039969a91505df14fe9a9717d0771db751f794e5becd0e5c3f9c0d844bf2bec4d1bacf231bdebe978c8d4db213eaacf170148e49bbe097664c94157b83f52fb117436d31bd565fafb5cc22951a8954f056ca0b6a02f5fea74dcf20d2acb3964c5db03c5b3a983e88ee32dac66d7a688eb4f9716a7c31231b9d7c488cad2dcefcf7e6de939be67978386cd59bb19460fefa989e81ee3386f185e53b223ee6cc0de2c8ccba6de19c17e483f113174cb942c46439228ce9d7c0b9da61a4c650ec068b55ba3a5689f4aaa225a35e71abb20833ba0649ac84c5b1fe93ecf43fc7b3ea4e502be35a7060384b820baacdcee61d4adf63ab7d65921131bef1ccbe897bc6f8caced4aaf87faf01cc5a95f35150e036393c9c2b2ae3c5b60e3b8e17d57145dad174e6e75b4b9ae0a797c3e6a534c6dace793db5d435f8f8be1de8ba9a70c3714b8f716a66e5aa1334eaa86ffc7d9c0de91ad7dc7f86a172c49300f5e0ae3c816ae142d371fbeed73531f72b080497fee1d56c6c09401ecbeb37536f1a2f30c4c2e9df94ee6921a5d1d5f2db9f7dcc5b74e84db66172f7a73967ed483bc7eb04a69523748e82ff2aca0968af5ce4914ab0e79f85d9621490648d5ed011ecca01d693675e26186e731c63e265ff4047fdf7b715b909241583117f82a17b309ffa0858b85c34f98916f2d94552d046e869a3b6f302bf512f4d8c13c8c3b1d9d44fd71278341d39fb1cad4e3ad9676afb85fbd730f0b3113a11400cf005012580c1605b4f933e1c3ec8c9a9945ef8435296475609d60b6d6fc7f6fddacf3dce861589eda1a3f3ee27ce6a488fbca6e36214d40ed30f7b56e786f7aa683128830aecd7e08fff82b76cfa134137e3a205c47179ff19384cc6eec35990eea6e29bda61b7c2ca3f338506147a9eb6f106e0934d8d3050973fdf9d21533b2672c4e6cf3de7c4a133858f54858c34e1498fe5bc13f925cf8a8dae2a02de3406e6a6a4a4b1e7d8499a34435d9bd349be7dfa83c2b1233c39016edbb6df952ff5b19491807abf6bd304aa354676bb84734550d2d94aa4c30964cf3fb4030a3dbd1dab96b80ec879dfc68f797ea8316043258ece8bbee116bb9902c6d8fd4204b36887d8eba8d8f07010aef5623288992a351c24a657a1b44003c5fdb0daecd47a698da6d78f33f3728bba3ec25a20449478ab26baced0736d0ccd7b5ffde6d5d65679790edf7e674b7fe6fe45b3e733284b38b59df43f02a69fc4ac84eb950f15b867c59552fb9cdbb7e78a98abadafc9b8098d3b8ab504b89411940816a724f851887c1a16611856b9af83240b7b7bb303de0b2ff65f7c1eb89e0596716ca7cd6ac92203973a5f130a13ee1323069d30698aa92a753e139ed4aaa49cebb65adf81a541d9494908b3e643e80a3f4a9366179d23c1ec42cc6829a3fd774b51d29263c651bdc40f04f43d4803df4d54aabd40b3c2ec5a97ba365a5c1966fcb8a30a2d0d02b476af3e8a26963a2f99729103caf364d5569414ab87ad212d9adcb69f2239919abb0c31ac7348f20c510d05ac0281e1ff70f796dbc47e7016edf06884f50eb1794d9835d3004533b1c65f82d526a9167839dd2fe0ef982e1d8144ccf7c04595400e482697bab86424918325c30359fb48557c1e247eac55f24aa9adc02575f6f57ab05fa6077fb88721fc9364ff20927af0b65b57991d36b35baf332bbf31010807b9c935ad0bb1f52db451b0dca3bb8cb6fada38dc9ca50149109174b823b27dd57cc5e65b40d9da804784da6d526f3a4c8151d050ecfe7d69fd842f62038b9ee1d71e99a51da3b073ee513a88777a14ca1a2b5b081dd3aca444b02dad13b62dbf07b3fdc49677e5a9a1d88055f8b7dd9578b55c2f2d9f4708ba756e54097f38d3df40f964aca02194d6902d0b7625eb1b48327b4dc587e128c5b7d867545d76007c6f9be4f5195e475a2ab01c25f6377329bc6edfac68e45601a189c74832c25f039fe30098672539cfc9d8e890685364a06f9672b28ddad841deeb89712a5dac5003878fac5b0afbc948a7a108929284ca43f0173e9b1085493a498fb80153b16ee479d540ad6fdc9dc7affacaa8fd286bc938dfb512731eb14bf2f432777d9ae0849a081622fee48911fc485137c997ae8176c61e1a563df33656cf766f6139a40606e55a3a8617cfe3630ac799531d3c42ac0282834fd2eded3806840e41514a38378bb5180e1c884f6815ba37a5edaba90ff66769408a8d4988d874dd9a4aafb54a3bc02145f52140c7d4bc0735c235ff8a9dae4f4ecb9b9a9e8cd9f1a6df0b782b4f89fd2f5aae293941c19aa872a54aa9a7bb8d1c2f4841ac1758971684a9e8d221bf2485f79ad4271c5036c6dafeeb3144ee91893a4761d32e752dc92a27b931e01bf232b9ac004fc581f6021974ce3271a4768c76fc7b23c3eb8f8a36bb32e16a37269ad1f50b426174ad7901522dd6319692ee7a311a5c7b47e1dd11df96fc288e2ce9d806d3ee8ed2ae43fae5c277cdeebd50d4a8f8ff89ca7ae7c7586bda0b094b1534e9092db0d59ca3676206043e2355759305884919afd98cd70dc4a9cb6bfd7d9eb11d5c8f0e3affc30f0f010f25c7fc79636d3e60c6f12a008d9981455cd61a2a490e26a6ddc34473c5cba2a344f930d0734064c03bac88f792907b3a6a1c88905a85b71b704bf87f4b3f0a22ee34c79468f7dbafaab3c477d6c81a417b3b09bcb865b835af1a44e6f2847a3e856d41d4cac07b1814e3481d8a3d3db84f3826518603b70d8b47b37c9a74cb5402f9c3d08d2ec81e820ae0d365dd9b68a1fcc0a1503641da629b6df5e1a80dad19e88246bd7933fa719a4fbe3b3079c0ab3a4f0130701b36182bd8244dbbd11126766e21a3b93d21f771535812fa4a210d8e9fd27688b28069b470622964abf96aea3953d1413902105447de51674d1b1c8eaf8b2361f22cff333aadbe375992a7cef5ff0d218532768694ba4bbb4e66baf32e0bde0fde4cf39b6f2447e8df2cf4f104b3f5dbe7dd6a6271884a4384cc8067c00cf818974a4a130e93449f42669e8c5a8a78bc38be3fa008c9eac44abd764e643a83bab7e3a1533542e648d044e7cba1a27e860cae517d3a33175a03e7d11e5113142a365751c53375110690d2f486aa01c653376be8d3094b3183a1d9523dbb70084e21998cfff84ac848f6d5afb184e7b7130eb2916f131d2300393b1ae4e6820918e32ab8a0c70094f4c38bc19fef2aeb053a6b6f7dcaa5b51d147dc77ace42f1e46b5a23fbe200d25f93456049ad6dd1777741f5708d16e1eea53308d7559dc19c8c19ed6306f902666a0c3d417a4df1ce8bfa158b8385d088fa0820b42bdf1c2b850907816fbf23fd3dce336618b4b61fc7aa94902d055e7b0cc53436a64a5b91c7545d6d0bd54fa5fa3d7fff07526345bb14cb52a2597406538eabafbb107c1644cd1e006c98166b2a69f4d5107add22af1c0278be78ea421634d80e916aca33f1cb3e4fae037323839ec656faaca02ca91bc89db4661d28fb810f8ee6097732d3cb09888b5a19b6705ef2828486772eaae73628d9231fa8250a8b7dec8fcc1c030a7a7236ec0f3a5742be66ea7b2ab16af972ed8de20fd2efb197fd0890fdc2df53305d402b68156a15b3bf9cd3c3936b28b67d1cc7aac42f1b2218e6837a41494e83712d533f2ed66700a69c6a7bbc467cfa28d2090d44f30adf74ade3e1e431d8f2e86a9c74ee307489af5efe831ae600390d51e6137b73e4f92d86b93d291739330c65db57d3fa6267f060b0702ef9f99204412cf91aa458bf1129f97fc952f48da77a792e8c680f9a3da5904eac7eda439a66b5c3b38c2abe9b9a028a25e70cd123ae730a1fc0d63ea101a433b96cf9710e9e1d631b6b033b6d234dbb5743bfbed6b0ebf7cdd733fc3df79d44bf4502432114988dd360d9d28acf2e7590029a0b64d50d87cc8a137b5e990a249418ff89bd902c9f2a65bcdc4735fa77b337e17ae177472fe4012a10080ef87ff7dad9343bd314aac9bce3a5f776e04e2058eef55913804d0e8229a98a7e04200433488e5cb655c8bd4530d912a08af8ee75ef5780762e75a91c80ab1a2dc39bdfdc9d8c9c168c45c8f945333ba91d8e150f10f3ad3ac1a7764e400752e29bba15440b4fe27cb1498d94e7165a863833dd7999974860ca83ce154b788923050f12814fcab048bff8f3fcbff9556bd4f128554266cd12aa9eff7b0f8fed87e3dae77bf397b939348c742645c67dd54e1cc309052e4ff3216341dde0a0fbc3020643f8e1cd31d8acb0346389af3a56567733144856348b4c76b0f10264e92cf7a582b635339d10b1b71e372cbd0857503e69a9179df893aa98c3697b23196c78bfcf60186112cf106a83bc1c532d5fcd22c303b1b0be7c7ae737ab6d23a8b3e6a36e8e2166cb553433b87229f3c412f2b6c98dee5e8da6066cbe76c906677a72d6f6ae171e10d8d66eaebf61f52b0d01eb3111956a300125f8163c7c51574961976c5ad00702dbc4982938ac771b6da2c3f0d43d595310b67ec39fd6a1538cc2a7ae2718f0996f1fbfef9aff9ba0ed1cd18226819f7edfed24fafe3c0cbc42acf7f75ab06d9333977fde8ebc228dee8f45114e8ddb13b7244fd82363cea4257b1172daba4dc544bebb05eac8bc6f350be8bdd58c1f1955fb30cd194c404c87d483321b23823b552161b4959104783596c9275ab9ec8e93b3f079d85e60778c113dc270aec1710e79b2a719e6201ca29bfb6d35ff434a427f0292efda7ccc6a79f0c9e85ce565c31dfbde85fca5b7db12290af4af8151976cb9b3bf39b315b131c3f404e3a31f4892da01ddc1e19cab8ee41abe9f29b50d7cbafd8e5a93580e68b893f98e6e6525fc640f8d401752ef59e82d300c0630fa643ea8bbe4dfde1804798fa7408d5a2e472ae96bda58b6dee25788e7890bec44684aeb0fdd1ff5281754d09c13f50a8c5132520387d98c89ea1668bf473c7f3cab3349623ec3c5c913faaf3014a898e7d73bba3d9b5e4e9fd5677e9a61cb509f15cd5f147348f40e2a9bf855bfa5c27dc08e586e9ab3576084bb309261d04108d417d432a72c30452a99d95b91c2295534d25964f8d8060cfb54eb9676b2bbf361a143e6c26ed7f11d8651e9dec437b738ed71c4934bf00860ee0fa9d929c896a693518e4a2a6e287415571e65def29bb08d3d9f814b1f2247c2ccba5b8e065b9e62357574f1a01768cf6e085feaa4e40bdfe94af6550e136bd31c2fa61aa44ecf8ba8c4214d5a266ebfe280fcb31df35d1faf94532baefce983ef3a807d496d10e552ff916b3dfeec5cfa1f8ea1c0e52226f3563752fa9c9a050a34a4b1f1e1d03261c7926a174e6d4ab33134e4ad961639cd4962d28c2f861d5811c66d0c29db139fcb543d6dd6c2a8a3f2911dfb3acd2aa74576ad26f01826aa1cd7598b79d03b92701c906c0fc94bc2d940f8b6fc0b32540d9c9960e5069a383e2f7bf97e19bf5320cec1becf3f34dbef42e73cc73aec3749c281dccc5f2dbf8ad2a016285d58bb3386593a1cfb9fe7b40d2e560afeac869630ea5b8f98abb3e483979d31237517a47481fc36dbd94011fffb4192e0f0d84b9d06d69051d624fab166e3ba79b452647f015086b6cd2e0e2102c98ece0bad622749f1ecaf68f0b094b94af7ecf8e83008e4ba20a3958257ce1bbaa31cff1b12dfcf30a0b9ef12e5b55ec12ded5bbb1f16a6983ee50c2de8dc10e6e01343c310d00b7981adb360090e329f2b814552b1064358db0abf5300559038cee10d584cbdf46b8bd5afc8cdd5e8cfb32cae355c7aa71f2208256329c32a65c035245f098cf80d651f75e03cadc6e282014ce0b1893e1ca16488f56e4ce77d977af1ee0b0df9ce4ef6cf8d1029970b48955bae1f2e0cd9984037a49e02d078d949e7197bf0d46b2e6ed3e67b05e28a1165fd6868fd7d0db420e3ef9c6b057883ae0f4ed8185e1cac4a57811f22c8ba62921ae5184fa7d4d85506ad5d57972fe8a611bf241fb7260a1f19f38643dd7d9ed6da1d915bf657f0bab56aa6c9305490bbe55de5df4c2062e51a583afe0ba8bc5b56bdc8981ea0ba5cc912723e48f8fbb6c9bdeaa255ffd5db6322dab2b23dad3afa5252a8a159563beb135d4cb219314edc2a194d196e4de922c4c8872bca36c95e2ef521f7dc557fd6faed34674afd303ee597fa398117a1b3b9724f25eab8215c86e78584662ac8f9464a81264b2f58d599eb2ac5da468b713315c1dcdc6841e84ccd6055a8729035dbdf2f7f8c92467cddf65c3de9b608f06697e90eb93e79780c19261ef94480fc5fc2d5bfa8dda5df7af3a95f5c3967e153e5bb53daccae70249ffe4b25049938f2d8c1c36e11a832bfcf240a1f0e60704b08c590e3083fe859e0c2dab307a95a5083d448199985b3f45d56161214dd5230940bb852d17eeeb58870e01c9455ccf827623ea9b1c6b9fc94f721bf73170c93ad899d69b0ec275a7e1302911ecc05f21a0b48e305063aa737f05fd5201b8a39d039174f6831c87ef90ed3e9401f03ad3eaf22a6a1e0376638965a1011e158755aa88c28e6d56eb9302670b9a67bfaf81b2c108e98809cea3bd3050ac21631bf39d60e497becdbcd3ff8f29392c9f4b4f7163485d7daf43531bf60d06a378b843ff387edd3eec5669a7371b58f97abf92c0e1d434884ac8ecd626b8ee5058040b9bf8331c40fd3c425781aa40d3fad72d23304ffa7a6a8ee4801a19e99cc48d8fcdd29373ac352062054f74610b468d6627154d57afa34082708e39051e1b389dcf9096d762b370c2788b1ce033c87f52d90da606ee0e8509a62be73f7b2cf47156d8da429f9f6791908266763689d3fc3c3b319085991a2b56be6ed017a4943e7b357a7123304cd68722e3c4807fc16c2c2f00285517231896aabbe55fb2d8bc90c507eabb395112e9cc7a333ce84115abd12c8e03fdbb9da6185cd5cde113acc5bcad2cd0a81592d9f7c285a3c0beccd34cb6e1142bf1eeb01a6cf51f2371b1791a738a438cf8c36006ed713374bcb6f8cf94914ecb1c5c8505439ba0f8b6598fb48de4c7407ca7c0f51ae895fca3068414f3cef5bbcae5dab7713da5143c0ea82bc14596d030035231b352bcf7be2cd1795b14aa2f8dbed6ed09570a501aa0e7300843929475f90b8c88b0f8291ed5c999335bc6c2113cb88e556feeeee9e5266e9ae3e2d91460dedd7276fc0504d66768f4aefab1f5533bfacf119596407a7bbdc28b811a8f573ec4d368924229ad214a3ac08b8d29fa563585a401ec7cece511b5e65c38c76cd47a28f8dbe73d39e7d27c4577eea07d7e4021c0eea8235c54a69132761380eae390bfb6954d75da895eb33f441806e449a768a8a2d6e0dee5446fae4ce8f71671c70f88f22166f294c19cce5eca1f103fadd51dbdeac4d6c9f5d32e793188df70e47132d012bee6c8e03566fea871dcee4077fa79d85dfc54751513a8b8d2b842884db3809444f2b59a305ffcd6c16bb1616ca17701bbfbcf2b519ae4909cc7a9119475d80f3e83d05a1571be5d919096e7198e93306c339e2ca90fc5f62aa1bfc34ce04dc7212e5da3e1ecdebf651e95d85740bc18566953f0a4a5244eca5cc468de21d9372cd75651c4c9ba6e483a0498fb4f556a571afd0cec165d85525b15f9ed0a1e1e7fd287ed6fd20babc1151bdaab9077e126d03074312ea33904648c314e77a6d3092872e5010d4ca70001f7c539b7695023c09544d2ed4ce048ae4b01ea5414c6bf5d00cec0d2b1566c762fdf725c6067bb406a09ff5f33f4b6e14c651d13d9bd2a2581705da99d2811bf6b62b82b9ef47c04bcebe2da0c7088221eee98245330d1b8d1e0b3d761be8b1d0ece5ddbb6bf0bd7f038510c5463f1d949b1a16658829ce1538de7d51a4a8103de6f60e5584d98b724d3a20856fba39cc049fc7fedddd13e312542435e18e4962beea526594990a59c92a7f5ac05322faf5d588bda38197362f3775465089e77ad6ccccc9c164ac5d15943ae9110842ded8b7fd6c7f3e67a2c3145137a38628b44f2804bb483d6d732912ee3d531f6646c20a11a37955838a8c50431c1f637082b2eff255d50fe45ddee986794c5c90bd7dff2dcb8c6cd6d36a786631ddde9435dd642c5c42a6529163e5d34ea9894c9963e4fdce891764f69200bc2fb0518e3d4ad9d6a0b8aa30eca074698d738c74cbe466009c4dabdc8d27a967045e407361de1ba58bc48d0d0c217f7bc4bbe2718108727ee2039828e19d8a0f4766dd5ccd45dc68c6912d7cf3e994ad4e53b5a7d0462d6fdbf25ccf13b35847114cd08f8ddd93ed1da40fd8968226635dccfb2ba02d32620e9fceab331c2df251f77760280fa9d8c608964524be1cf4844057a3120f1cdbae2a23f52d6fa52ffc46452449f6fda4c5af334b8598ff649366ae01cb5b9e6617b713bc97c265a5b26157059350a43705804da1847894b5e392c08e603c2cdd9ccbeed3e8b41f8af294ef822a409048b62578930b22638167178f99f7ea385bb58629348de1687ee5747a29df4167f9171fcdff2372554455edfc902ccc8c23cd40ed2201e243c36fafc02a667ca2fd6a234ac87c7ad01f0f902de868d2924d5377aefaace053c8b3b987de5b5608a21692a77e297f9d566310b0ebd48ad2fc5e75818f2b15e136cb1721310aaac7deee1d60ec4be0bd95820debfdfba8fe91bfd0cc43d0cb6dd66c2103634116752243090c265d106a3d2cc4fccc9d6aa1d07673e4909f940b364a11086d82b89025d85900eb7245414210d37a8b8341a0cedecc9ac68d2f1927a3390ee740b678c3fb1e14fc584106d83be3bc8214da3458d308b9bc2934a6d6a05b353cd9492bd58406a84ab9aa808f60d30d8cf0ab90975955d7795f4e4b6ca0122431add26778380244b0903cb41a17eaad67ef349ab12029d9acd3f6a1ad85b6e563f1d518833c5d5e6c52ffcf6af0993478b9dc40873cbf0482ac9b18a585b7c1513b0de9d1f07d4c2abe77ae145f57ed3212bc1d93eba1a7a3b6da889a558221db7d010936109f9d3f0895457f239f3b8c60a59aa0d4a1cae5ca520b759a8bb713f21789e0b794fe4399b7fd62cfb2f480c105d9858a380d1cb2cf1037e04707370f2885b598a28b0926c884d795dba2c47efdfd756bd32223e1d2be93b6d45da1c2ee0378124847e4d9faaf1ee6fbdf7a73600220a21c4e573fc456fdb3e01fb09cbeaa96fea8f6c8b9f908201aff7f52a5b36ac39a2a9fee5f736eb161559f5b64ca58ce51b418667de25e485c8dcb820050eeb0351b6d0dc106822eaf269194bb83438abfe99d835332cbe951a47a54a5d73b7b5ce1c52d73c1888349e7552b2dca1c23872084b71ea4c760d85179e1732f9693677a84f0a35de1bc42fbd249eadef653fa3f8a62d04176e18ab926fb6660c08c3a28ecba07a6e5d8c8c4fefe83da3f240053de864def1986151f269b8f90cdb8a0cc77eb3e7b6af4ddedd4f3f972f5da2ffcdea856a6f3136fa630000e12b2cbc9fdba969a8efbcc726ebc0e59d8ac3b879eb252a0e85baac2afa06226cadc7ebc7b145112b243a041f7dfbcfc35c842f6f615eef22f61ad0a64a3ac2b33b34a3c4f1c39ce1129da24afc818d72e1e7ce5bb555bfab5e7bda8df3c45f82d291a159245bc8406e71bb0a3c64eaa182a74fca65db393d356fbcd7fc2dad1a612ee46652c2529257758feabfd635f70f7d4b5ccc601bece2426b2d06cf09dc5cb0f18a88b358ca44647cbabeab048c9ae5f166fbc6c565b22b119b7455df14776c9aa0d83db360c462309308dc11170a985218ff2a18c9eb9097664c9027d5c92f654478cccddf51e5b100c7c4aaa28357694b9f95af0e8f3683d96f49633a6bde253364d6332034edd7001aaf91b47ed2011a5eda34d50e756c75f68842851ce4a549e4ad0f41d18ade695f15dfea9acd8f1c6093478865852c38c9ec96dbc4fcd74ff640fd381f4716a1ecdd38fb06bb4fab4ee5ff694869fa83d2449cfb6dd2d600d19a201da1a48dd144d090d82bbcb85683887cb940c7bbc3adf21be76d8183ee189d0617995df318e06c011f8e46a59da7df15ba9f22acbe52a676d0d44fc63b3a50c99157168e863bd99700f2e549cd2d954766a2926138837a1dfa8b79b8f1026f9839157573e177632bb2aac6c753609ac08eb28845db867460f4d6f097fb0e791c2761de893879e5f2d07ae6a915d4084f804112ff48a82750fcc151495ba65fbe360e30ea24c9a1ef06fbfa07da34ad9996b37e357e8153af21216d300f36bf437db538f0c6a1491af33f41ae8cb5011d9483f596b6695df039c3070263e304b4e636cfc6f937c72b229d5bbb62e76fe310a6a7ca74888bbbf146c345347c943439440bb314fdebe29b80c4da871800a085b5e0ae5a26e43aca059a888c90aa19ca292dd195b1df782ee9b315ffb3ebd7c7dcc0a74000627236fa8923bf33315732c1fbe47b7209564fe21cf7080a37d2dffdcbd6171336115a6fd0b3375557bc73b2adb3d128a5fb9f9f1b8d024286e81d455c1f1d6b20c6089f08a0e543fff23ddef46b185aca371b7e69a38834c593af2bc06b4d819b7dc4ae6a78ed244ebf3b8516deb975522b0edddd8e75a7bd7a79888739535a6f5bc5646a82bbcd902f70d4749cb5e94d6a7ea4dec2eb03a35b52767e879ca5cef5d6a2d0e9852987464968b039e769c761b787534ade72a4e6316e0905c7630a467fe0fca7d214defe8a2e6ae01aa44fc8e141ff9cfe996f8a4fb0f0e9d6011d7ccce722214244dfa2fdf27ce3760e71c1a658736aea22f0cfb2005855592b796daea8c20e4bec06b4de4807aa4cbfb988b8f98fdcb4d2a36066d1e79cd1f1cc6c89bd7e3a66541e4be954400793295c6cb7560919d48e47e3c69af5c9a903650e09ea914574be5e597c006d4fdaa38f227410dab5130d7494980008dc4f25a147d1f232e8d3c9578db6ba3065f8b3cb6025964706e7c6bb4d9c832cefca8dae5aecc326d473d68e9708b8b1cfce3c6fc54ddf96327955a2a5423d48c86ca911ca7a4a31e28f5efc3410078b83ffaf18fa21372886e3de286c784e1f598cbbff1643c79fd9a7f5b2ff4712a811fe78bdc9396e16509a0512be08278a3348f7ca1078b4e334b199e6fc60171c684d19de6e36ec5cbea5781842ab15b7c44773df3ce9170e0aca24c72b83cf28aa187cb24e05044ddb08927022d8a5582efdb6488a393360ab8fac099901d07bed29f5108662f3044c6c65c3ddbc1d1ea9c3cfe277db92a11a261386381bea19d63347b46fb6c45dce4770a40d02be928e04a1644b426ba2d4a1d46dcc21bee29bbba98ee6af6aeb184bed66db4d9ff0def1f9412db70254dea4034b697bca8eff7c9bea4d36b1aabd2c136e8cbeab703367f0b19b16805cbbd2a5ee097465c2a6bfba19fa411733ac5c78ad37081746e808dc220310af6be9ad152a7578a2ddb2001e9f047c44add0bcb58b29ea2e383ae612c8016c248c47b5d0fdd1713f20c4c493c7d51da408b2792527e51318a5475202b3044826d43a91650fa6b7dc9f3ebe9b22f586418b5436e268f9bf4ec723214c4a31590071ee6551e83cc39ec025fc0d4479a0c10e28dba534d9d3fca294133535a6faa0189361f70470b9815c49fd6ab2108f65b9cdf5f7a4d1b60a920777aeb4fcf5c338f6727d67541f1d0029ceb400edaa5c294504ba72fa98c89341a70d99c8fc5de296c93f9b27d4c3f061e02283d8a193404b1035b3ef3c3bede4220aa97189c6d4b14de638e85f33fc7dd5e9061d8d398e69225dbbca0aa97e5c3a25a9c47808d5182a949b554d1f709f32a253b52801243919b163f17f014e9618335860a76641b904e8cf37bdbd764ff5da92e79d0b5c2020f4d575ac3f9f56335cec8484719c43ac13214cd68ed93a85c4ec851bb8e555c95494b9357486f7ad77d4b74144b70d117c958720a5107f6de45dd38bde347ced84178f200e75910286be054f30f68c18c475eef9711e95e9fee31ca7d877ddc674fc1c9b902a7abb8b169cfd03ad9827695789c3c0cca258c67880ea2c77c8f81511f92d960b6e889ac275dda29e79d39dc73452ffc5f15d10e45b872a46318e90c095325060efdb409e2da5d48d8d2eef4000affe4ae44995089b90d0504af769fcf0d939c77fdb8019c1abdf309923438515061b7093b2fd99b09220e1df5f23b969e763e9385fc81be8e1feb73c17a371f432034358ffc570cc930fb99a33488fd6f03b4618151ae5e054293e231c260e02c153907a14939dcd23ce7db889754777651b22567dd7b32882d7bfd5b6f5ca604b343e98ff4c0eeeaa9ee218abd6cbd664b95e476814a52a3fc2d4a64d0148bbec12fe754ed46b935dfd2eddc92e4e8af434b8711d5512ba78b18678799d8c40fe305e20a64a3fd0f43f94a69b2b2213045f5440a0756216212aa0e68c6db768ac3439142ce983da0be960f95a234c2552a164ac1780055e81189df960ee8894153999733c91584ba0b7dc4d854b49f2e371877d2a30dd0f2d3ed8c7b4d1c60980798712cf93c494f50fe0695ed0ec6f33efcec06526f0c49ae53e9395022dd08ede61c21f8628099d0ac27d7781c9ce1aeaf40c1220cd143b63301903e76a71dde7c96d1cd6bdf0f2ac7b2412bfee7400c17173d979578488f7e352f1c5d2e34e05325f474f2888416e9d982d75c1b041e6fa2489ebd80cbc05081c8d865218f5ffc246fb83a928b1dc12b2b0fd6df53d6f94aec0dab08869fb26a76f919b43ec10b52edce67c3978908fa3af608c7b0e5d03885c6e6b2249c7fd8e2848cd01d8ac0deacf700da42b3a31033185eb10690d23b38578182b941aeb97f3af6e0448c473b6343911c535832b34203b7471bebee821886797be3455708575fae80c6fd46736bcc296c4c1e695c6ded36be25cfca6a594ed10009179731782031c759bf32aa7bab1ec96cb5a18589a79616d9374d08394c7c5f56a6b1f9b3ea0f20bb55940cead394fd4dbb28c2cd7d3ba73b5158ac88ff4122124038acf00b4a5884f13d33733868a9ca59546e0648b057b2f6763aae3ed75fdd3e35731fd342c1fa0a5fde2dfb2b2bbea14218dcb407be0f8bb31fc5b55caa4c55cbd28cc3b7fb85b91d83a9aa085913150724a7f5b5c74aa4533cf87ba482583422051f068a41bca9ec9aaa9deb9fd1c8b2875c98d5cf229d718a1ca0d101f5aed0f4f38c766cd2fb9347bb9726821d66e941dd37acb9b012628fb31b319b981df1812b6c0b592ef24f0bcf974028012b0b66b24a97be20b849616ba1fc2ba35c861bf7dc30f67278fb168714beb5eacef0574d8d043ede521bac07bbd6c58eff527d4832d8718ab5b5b9c3098cfca65b881ab9ba3f97a9539549715b6268c64307922dbf6d871aa7832cf1b1cb1f082e09a26a19b9ae69440ca7bdeb966cf471e47f85712e7fbbfd20beb14cbc3fc14ce7d2aed8d14605b01db344e7dd8045322919372575279785485af946ab29d9dce8cfe4e4e33bb70582160c95e53e5076801fcc6eb7cfcaddd54f35acfead899a1c50fad0dce61b8744eebfed02a359151bff95683153d638fcf4c247bd70cdba30d51a01aac9c03f756eff0dc2ddfcd2812dadcadfb32e534ad860e5cca41006759e251329379a6a5517da3f685f0c3f60a9ee41c83542b751e2b7b85db0c8c1bb4254f4428cc4e4226ffd1e21564455b3b9194b1557ec1333d88f8af4d5ebb2aa2259bcac8bab5d632c38a5f21ae294f78e33f4c93d35f5c9308c7eaa36ad4f329059bbaf308cdf4b39828fc21e18a2d06c2d03a3400aa2f1639874d90bde36d6adeb23925af5ebc4af73d7e3f5028e8120ef4ec0718cacd9b4dbaf54e7cb19680c47dbf9534fb220e385f6c24a326afc40065cea7adce4a7b3bc1fcbd367f4f1ca2c63855275472a3d4ea29408e7bb25f1a0834dab2039622a3290f8f82aa1767c8ddf0743166ce82a45921b6e42758e7dcf6b8ccc5f007efbea51aad27fb82331f814d9ac0f6005b355aa85d35541ff6118bf7d88263ed4ec1a72c8c0cefdc906871a06e72e3db42c753ca4fc7a5e7c9f7636d8f01ef2b5e4a94f0b4cbf26d82021ec21f086934e7fbc85dbc141cef7e9465dd124d7b409feed894158aeffab9ad8a28ec1b69404c5b16f01e8dc11951c6758df5716e9d96f477ca945c2125810049352b9de7c38d1cd1ec243db84b233fabb9297385bf4e9fb44d6e940df06a799d3b28ce405a1e7684b90875b11c1b22c9a43ec541747f4fd113f1192bdecf03ea6d6b8d62f9ed5dd0609cff1ba750a07b5b54960ceda80cad8cd56c1035377397b398222fe6cdc9ab59bd5610fdd963761660eb1cd01903d89fba501b762ddb4f355edd746359f94fe624d7e7a5432b5a996a1fb53fdf139ec959a190bd9719423b40a3cf2425231d25e3aaf8e5657916fa9ff2dbc1d517d999c143a997385de610fad69c9a9d7d005953f879eff73628c07c303487b8a22774cc6df36d05b3b9ca270dad030cee334e0c8c37991aa63f0efb80512b95a67d72845441b7ab70d19ee457535529da76798ae817663ba16b2b4cd221fd164a64a7a07d70f55176d6717a23fb40bdb01d1092cf2b6456cd320d3b2193c8c38b1f7fc8cff372c50083fa38eb7841516b3b108dabd8ee2f8de35d5c027423536a88e2a90899c528d2e4061de12a9a966addf4fdde20a32fb49d651924772018622096ec96d775336038c7062884ef63978d490e0fae8ed4d738c6e35302525137285f00a8dabc76ca19fd949120484586bc5ed99c739d3794f0ad5f4164ae2f12cf887ca2d768770481b45e9a3df4039c975a5fbad930a3b8ea039fbe6d501219267c7c6b78a32c93781336ce5913f2ed92dac23f78b6c89a7bb8ce06300e2e1f1ba8023459feb8a3e74f099e5ad98694090f7d1021e8aee0535e5d1b86b0bb190ea0364360b71ab7ceab61a80f3eed6853a081d06d4560f250be483e94e8140efc6b9dbacc1d7543d0687d3c980800e62d32851d78b850c75ad0bc94ddd531b8af436bf8c296f8e99cbe97c35638d7b197f34dc7f610caf97520c75ed584dc33d3be5bbb4a95b504738018a83c3f4138100442f072ddbcc1b43b89dc8d5710273b5ca43dd59addd59e2b7828b71931e4965b6f27291ceaadf98a7a1edf324498b3255fa5592fdf6040e0620fcad9f5ad4febc0b1086755509d66aadad768d216cd9e78f7377d58601d704a10d8dd7c594334338da2b265671a81f46b2282883e3548e63361a819f3988e28d61a0931824b5976fb79d32ef940ad44e13ac25794614adfadf605a5828b1e968e0b57f24761a25663734f3cfb5b6ac1a3d2b2788596c5371bc7c02c23db8d6af1a537066af87c2c3c08a022711f18adea24dec99f36a47d52cdf552cf7cda3309ed75544a5987f4b9fac37aba91203c097f4e3a7c9c6531733a5a695acb9a7dc27362b41e1a8530fe27019b26214a778bbf52228384a24a998cfcf1b557c5523c8d9fb3de48b77d4959f16aa83bac657d8348b66fb37e0b8786ebcea41b1031c775cf66102badb34af6213211e15008e1c340937664b0bc188abf39e3f34528936b04d4842351d6b4b3e9f868db9fb6a39f3fa74c20e793959e46859731480ee7f98af7dd325ec37eaf06411c0b67341cc3271cca2da1cb9560d1170516cdd0e8c2a56a9acf62316999cc80dbab81d125d19f9106bbb512479bc26df1298c208bbc7f8a8b9abfdfd8ba7bb55fadd04e7491cf33eca8743ecf0aa7c05eacf91d2c320c60921497ac36470a841d6cc43d894bfcac08470ab553b074be6f052a7926cb41435c8666a471fd70e396c3c3e60b413e045aa7fa050d510ac45e32c9410b1862b598387d579084280e9028fe329b8e9ac72dc4d0e148f644bcf6bfec37ae22c426e4a039324a3e8b522af9846b076d3380a8be883d24e91853004f6d083c0b3ee691f19c69837ae8e0ca21518d9a727c1ab05de70adb922d0c064edb52f1967810f4ea1d91cde65d0fe1e4f9a9fa5562caf4b5e5e0e7e2c9d3c2bc50246470cd7e513f7b54792cb4032bc18b032a3622663db1acf0db02971a86edfe1b292c38f45bfecd8d4401cba009960b23de84a999cbceef4ef4826e6201846461a4b722ef2dc971007ad66fe3e22f9146ec407cf97cb80d198f64ec5a7d9575087dc3f9ecb438b536ccf2aef35245c7dfa37aeda9f7c4e4c38172d1bb63eb18d29ab437d443244041a91910f7be4fbdb0c935fe448d319a2cd2323dc9c98ad62c75771c8bb0848702c36796687a683198df67c9318429146f267b85a067ae07ed8955e8f0c7215155fcc280ce1475c685744950626166ca2eb531b3b77c42cb9328c7361b6ea34ec6a2f01039d48e2a939ee05d1d3411185aa7e6e2e58f61c1c871870a91de725ab4ad480d17fe2449ae6b28007555da985d34c9cee84dc502335898c12ccad3e5354bdb1b751505cd9ac32cc4eb20776d2769da8d5e39cbb435200e1d9993af9b6f4b9373cf95a93dcc3dadf870242fee9272b24271146d99d1ea8d4dcba9cab29ae8b36895071a7986b8fe2229d98a8558a2e8a16b0c6fc425862eb2849ca017aa8359cce397453239ee5fd4fdcb3fe4786ba68e9e690878364b06087eb4f04d21f9bf57d9d3b5e761073cd6b4e2b742f3e06e482d9c305c131d38ba8958f03850c47be974eb3a24063e295d661f3b9e564ec5ed413724c691c70317beaf45faf4bedb345e85ba70dad63370b6f4d0e91b1a577f030f4b763fc47a8601e64fad8caa0f22906f70f088706c9c5ab1e4e78af64e9a4ce4ad2a007439f72990ddb2ff5027694121014cfcf92a7a01b1d583861827efbbd5ac383723fc889fa74a169ce8c3ceb062a04465c4aca021396e4d15378d9543b72bc25e6ddc4f72cc619e0fc0b9abcb45211249c2161ffba4be424ec3294d4a229696c1c674c8c6eb55cc81a044e3ad4761af5bcd0994ffe7d24bc1e59b555aa2f82ddfebefe9df361064bbeff42ea42afaa89db776f01a16ae2279ef441be91c24d0bb9918caba5fa1f2373ac57b4fb94fe093cf438483bc41cdc6faa57c2bcb4400c995f0add968bdcd4aef4a29388d7e04cf373b77cfcd6950a8959aaa84f4ce2ef148299df05c3d682420f46a0a0d745be85479bb0d1efc728579c91b044c753a19b13ae2339082923680ce651fb5e900a9b6a3ad02161ff3738f02d45d28556f2e3cf117e775f10030550c1dede8961c68ddd513248209c05eebca6aa649d6429d93561ebf96dcfb754880e63e80909ed7197f7e5776e8d6e0eac3089b956a141daf4b711c4e0694d1e54089985848d17f95e391c34c375d3f2c92ae05d1097b11bf53dfc874480fd88f593ff56dae02288fbe129a1d173647fc459f7ef06376c00b730b417828219a012025dca24bd7d0f9c27cafdc77b0a0ed68239cbc277176783a0206f8fd57972afeb497629a75743a421c3f1fc81a0cae0638e6231406007caad53c89f2a533a999d3d2fd0d57fad68d0ae90ad38d362b8c8ce6950365c1e219f8c17f0858c592aa33a7ddef984da50caec3263271ebeb5838caed335fc608d575b7b1a69dbc8f8a4629b9d9a8b3e39f079a9d4366afe1101db9d53b092531b3ac98ea5ecf374ddd3c1095ddcc3a5b8d5d551225bf4a41d40fb336300305de9ae9fb1a584dbb2849955730501ca2002576101e7485f36cb10652f1aecfd02b84b068bd7ca5aacf0aaa2a7fecff5cd7e2eae575f0eb91c93aff51c6700cf82af6f654d1a9f424535155ef22d8a22d5794a8bfad9fd0d1e61209bcdbea3156febbb3a572a4dd610c15a358315867987799111f7157c89eb6ff65c1caef8f396b0e2ee1bfd0b81aceb4bd6d25f3514e536fbdd860024720b754b48d8dca327d20fb245de3ac26f5d068fdf4c37c7a87b219ef7db550900b6d8fd8c1d0423f5a82c268084bd023dbe4d2da19073f5474c3aafb21b2966141acbef7962dc9bf7c58d9af320b37e29184c70343fe66aa16ae66a3d90c3a3666bcc7f8b6b7aa1a0f0ec698ccdcf410c8829691fae9debbeff3f6394b4a4476203a1c785aca501317ae67d5c3cc932da6d05333a7b053d149f07a8bcc4c439bcf94aed65550cbad364dac149aedaab5ed5e7fab51def09555abfacaf276b01009af9a2d51534c173875f2eb66d021bd1c12f8753f94a20a0b1f83fc1cc44dd1b267acc50d6d051f7f4e8276e76fbdda697df938902b5c5af2bc712661ccf6be22379edf247b90dd88d3a2611845931b674c3ae13e02f89b14e1a2f3327dbdb4e3dd96f6f3884221dbf12b37d0efb61e5be25d4b62823e492c486d7e72eed77dbd41f3610e9984fc851c41080e33ccbeae3e8760e7b66351882772e7a05d658f427171190238c5278442c80f4df28d5d735fbc00fc6b6e881d9b364fad9e8aa062d6c852a374adefc87b8419522813b741191f5766d9289bbb602220d0aacae2729db435a0993cadc818b9ff40fddeb8e12f1343790a350888aa39672a9479614929c9ba171e654ad14ff6c1dd14ab2635f32f263e7312755f90e0ab64964ba38773f415e29755b8b8a2c61252205899ea9c849437fcf1fa82c99c8e6f95a323f399472f01ecf738aa008f8a1a27d6365286e236732e9fae3f0bac254c7780c69ad299ab4c30391f1bc4afafbff8e7a457a5c99eefbb4b50a9208f5d4e416a08804c4bda6ec6e782ac5af256c750283a6a5e8ab3c44fc560e819484fd42c71778bcca171b2382bd556efd3acda7036119f66de963a55f1864938a02c4c44d9bd4d24882eccc0e22874449fb020a3128fc4eee19c6ef0940601f697f2c1e369584350aa944e9375632a655b0754c48bbfbf3a4b7d04c755d7a14f1029892dc693e78b5729883aedbbb11339d99bd58c201610874b1129e0b358256c39f7d0cc88bb2f4c0cec9aae06cc5957ed61b92a8f52b599ba1175f9c22843ca1c46712d3352ac0bf415b25e57928e913ed7a6cddadf44b4981812c68ea8b0cbd86fa40e8d868e35889acdaf722b2f04e6670d85ee0f4cddcb4c1ed8276afef2d2b873677eaeb7977cbd08e1c0c27dca3b4938110b7b3c6d587212ed54bbc15317d5b0f678ed02f3b5479a7028f76264644bdfe576c52668b42fd1268fdab3aff5d3d0ca0c91494713dfec3e5bfe4e7a6ca64c7bd47e85d5a2893c5d1feaf1da1a2a8d71a3991e3bbaf60dfcb396d3c56b0bc90e26f70c62e61b12806a7f51539bf3bbc250bd1e06cb92d694f40de53aafb4f9e4fa7411a08494f7d4fd1a88a53145a385b5db251270dbb473f302f603d0a30542475d772cd543a0c93ba9fe1ce3dafbe69827cb010d18b4d2f55dc82c40cfe199de6efee33bebddcf57b000f31ba371cd22b64f8f6ce50dc333df41dd321aabfcce678842acaeabc59a2fa4eb56081a5aa243a04d46d36985c66155792444c1431282fb2212c8b4e56c2f1f1e9c90a77f5566fb277c8dfff06044f24c7a739a10bfbdd9711422d8c1107edfc959f46dbd4e7b0767a60754def441967b94f48de0faee3005343b1fb467375bc37a73dbb0ffdfc005a99035cbbdc67a4446943c09c1cb8bc9b745c69a252dd6c6100d2087bfa3dc76d33b0e3003d196bf69333cd1863370c2c59ecc6cc364b11aba63e91b9ef8128b4a54beb2cf89e5ec0a9883a88b6f4bbba228d01ed853c1312610ac173c0399ab9513b30bab70d4da2757caeaa7fc0e91da2843a950c095ab39b164b14d3218e4d931406f33b6b7fd3f7dc1a1bb765bfbdb5f03b2cca397585a8845daf488f93a9aa262482885336bb71daf89b47bc055917e80888838595b674decfee78b166c45279560cdf09bac2832372d42d01e56c761ed13939d164c978d55732ed296d18ac8c3172e639e063f1d232b69d9c339c8a53c1d9ca98f4afc55c65875289fc3eaa141894128c8599ec25c8e2d358d19bb844e26d8c4e15020b4ce9140653142ddfaa1fad7c6b6ae65e017eb37f9797c17e754ed97e7ffa04a63203eafbec2fc22b72756a4cff51913f4eb7f1bdd2b30c9c2d18405525ea024567f1ee31d6ff2640c80cc4eafd6e674a6ca14d649132a57502c1f927f8ccfb6559cbc6bb3c1d797e5414f98afee80d73197c698a47ffe8a5798def0a1f1f69a78d4a8c429ce0728c168487149a057d24031acd9a7e3ec82788483ece4b607df393e212a2c7609e211041bf39df05f6f42ac862106b369cf65139618a121ccd057461235dc1a4a231ec5e2ae04e6ab847a1ab48e28c59aa7d7ec7f0a8dbfcac69e65aa310e7599ffde6b55688e38473292625aa723c2a0c8a9ab7e66de5ec508ae3c9e29cdea96bea3355bd170b881c2d58b3ceebfa9f29ca897a9b87abb3c6bf316bb0a19107232622c2fe435ee7c00433b1187161d6a6283569a0a7cc0b4fae6cfd19d4f206ac1d291be8af2e1bdff642037a731042d8ff8617e7698658fc33a8debf82bd0443dc021658d1a02add509b76eb29f5308018825b03dd001c6d4147217a1d36eb49688f72da67b2e52c1744f1ebb0bd07bd5e14d0792616159323783b9a7a0a60a2c0dc656caa512fd77ce503e1906365c9bdfdb7e32705be0d21fcd6f20641aa5b3aefc836259b8324bf80138ec7ee4866139716a2619e7ba61cf2a66d2dfe91486f5d4ddfbfb5d8dc1264dd53cb710f17f7312fe34be2da165f432a463d88ca778de76ddb75494e46f7385bdd0a18ec6df7e852ddf195bfd82e93e2902b5e651e010c0c323396830cd6b9d53a7d9a1480bd163315fd459970e0d4c9879a57989a2e0f7af5c9cfc2bc96c3d0a8436d77fba3249319c651b6c9770b813f96224a9780094e7490aa292556ca4c4d2ba0860e6db79cf7adc3de6d3c09004f36f413a8dbdb50b4c9875932ccaeedc7d6f25b0391857818c4af2db95d234a0584306ee75222dc3d345b666c9fd21167a4146f5ae6b47cd9c8ca3cba74d01fd4fc45fc1a1f7e0502d8b72cee4f20ed54b848f3b81ff6243a8f8c5e5711d86aff3a7b3953c1f258a011ac5df1372fd864e17aa96ff8427a687c6db0308f00fddd81ebe6ba3514f5e1471eac791075b60edb851ac4b5d43defed0ea58d1f99d4766d61bf214738c73e9fdadbdd64adff3e86388c7fe14bb100d8ff15c4a756fcbd90d20c73f78bde6041d2c6b63f5535f2542b0beb0d039694499bd5659830c93b5f65831bbd05e1fb9234eb68df491c31082c90ef4e06b7e25cdb82a1dde475010bd9c24ee4375a8ee5b54a963f7cf77bddd1e5f860d3938a4d41685587d10cb9aedc3bc320a6e3ce9007ef827200eefda0b9b73ac6bb607b641c9636220ec6fbfe58307556d59840774de3c37772775cac8f481bccc2ec7d29177557d26c864dc4073e1901346b1bb55ee15f73de3218e2bef0f9a92126386cc446b7f5c4fd3000d4750b58b8a582d2cc7ed7e93db7c5f8002330c7dfcf56ae372c0166ac8d1d34230d22bae9bb69bb685022eb4c5c7b4e14d74713d10fdb6fd5590f7fe5c98bf456a419070d6b4740527ce7683445399c0946a78969c2b57066590c81024d813ee3f375c1876e16711df1f1260255fc1b91ff6ff01a567e02119586a8af0ee7738f4c907c0278cef580c8ba7a401e0589681b5fc3b8fe2c107209d23f86991d06947a58dd28f48f08d5ae4ee06b0fc26900ee8a718a299defc5471e884d979a17dd59a05ea538469cb6d576608172e4898124c6d3e891aa3cbd3c24cd28efac568a3701821906a4bda9c3e21f1b70fb1ecf25cc107b4a32964e2eb450e0923138b12d018db9277d6f1269b2d3eb4f849776f368b3915be727dbbd864f0fee9d020bcb0871f29616c23c8ab4e4fce6eb75845d73b90af74cdb7da5309cde3fdaaff14cbda8e207939accbf5fef17ccce5d0715b7dc770ba45577d7ccd5ab53a32152d329f8790f6750f7369dac4388c9af2c102b1be1d3492474a5c18571a37a0fb53d40069967be397970008ab21ed6b02bd50ee8354dabe190856aed1b414c6633e62b1e0f77fa3326a80b5dd668764b0f98ad7b21672541f0bc6c03991596d98de8db225b835ac6e31e161c47f71457954517c90e49475fe7cffa6b17efa038bbca1557b347cae1d788b4af0a840dfd1b8296e51f5f8d8e5714cd43a4bd0cc1284bf838486f278aff1625d5f4ed19534e8d7eda34466c561817e48e8b4a6eb3aa074c632614a12148192e213f25a53e0c92b00721e1372765dab689c0b0a00c446b6c1a0422e8986d58e722da6ed853d2973d233f4e9835330929e3ce84eb2c35032c521ca28284be9d03b6c8f855bd024f9adb70b5e4ebadaef94df118a2ca348800f66c505cac9d044b65dea15f4ca81eef36086d081742c890d9229565530d0d71cef0779c109e640657abbc65e5ec431e035ba3dd53d5ceb8b5ec0330134264daa5fdb7c58802ce78751e88a98c145a72c53f097a96474f15d734362d8ff1794e44121dc02e61e1ec63d5c135c84c6ac38f3db33d8fb27b641202b44ccbbe2fb8c9b984ab91ad44f60c64ec4e481f1dfc618699bae2f707bc255aa0ef377f813637d91bdf5b965d50c54c60f0b196f21d684cd867e5eb20ab0eb68d6424ba7950a081015fcb67d5f55aa32953cee543def2f83f5284fbd4da2dd38c3282a53680e18ea86214f395a9d02a1c653f78d1e4f7bfccc7bc7ac2a9507eb44098c3e0c1d3ff9bd09428210a7c894f3e07c3f83508d3d0f0bb438a0ac5c5d9f0b1f743dd3ab4b41a89e981c5c352d4926e5d457342d55953e31cb0c89e7989605ca04b42bb5b75231bd1ebb3bc72ab2fb8339360fc1f777fb9f34654b0fb240c7b9aeab41a01a4cac7381e42bb5fe921ac5d2fcd53ddf6ca12d82261a49e9a58fd3b2f56bcda3da04333f0132fd6417293c8dbfa838eddfda8ddc3f65b1d0c2c61c8b1eebffd8dcfc370732845083affae29f2815200786773ba1238a478591615f7c5a1f948598d55682aefeda58ccae0edb7511e174918fc61690b2253cf5f61f867c6f4868a8eb8b464606e14e0aa0bd03f2749f21886b24e45f0d39674cd6a45ca4de80b430fde7cdf0246290a5dbf267541ec2deff10f5778471aef67e3bd628ad68cb3db29d651144404765aa9ffcf182857b56239c7ef0d52275d0365665d51c33df751293d1764dc902ec39a8edf19b3f47df0419e7169f457ae011227167b42c1c8c8bd2edd7d400ff1d414f4002f88bd292c4fc9af8e1e9e37a44ee0f8f44fe6679e58bdba5f10689a9213ca07110a2c048fe8bdc32d97978ae32a0a7aa984efbaf0f5da16d2ad454aa0fca4f69db2a2d307bc2323df31987c6c64746dcefcedcee65e8e7a0761c7c1a919ad50720a3414483fbc430716e19848d69cb0df3c23da910b4b255ab4ee4f912a0c2550d18585a5ccd92df20f84f4530caa96424854d6d2b91d323dce2e0b73455a6bd1b28374123b33c2acdfbdfd09a1b7bd3120ac297de6b66ffc7fae778af2493059e0dd8416f8b736b7f59528a3e93a36d05cc1b78b195a8be12490e392aa4d2a4ec52a15039899715c140a33d8f1c02cbc9b4fd8f5cd05e8fd4e3b13278a79e44606650e11ce93249e5385585cb9d3bab956a156150fa4e5e39850f138cd1a5570d24c0138bce288b467c8b2bf5760b0dd6de3b6bbb38d24c1b52c0435c1f2d84d6d102dd2ae27e03e7f62b92c300f64aef2f26a4f682100efdb7aff75246032cac658d8a9af8c9ab9e803d0642fd7015b2724872373db89fd8c4bbc18f4d66a737d5593c01d5f1b1a28d1f8764ff1ffea8e955d48e4ec83fc5c808350e5928f031e7d7ea276a5d1c62369780d35c67cb7a7ff88b7a67d74734db35655df8118520b4047c037a6cddec964ee48c208d96584d9a8ae0ae92b0f1a8cd8948f9624d118df2b514db3ddd92b2f2fdf9ce774f1ae53218d69f4846eda7b9f0e59c4cefd99bbac200426843571ff618e7bf2c56bdc9f830170419fee86f5b8986d35f994a91b87d2cdf412669ded608d56ce78c5417af2fa85a865813e56f2d9582017b144706d73124e1b4adbf2e054288ceda36e65d314ae0542f408816b933bc28d6e1fc94f9c249e4c92e25c8b9fe0953145302f33ecd3f40c93b3e98cb7c8203319658b206334c87d62a33fcbeac0f9291fa7cb0a1b3272669529d21eb854b72d1246d54e6125137d1729b143b463b800ee6d0cb37808777f9ebea9912a8b445998064ce9e476dfe00ece3c081dce0e8c04df3282bf1d5352443d0e23957e62270ec953dd3428afc2a76d117445a201ba7c1521b8a2fd6bc84864a1f9359e02ad9045e285e7c9bce08e51d525d5bad4164983e627fa414abbacfe0c47aa941568fca31b83cfb18afa6b94ab5a904b8a10fc395f34e21f3101b82aa92f70445ad2daca80f240125cc215a6d6ab0bca43a74d61ba2996f35f46226be6bf348e5ecd171bd254109718a0554b94a8745382499b509ef25d0478ea5d4f968699ba55a5249d12e86e1303510a4b21396c89bcc33a0e8f8161536b523981666d898c673a14fa1a34ebfb4d9c132324e8d202b7a47477509dc920960008cf2d034eaffdfe4ddbf5302ee116ab98bcef583f2b7e972dc406ff3da4c47e1b8cfb5564bbdb72e99f66bcfd38d51c290009c9d1493a1d46ba2303e9ef427d5691452a511d5f9c8e9c19c292a68226f757701a892f2498836dd0756ee0a858685048e56ef6ce6b615cf38f13b26b5d36e4522f8780a163a4e99604c24c6f563df4029e0254a6984c16193d448109a3c5137fbd7274148c54d544e951ed55a60ae123e97a09caddf34c4d7d5f5f87653c290e9c6d01fd739e7869db849cd4652ccd1612d0b9ee0d1af84e785e91f48f32fe31d13d07a7404d12529a121555d995f2bf0188d3a0aaacbdf705ef2529b9a574a044dcd7068cc24b5db113e9c0cb7484a86624c630b5a155434e06d9a91233dab1ca8f6bca3c745e36c5cebaf222af19145bcc1b133c27594d479014070c7b75f927099ae04877acc414ba8e36e83d18301f95971de4ca60697f8f75afd3863674ee523d6b3329223af67f746aa7bf0d6f51dd5a4444e84b375c926b38bfbf830e28c924c9436b3d4f306ad5adb8a12c51450f13228d1f45b83aa4e42bcec1d6dede63b2ab6ef5349e3c9ceadbb35054847575d41503633ec3ef8e0950055f54891bd8c3b25d69c5642fdb63b269146e12e5e64298e3d682c28782cd935ab7a1e3ac1ceebd15fc0f112b4e27a4e09df079bb95a39f13610fcae29746fabedfee4da372768c352150f4b70631a9a0b80c88b52e12f6fa9b1e63e200bcdb93ed2e9b14e84f61c7bdc6c68e5091e42037d9c80bd1169e2c5d74b6fd0ece2ad827b451953c491cac99c1f86ac6439e75e99fc0cdf6fa47e7a568d1c5814f5a92d810d95ee76ba2f09cb264f34b0b0f7c537a667b86ee6c2d7c8c0b58e870b9e53573f8157ad4a3a359468073c4592deed970f8a28aca53740bef0d2c6793c0c9f51d39d34f23c8843bfb9d0023523856c8aa4a4ece50d5011330aab99116e91d1f52f9ec359eace8250e58f3bc371fe5169d19a63c16cbd21357d2ba8a58d78cf2bcd8376f2424c5e0cfd1a79de81ead317fbe544256d08c6138c7eaa2eda68767ceb5ee483f268ae6089aaa9dbb20362c8e2783c346d0eade83f4248f0c35f5fe12ecb00b3bd518b6fb9aa12c7c92ea261177c5670e1fec7c01002aae7065de4cbc8415a9d95b5b4697a601128abd752f93f8f1e1c2a7e6b5ebd165bc7bda37b0b01ee781640696ec7ae7af47f756c1b418f53a52365566614c19f17540b20e1875ce3d5927bb63f944670df1266b802b163b7a31279b616f7a655be6c4ba6914c1c41162dc776ff52c61fcd357a2aff5c8d25a7432a33cd70f0ee53ec72f489f8f1f21bd518c9fe8edc9b8e256dba763653945ec698bd09078922a37472eb157063a8be0da859c40cbfb26763af876b8e75a37efc3b5eeab82f0e4126311835130bbb9a901d89a4adfa2dc7acd18f6630880fc2ff8487c18106895298114c25cfcc070690f2c3e58f4c346e31d45dfb5d2e2bbb7d3911008d10aa06348836d0bfaa945bbd68f581a88a0f95190342d09fe61a2f2e2ae70327211f957a5428932379a924ba64be815cd314b74fe9545978c0c6b991253a3e289d81691858802eebc864ed656dfa4573b66cae1b18ea7d37fc3812cb3da1efc0608615a694318be2dbed91afc254971f84862488fe316641925fa204ea40d48846de3043c695f7aa60994f7f904d06647208bd52b7b17e0ad4d4c1c7c166be211136600cafb32e238775c9b7b9a5f754a51c88ef7358ee579f0827a80e18a510698ab3b5fbec9c75ff790f7649b39d25f148d90052e3de5a27a8495e864ab3170a5ba90bee7f21fc99ea5121cfdb49c4bb80db5b1be676be9a3af3267f43147755f31d30c3544ecccecda53d33a2b3e21101854ee6a67ead8fc47c61dce819d0e819c86493452b9b2c67fda065ae44b421f8ba0fc9bd2c79b4c822928779ef7d6e0fa6d3194646ba8d65684a5c31f8767a63c67a94c7d62252d54ec2d4e6c2d86abd4d48d299f6630c1a8e23e7ac120c80a150659071ac8e71ef12be65982a020f9c7c39b69fb07501d73ba237266f8236a17b384ab2789dd3bc5ebbe5f8ef5b6afde575dc3f45a17cd6c0337e4d89182af04dbd926bf7464805fec399e2fd7f3f6dce550905b92348b854881e0da4ca868a8f3ca07185d252dfaec04194373753e833330fb8edc82c320e147d2db7548ca357807850cf10b30d7028115fe8a5ba4f082cc3d32da7cd0465e3c3811154d4066c41574b7f42119620a3544e6d7ac27c2dc3ac7c60546eaa9b81c437534961a521f642c14bd1856b000682c8eed26b788e46f743b1727bbdd80fe28065459e82b3c53a221c863782b36a8c775fc183f14adcab6842e4612ef295e6601c47c6ea893a9b57f66db14524f64ffcc37b2a69946a30a2ad739ffbf66eddb58fd9eaf48d24d8b923e4c4781e5f7bcb3920bb43efb522eb458f7b00d7d8fec09d7b75f1ed0457aa4837c69cea987f47d57b1669cd16bbec632fbeedae097f7c20722838247896f3b211a47badf73c873b9324b9ec92f14d2950c78a3db29c5ea28715a3f3314732e5970ef49eb403281c3259b033f4d8b7323075b26cd2257dd90c095e4e067c54eb0bcdf08c0acb3f29ef87b89ed57314df7d5bb6028e8e4456e9acecd54b860acd8f56e25b796c6362f09d50d48d2732b119e8b378de417918fb26ba6946242555013b15e8e209e253fd5014b212d9b20e2ae4b70c27a2472384a67f4a39b046990333d56dc9a5c7c878f0d02b1170371d05247b9e42687f461218e3239e8239873d5f4950e219998f17f2112154c3d9186e33a7967210ecebce230c9f498339802ff047d7da4b478f8b7f74372ece8cbe8ce169fdaa3ef2375abe54036e1bc50ae67edceddcb8e8b1dddcdc5049b34f51fbac33519e6af8ab8e5fabbd6f5993edbada9f40806c965a1ae4bf37bcf15c2a2fea6298f61e85b2155c66d5db1dc8f1b4dd3863f3cc418588010722581412589ae3914a66656bf9747c36427ff0e7c573eacfcdca5ed011f0db027a1a45e21122f6e2dc909bc082aadf3658dd8e958bb4e77beacfa1ccaec25665269eacdefdd334616cf15e057a963e859065bf5f7ae653048121f42b68ac5e7a6ff91c78049d61b1abca7a1783190ec7c861a7964678eee2581055d87921697a4be7e778f6a57a4b58014505932c60bb7305bea9d57c4cbb4d31854b1b3e0d37a96f377e6df192135344f34e4d727926122cf50ae2cdf08949a09950bf8c7b62400e1fbeb213220eeb9162e0f592ec418afcf035d249c060692c85c2228bfe72b90f54a66dfcac83341cb504b6f391be43d821812ef3f67c8e85a80b9e7b6039bf64d55adecc425331e33cd8cb14adc7ebff70942e6517773c65d6a76cfafa0bf68f2fb17544b02be476b9ec566c216936dfefa20d1d74d64d5c065095fe876c26671f5a8f8c4e7e60ad962872aadf25be312bd51d36ae677c5f1a29519956cffd7c6b6fd9fbf2ea66f560485621c77eef7e18d3c81bbe57b44fbc6907a9c276e005e673798127a359f8afe6f7f4f09dce45b05906816eceb06c13a6c073158a57fcc48bdaf83a696bd34b818489ff12df4c80b078464ad9e350ab52e3aac08653b29cc0d3bbfd55234b8f51263fac10c49e549737c6368a2a44c84dc6cd8febff1dcff2133e9e0aceb149c94dd8b2bce7027fec93efb6a4d35c0b8ded815a630a90c0618b523a52455d2391fc49148634ed3f1bea617f30e3188bbc6d4114a22f4b738013c7944b7aefe681f368b6b237e9587557949bccfc1428d28cee0e00c54dbbf2ced8cf9dcd5470e555fc9d4ce3c40233ae27b00c66628346735ec8893582d47a9ef6e30216106095fb3096e85dd3341b41308034d18cffc865b5ab8453c58bc0da07683a08f1a2ea56e4977c9c3134f32befd3b425b33ed49ccc7aa2a02c1209410a927656f3ce0b8cc5f6942f6cfcff776840041ba4229a87865fc72c640d5b06941fe00a96fc738f7a7037a580181c8ef2d51119c61a7637069e06f3cd3a17ca0d7cabbfc9b615ed39b2beab9816a9fd5ee9cd07154f1767b9d8b987b86edb466fd647a1dbbcf4e8ba4c1f032375e93ab17e270abcda4b821b166147c93cc36e8948b96cc875386bc5daaa3ac3a5e4a13fdf526b4b8a133f4b59051019786c583f92aef40332cfdf480dca8ab2ce9e5802c44b9c680e1397c0c6cd4959a64602f3733bad4d42ec34a19e249cc4a3de9234863d5c15e79c80f0f6fa57ba1dee5522f1423fd6f3f92fdba4d2344cc1373ee3bec2f282c69eadf3f6231758bfa05fbc56253abd4e9fb5b9ffe9a1c4fe9f1a870943598d4dfabface8bdafb63ee34150b9d3af004009094c313053ffb49b53011f9672decb55326a1b816c2d9fdd9ed95f9bea42d6286c8d1550adaeec8f4f407aec8a7167d1bb811e2c829dd1f3a895aa505c6427a50b1f9f9143a8fb351d16fc09c7e1eadd63372efa96cff2e23aab15e9e6f3f60f8cfdb7f4736d806c3fe8968f02231844ece869685e83aca13524bfbc62166456cdb37d0d3a018b8625cd4b24e988e0e5c1391be470cb0a3636d7cf3e35337c21cde0c55db00ed35b3d516260501420e79d24f2e4de40b39eb585793f2c719662170d3daa4b2f0e30d47e89541b22d2676a21a5c8618037d7d293906a3e9742abd83c7cb164fb9dda8da63437d9a75689b091a68449be6bb5a8686d2fdbdc479597f42c8625515807a6dbb535ea192a7d0042f07a7404f4b6339ac072b82ee342630e2a9a3727ed2d5e86564962967fd4ea248b6b5de3ca3f9a5a8bbc5a92098eeda7b9c32bf3215525a9aa28b740f95958e46744a4e3b6b75db07823e9bf01b1d30fd4cf2dde5dd981bdcddeecbf2a291a7d4a6ebcd7522bd7f679e5552ed29d3b456d2656fbfb0c484870d81ae8f696a5d5462f43f61096b5dfc7bd2be8df2891a216493ba1e9494d7a8972103ef35db504c5c0657006bc53e5886887ac319093d034c82eb84a3f95dd57de94753434e75dc1f391cc3b44b23249fc51d850b4d5bfd88b55de4b8df82f9eba0ceecf0ef851a3ad19a8ea9796da320752a035659a59f313bc1fa0f9f5f452773fae8cbc47b160fd75318eac7e09974e3fcfdcdc2b9c923be37ee5ca3bb0ff048c81a27d92c017f51d68a7bc7b22bf7d24b34286f542361ee17fb8d1b9643785a845b3aaa39421055ecee7c92548772ab8e7134acd4b8f3f79bfbf6ab3c91bdfd0a7a6dfee9e3778d85d47d340061578cd52e8212fb376bc0f15019e2c10a7c6ebb7659135789563b08e696ed7425ecdb57319cc564e26642978f8e56a2ab3e6112210753def9acd56ca2b24666530846e4da084c5b785a9e05a45eeec53fbb0806645d3afa353b70f4f456117ede740dddeb73847404559e5f765a581ea97a322b6be806762f453eaff8192a8b7a05c44cc56c8bf7c364092621dd0026c1ac0dd44f84e895070c19ff673d023e6cf0de959e0e27e7e04a2d85451d614dd6f4b227530f6af7278e9ffc8e5a24c5cffbc4331eefcc55ad73db0d80afe4ff00d0c5f2ddac1e3b932c1ae9f15ac09e47e0710a476063a48941bdbc0d3e58c9b50447072de3e86fe89d4d9c5f083f7bd4fea855c06e78cb6d4da5acfbaedf898b9a4651bd5e985a8ef534558d75f9ad57922aecad6f3ff482f9673c885c81d92a91364e428f0a09e72a9371359b0cc27aa2412d69ce2ada84a741488d2bc07c519a8f65b43a5d175cb66c0b9b947355ff01d0cb7f6bc0647086d954ae9e856d5e8bde067" +} diff --git a/api/pkg/handlers/did_auth_handler.go b/api/pkg/handlers/did_auth_handler.go new file mode 100644 index 000000000..1d0b3dc89 --- /dev/null +++ b/api/pkg/handlers/did_auth_handler.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/did" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/davecgh/go-spew/spew" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/palantir/stacktrace" +) + +// DIDAuthHandler handles DID-based SSO login requests +type DIDAuthHandler struct { + handler + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.DIDAuthHandlerValidator + userRepository repositories.UserRepository + nonces NonceStore +} + +// NewDIDAuthHandler creates a new DIDAuthHandler. When nonces is nil an +// in-process store is used; the container passes a shared (Redis) store so +// challenges survive across replicas. +func NewDIDAuthHandler( + logger telemetry.Logger, + tracer telemetry.Tracer, + validator *validators.DIDAuthHandlerValidator, + userRepository repositories.UserRepository, + nonces NonceStore, +) (h *DIDAuthHandler) { + if nonces == nil { + nonces = newMemoryNonceStore() + } + return &DIDAuthHandler{ + logger: logger.WithService(fmt.Sprintf("%T", h)), + tracer: tracer, + validator: validator, + userRepository: userRepository, + nonces: nonces, + } +} + +// RegisterRoutes registers the routes for the DIDAuthHandler +// These routes are public (no auth middleware) since they are part of the login flow. +func (h *DIDAuthHandler) RegisterRoutes(router fiber.Router) { + h.register(router, fiber.MethodPost, "/v1/did-auth/challenge", nil, h.Challenge) + h.register(router, fiber.MethodPost, "/v1/did-auth/verify", nil, h.Verify) +} + +// Challenge issues an authentication challenge for a DID +// @Summary Issue an authentication challenge +// @Description Issues a one-time nonce challenge for an ML-DSA-87 did:key identifier. The nonce is signed by the holder of the private key during verification. +// @Tags DID Auth +// @Accept json +// @Produce json +// @Param payload body requests.DIDChallenge true "DID to challenge" +// @Success 200 {object} responses.DIDChallengeResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /v1/did-auth/challenge [post] +func (h *DIDAuthHandler) Challenge(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.DIDChallenge + if err := c.Bind().Body(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + if errors := h.validator.ValidateChallenge(ctx, request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while issuing challenge [%+#v]", spew.Sdump(errors), request) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while issuing challenge") + } + + nonce := did.GenerateNonce() + id, err := h.nonces.Issue(ctx, request.DID, nonce) + if err != nil { + msg := fmt.Sprintf("cannot issue challenge for DID [%s]", request.DID) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "challenge issued successfully", fiber.Map{ + "id": id, + "nonce": nonce, + }) +} + +// Verify verifies DID ownership and returns the user +// @Summary Verify DID ownership and sign in +// @Description Verifies that the caller signed the issued challenge nonce with the ML-DSA-87 key matching the did:key identifier, then finds or creates the user and returns it (including the API key used for subsequent authenticated requests). +// @Tags DID Auth +// @Accept json +// @Produce json +// @Param payload body requests.DIDVerify true "DID, challenge nonce and signature" +// @Success 200 {object} responses.UserResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /v1/did-auth/verify [post] +func (h *DIDAuthHandler) Verify(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.DIDVerify + if err := c.Bind().Body(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + sanitized := request.Sanitize() + if errors := h.validator.ValidateVerify(ctx, sanitized); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while verifying DID [%+#v]", spew.Sdump(errors), request) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while verifying DID") + } + + if err := h.verifyChallenge(ctx, sanitized); err != nil { + msg := "failed to verify the challenge for the DID" + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "Failed to verify the DID challenge, request a new challenge and try again", + }) + } + + didValue := sanitized.DID + user, _, err := h.userRepository.LoadOrStore(ctx, entities.AuthContext{ + ID: userIDFromDID(didValue), + Email: didValue, + }) + if err != nil { + msg := fmt.Sprintf("cannot load or store user with DID [%s]", didValue) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "DID verified successfully", user) +} + +// userIDFromDID derives the stable user primary key from a did:key. An +// ML-DSA-87 did:key is ~3.5 KB, which exceeds Postgres' btree index row limit +// (2704 bytes) for users_pkey and every UserID foreign-key index, so the DID is +// kept in the unindexed email column and the primary key is a deterministic +// UUIDv5 of the full DID. +func userIDFromDID(didValue string) entities.UserID { + return entities.UserID(uuid.NewHash(sha256.New(), uuid.Nil, []byte(didValue), 5).String()) +} + +// verifyChallenge consumes the issued nonce only after the ML-DSA-87 signature +// over it verifies against the DID. +func (h *DIDAuthHandler) verifyChallenge(ctx context.Context, request requests.DIDVerify) error { + _, span := h.tracer.Start(ctx) + defer span.End() + + matched, err := h.nonces.Match(ctx, request.ID, request.DID, request.Nonce) + if err != nil { + return stacktrace.Propagate(err, "cannot read challenge") + } + if !matched { + return stacktrace.NewError("challenge not found, expired or mismatched") + } + + if !did.VerifyPQSignature(request.DID, request.Nonce, request.Signature) { + return stacktrace.NewError("ML-DSA-87 signature verification failed") + } + + consumed, err := h.nonces.Consume(ctx, request.ID, request.DID, request.Nonce) + if err != nil { + return stacktrace.Propagate(err, "cannot consume challenge") + } + if !consumed { + return stacktrace.NewError("challenge already consumed") + } + return nil +} diff --git a/api/pkg/handlers/did_auth_handler_test.go b/api/pkg/handlers/did_auth_handler_test.go new file mode 100644 index 000000000..25868dea1 --- /dev/null +++ b/api/pkg/handlers/did_auth_handler_test.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/did" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/cloudflare/circl/sign/mldsa/mldsa87" + "github.com/gofiber/fiber/v3" + "github.com/hirosassa/zerodriver" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errUserNotFound = errors.New("user not found") + +// fakeUserRepository stores users in memory for testing +type fakeUserRepository struct { + users map[entities.UserID]*entities.User +} + +func (r *fakeUserRepository) Store(_ context.Context, user *entities.User) error { + r.users[user.ID] = user + return nil +} + +func (r *fakeUserRepository) Update(_ context.Context, user *entities.User) error { + r.users[user.ID] = user + return nil +} + +func (r *fakeUserRepository) LoadAuthContext(_ context.Context, apiKey string) (entities.AuthContext, error) { + for _, user := range r.users { + if user.APIKey == apiKey { + return entities.AuthContext{ID: user.ID, Email: user.Email}, nil + } + } + return entities.AuthContext{}, errUserNotFound +} + +func (r *fakeUserRepository) Load(_ context.Context, userID entities.UserID) (*entities.User, error) { + user, ok := r.users[userID] + if !ok { + return nil, errUserNotFound + } + return user, nil +} + +func (r *fakeUserRepository) RotateAPIKey(_ context.Context, userID entities.UserID) (*entities.User, error) { + user, ok := r.users[userID] + if !ok { + return nil, errUserNotFound + } + user.APIKey = "uk_rotated" + return user, nil +} + +func (r *fakeUserRepository) LoadOrStore(_ context.Context, authUser entities.AuthContext) (*entities.User, bool, error) { + if user, ok := r.users[authUser.ID]; ok { + return user, false, nil + } + user := &entities.User{ + ID: authUser.ID, + Email: authUser.Email, + APIKey: "uk_testkey", + } + r.users[authUser.ID] = user + return user, true, nil +} + +func (r *fakeUserRepository) LoadBySubscriptionID(_ context.Context, subscriptionID string) (*entities.User, error) { + return nil, errUserNotFound +} + +func (r *fakeUserRepository) LoadByEmail(_ context.Context, email string) (*entities.User, error) { + for _, user := range r.users { + if user.Email == email { + return user, nil + } + } + return nil, errUserNotFound +} + +func (r *fakeUserRepository) Delete(_ context.Context, _ *entities.User) error { + return nil +} + +// testBufferedLogger creates a zerolog logger that writes to a buffer +func testBufferedLogger() telemetry.Logger { + var buffer bytes.Buffer + l := zerolog.New(&buffer).With().Timestamp().Logger() + driver := &zerodriver.Logger{Logger: &l} + return telemetry.NewZerologLogger("", map[string]string{}, driver, nil) +} + +// newDIDAuthTestHandler creates a DIDAuthHandler wired to a fake repository +func newDIDAuthTestHandler(repo repositories.UserRepository) *DIDAuthHandler { + logger := testBufferedLogger() + tracer := telemetry.NewOtelLogger("", logger) + validator := validators.NewDIDAuthHandlerValidator(logger, tracer) + return NewDIDAuthHandler(logger, tracer, validator, repo, nil) +} + +// performRequest runs a request against the handler and returns the status and decoded body +func performRequest(t *testing.T, h *DIDAuthHandler, method, path string, payload any) (*http.Response, fiber.Map) { + t.Helper() + app := fiber.New() + h.RegisterRoutes(app) + + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set(fiber.HeaderContentType, "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + + var decoded fiber.Map + require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded)) + return resp, decoded +} + +// pqTestUser is an in-memory ML-DSA-87 identity that can sign challenge nonces +// in the same bapp wire format the wallet uses. +type pqTestUser struct { + did string + sk *mldsa87.PrivateKey +} + +func newPQTestUser(t *testing.T) *pqTestUser { + t.Helper() + pub, sk, err := mldsa87.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubBytes := pub.Bytes() + prefixed := make([]byte, 0, 2+3+len(pubBytes)) + prefixed = append(prefixed, 0x05, 0x01, 0x01, 0x01) + prefixed = append(prefixed, byte(len(pubBytes)>>8), byte(len(pubBytes))) + prefixed = append(prefixed, pubBytes...) + + return &pqTestUser{did: did.DIDFromPQPub(prefixed), sk: sk} +} + +func (u *pqTestUser) sign(t *testing.T, nonceHex string) string { + t.Helper() + nonce, err := hex.DecodeString(nonceHex) + require.NoError(t, err) + + txHash := domainHash("BIGTANGLE-PQ-TX-v1", nonce) + mlMsg := domainHash("MLDSA-SIG-DOMAIN", txHash) + sig := make([]byte, mldsa87.SignatureSize) + require.NoError(t, mldsa87.SignTo(u.sk, mlMsg, nil, false, sig)) + + bundle := make([]byte, 0, 2+3+len(sig)) + bundle = append(bundle, 0x01, 0x01, 0x01) + bundle = append(bundle, byte(len(sig)>>8), byte(len(sig))) + bundle = append(bundle, sig...) + return hex.EncodeToString(bundle) +} + +func domainHash(domain string, data []byte) []byte { + h := sha256.New() + h.Write([]byte(domain)) + h.Write(data) + return h.Sum(nil) +} + +func TestDIDAuthHandler_ChallengeAndVerify(t *testing.T) { + // Arrange + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + user := newPQTestUser(t) + + // Act: challenge + challengeResp, challenge := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/challenge", requests.DIDChallenge{DID: user.did}) + require.Equal(t, 200, challengeResp.StatusCode) + challengeData := challenge["data"].(map[string]interface{}) + require.NotEmpty(t, challengeData["nonce"]) + require.NotEmpty(t, challengeData["id"]) + + nonce := challengeData["nonce"].(string) + id := challengeData["id"].(string) + + // Act: verify + verifyResp, decoded := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", requests.DIDVerify{ + ID: id, + DID: user.did, + Nonce: nonce, + Signature: user.sign(t, nonce), + Scheme: "mldsa", + }) + require.Equal(t, 200, verifyResp.StatusCode, "verify should succeed: %+#v", decoded) + + created := decoded["data"].(map[string]interface{}) + assert.Equal(t, user.did, created["email"]) + require.NotEmpty(t, created["id"]) + require.NotEmpty(t, created["api_key"]) + + _, err := repo.Load(context.Background(), userIDFromDID(user.did)) + assert.Nil(t, err) +} + +func TestDIDAuthHandler_ChallengeRejectsInvalidDID(t *testing.T) { + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + + resp, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/challenge", requests.DIDChallenge{DID: "did:key:z6Mkabc"}) + assert.Equal(t, 422, resp.StatusCode) +} + +func TestDIDAuthHandler_VerifyRejectsBadChallenge(t *testing.T) { + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + user := newPQTestUser(t) + + nonce := "deadbeef" + resp, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", requests.DIDVerify{ + ID: "some-unknown-challenge-id", + DID: user.did, + Nonce: nonce, + Signature: user.sign(t, nonce), + Scheme: "mldsa", + }) + + assert.Equal(t, 401, resp.StatusCode) + assert.Len(t, repo.users, 0) +} + +func TestDIDAuthHandler_VerifyRejectsWrongSignatureWithoutBurningChallenge(t *testing.T) { + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + user := newPQTestUser(t) + attacker := newPQTestUser(t) + + _, challenge := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/challenge", requests.DIDChallenge{DID: user.did}) + challengeData := challenge["data"].(map[string]interface{}) + nonce := challengeData["nonce"].(string) + id := challengeData["id"].(string) + + // A signature from another key must fail... + badResp, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", requests.DIDVerify{ + ID: id, + DID: user.did, + Nonce: nonce, + Signature: attacker.sign(t, nonce), + Scheme: "mldsa", + }) + assert.Equal(t, 401, badResp.StatusCode) + assert.Len(t, repo.users, 0) + + // ...and must not consume the challenge, so the real key can still use it. + goodResp, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", requests.DIDVerify{ + ID: id, + DID: user.did, + Nonce: nonce, + Signature: user.sign(t, nonce), + Scheme: "mldsa", + }) + assert.Equal(t, 200, goodResp.StatusCode) +} + +func TestDIDAuthHandler_VerifyRejectsReplayedChallenge(t *testing.T) { + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + user := newPQTestUser(t) + + _, challenge := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/challenge", requests.DIDChallenge{DID: user.did}) + challengeData := challenge["data"].(map[string]interface{}) + nonce := challengeData["nonce"].(string) + id := challengeData["id"].(string) + payload := requests.DIDVerify{ID: id, DID: user.did, Nonce: nonce, Signature: user.sign(t, nonce), Scheme: "mldsa"} + + first, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", payload) + require.Equal(t, 200, first.StatusCode) + + second, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", payload) + assert.Equal(t, 401, second.StatusCode) +} + +func TestDIDAuthHandler_VerifyRequiresFields(t *testing.T) { + repo := &fakeUserRepository{users: map[entities.UserID]*entities.User{}} + h := newDIDAuthTestHandler(repo) + + resp, _ := performRequest(t, h, fiber.MethodPost, "/v1/did-auth/verify", requests.DIDVerify{}) + assert.Equal(t, 422, resp.StatusCode) +} + +var _ repositories.UserRepository = (*fakeUserRepository)(nil) diff --git a/api/pkg/handlers/did_auth_nonce.go b/api/pkg/handlers/did_auth_nonce.go new file mode 100644 index 000000000..dd2bf4846 --- /dev/null +++ b/api/pkg/handlers/did_auth_nonce.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "context" + "sync" + "time" + + "github.com/NdoleStudio/httpsms/pkg/cache" + "github.com/google/uuid" +) + +// NonceExpiry is how long an issued challenge remains valid. +const NonceExpiry = 5 * time.Minute + +// NonceStore issues one-time DID auth challenges and consumes them on success. +type NonceStore interface { + // Issue stores a challenge and returns its id. + Issue(ctx context.Context, didValue string, nonce string) (id string, err error) + // Match reports whether a live challenge matches did+nonce without + // consuming it, so a failed signature attempt does not burn the challenge. + Match(ctx context.Context, id string, didValue string, nonce string) (bool, error) + // Consume atomically removes the challenge. It returns false when the + // challenge was already consumed, so a replay cannot succeed. + Consume(ctx context.Context, id string, didValue string, nonce string) (bool, error) +} + +// nonceEntry is a single issued challenge. +type nonceEntry struct { + did string + nonce string + expiresAt time.Time +} + +// memoryNonceStore is the single-process NonceStore (tests, single replica). +type memoryNonceStore struct { + mu sync.RWMutex + challs map[string]*nonceEntry +} + +func newMemoryNonceStore() *memoryNonceStore { + return &memoryNonceStore{challs: map[string]*nonceEntry{}} +} + +// Issue creates and stores a new challenge, returning its id. +func (store *memoryNonceStore) Issue(_ context.Context, didValue string, nonce string) (string, error) { + store.mu.Lock() + defer store.mu.Unlock() + + now := time.Now() + for id, entry := range store.challs { + if now.After(entry.expiresAt) { + delete(store.challs, id) + } + } + + id := uuid.NewString() + store.challs[id] = &nonceEntry{did: didValue, nonce: nonce, expiresAt: now.Add(NonceExpiry)} + return id, nil +} + +// Match reports whether a live challenge matches did+nonce without consuming it. +func (store *memoryNonceStore) Match(_ context.Context, id string, didValue string, nonce string) (bool, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + entry, ok := store.challs[id] + if !ok || entry.did != didValue || entry.nonce != nonce { + return false, nil + } + return time.Now().Before(entry.expiresAt), nil +} + +// Consume deletes a challenge, reporting whether it was still live and matching. +func (store *memoryNonceStore) Consume(_ context.Context, id string, didValue string, nonce string) (bool, error) { + store.mu.Lock() + defer store.mu.Unlock() + + entry, ok := store.challs[id] + if !ok { + return false, nil + } + delete(store.challs, id) + + if entry.did != didValue || entry.nonce != nonce || time.Now().After(entry.expiresAt) { + return false, nil + } + return true, nil +} + +// cacheNonceStore keeps challenges in the shared cache (Redis), so a challenge +// issued by one replica can be consumed by another. +type cacheNonceStore struct { + cache cache.Cache +} + +const nonceKeyPrefix = "did-auth:nonce:" + +// NewCacheNonceStore creates a NonceStore backed by the shared cache. +func NewCacheNonceStore(c cache.Cache) NonceStore { + return &cacheNonceStore{cache: c} +} + +// Issue stores a challenge in the cache under a fresh id. +func (store *cacheNonceStore) Issue(ctx context.Context, didValue string, nonce string) (string, error) { + id := uuid.NewString() + if err := store.cache.Set(ctx, nonceKeyPrefix+id, didValue+"\n"+nonce, NonceExpiry); err != nil { + return "", err + } + return id, nil +} + +// Match reads the challenge without consuming it. +func (store *cacheNonceStore) Match(ctx context.Context, id string, didValue string, nonce string) (bool, error) { + value, err := store.cache.Get(ctx, nonceKeyPrefix+id) + if err != nil { + return false, nil // absent or expired — not a match + } + return value == didValue+"\n"+nonce, nil +} + +// Consume atomically removes the challenge; a concurrent verifier that already +// consumed it gets false and the replay is rejected. +func (store *cacheNonceStore) Consume(ctx context.Context, id string, didValue string, nonce string) (bool, error) { + value, err := store.cache.GetDel(ctx, nonceKeyPrefix+id) + if err != nil { + return false, nil // already gone + } + return value == didValue+"\n"+nonce, nil +} diff --git a/api/pkg/handlers/did_auth_nonce_test.go b/api/pkg/handlers/did_auth_nonce_test.go new file mode 100644 index 000000000..dbb10f471 --- /dev/null +++ b/api/pkg/handlers/did_auth_nonce_test.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/cache" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + ttlCache "github.com/patrickmn/go-cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestCacheNonceStore() NonceStore { + logger := testBufferedLogger() + tracer := telemetry.NewOtelLogger("", logger) + return NewCacheNonceStore(cache.NewMemoryCache(tracer, ttlCache.New(time.Minute, 2*time.Minute))) +} + +func TestCacheNonceStoreSingleUse(t *testing.T) { + store := newTestCacheNonceStore() + ctx := context.Background() + + id, err := store.Issue(ctx, "did:key:zAlice", "nonce1") + require.NoError(t, err) + + matched, err := store.Match(ctx, id, "did:key:zAlice", "nonce1") + require.NoError(t, err) + assert.True(t, matched) + + // a wrong did does not match and does not consume + matched, _ = store.Match(ctx, id, "did:key:zMallory", "nonce1") + assert.False(t, matched) + + // a failed signature attempt (Match only, no Consume) leaves it live + matched, _ = store.Match(ctx, id, "did:key:zAlice", "nonce1") + assert.True(t, matched) + + consumed, err := store.Consume(ctx, id, "did:key:zAlice", "nonce1") + require.NoError(t, err) + assert.True(t, consumed) + + // replay is rejected + consumed, _ = store.Consume(ctx, id, "did:key:zAlice", "nonce1") + assert.False(t, consumed) + matched, _ = store.Match(ctx, id, "did:key:zAlice", "nonce1") + assert.False(t, matched) +} + +func TestCacheNonceStoreConsumeMismatch(t *testing.T) { + store := newTestCacheNonceStore() + ctx := context.Background() + + id, err := store.Issue(ctx, "did:key:zAlice", "nonce1") + require.NoError(t, err) + + consumed, err := store.Consume(ctx, id, "did:key:zMallory", "nonce1") + require.NoError(t, err) + assert.False(t, consumed) +} diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index afe0127a4..8718aef5f 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -64,6 +64,7 @@ func (h *MessageHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewa h.register(router, fiber.MethodPost, "/v1/messages/receive", middlewares, h.PostReceive) h.register(router, fiber.MethodPost, "/v1/messages/calls/missed", middlewares, h.PostCallMissed) h.register(router, fiber.MethodGet, "/v1/messages/outstanding", middlewares, h.GetOutstanding) + h.register(router, fiber.MethodGet, "/v1/messages/outstanding/list", middlewares, h.ListOutstandingIDs) } // PostSend a new entities.Message @@ -226,6 +227,40 @@ func (h *MessageHandler) GetOutstanding(c fiber.Ctx) error { return h.responseOK(c, "outstanding message fetched successfully", message) } +// ListOutstandingIDs returns IDs of entities.Message which are still to be sent by the mobile phone. +// It is used by phones polling without push notifications: statuses are left untouched, +// the phone claims each message with GetOutstanding before sending. +// @Summary List outstanding message IDs +// @Description List IDs of outstanding messages to be sent by an android phone polling without push notifications +// @Security ApiKeyAuth +// @Tags Messages +// @Accept json +// @Produce json +// @Success 200 {array} string +// @Failure 401 {object} responses.Unauthorized +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/outstanding/list [get] +func (h *MessageHandler) ListOutstandingIDs(c fiber.Ctx) error { + ctx, span := h.tracer.StartFromFiberCtx(c) + defer span.End() + + ctxLogger := h.tracer.CtxLogger(h.logger, span) + + authCtx := h.userFromContext(c) + ids, err := h.service.ListOutstandingIDs(ctx, services.MessageListOutstandingParams{ + UserID: authCtx.ID, + PhoneNumbers: authCtx.PhoneNumbers, + Limit: 100, + }) + if err != nil { + msg := fmt.Sprintf("cannot list outstanding messages [%s]", c.OriginalURL()) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "outstanding message IDs fetched successfully", ids) +} + // Index returns messages sent between 2 phone numbers // @Summary Get messages which are sent between 2 phone numbers // @Description Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order. diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go deleted file mode 100644 index e2c1fb837..000000000 --- a/api/pkg/middlewares/bearer_auth_middleware.go +++ /dev/null @@ -1,51 +0,0 @@ -package middlewares - -import ( - "context" - "fmt" - "strings" - - "firebase.google.com/go/auth" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" -) - -// BearerAuth authenticates a user based on the bearer token -func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *auth.Client) fiber.Handler { - logger = logger.WithService("middlewares.BearerAuth") - return func(c fiber.Ctx) error { - _, span := tracer.StartFromFiberCtx(c, "middlewares.BearerAuth") - defer span.End() - - authToken := c.Get(authHeaderBearer) - if !strings.HasPrefix(authToken, bearerScheme) { - span.AddEvent(fmt.Sprintf("The request header has no [%s] token", bearerScheme)) - return c.Next() - } - - if len(authToken) > len(bearerScheme)+1 { - authToken = authToken[len(bearerScheme)+1:] - } - - ctxLogger := tracer.CtxLogger(logger, span) - - token, err := authClient.VerifyIDToken(context.Background(), authToken) - if err != nil { - msg := fmt.Sprintf("invalid firebase id token [%s]", authToken) - ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) - return c.Next() - } - - span.AddEvent(fmt.Sprintf("[%s] token is valid", bearerScheme)) - - authUser := entities.AuthContext{ - Email: token.Claims["email"].(string), - ID: entities.UserID(token.Claims["user_id"].(string)), - } - - c.Locals(ContextKeyAuthUserID, authUser) - return c.Next() - } -} diff --git a/api/pkg/repositories/google_cloud_storage_attachment_repository.go b/api/pkg/repositories/google_cloud_storage_attachment_repository.go deleted file mode 100644 index d1e0eb926..000000000 --- a/api/pkg/repositories/google_cloud_storage_attachment_repository.go +++ /dev/null @@ -1,92 +0,0 @@ -package repositories - -import ( - "context" - "errors" - "fmt" - "io" - - "cloud.google.com/go/storage" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" -) - -// GoogleCloudStorageAttachmentRepository stores attachments in Google Cloud Storage -type GoogleCloudStorageAttachmentRepository struct { - logger telemetry.Logger - tracer telemetry.Tracer - client *storage.Client - bucket string -} - -// NewGoogleCloudStorageAttachmentRepository creates a new GoogleCloudStorageAttachmentRepository -func NewGoogleCloudStorageAttachmentRepository( - logger telemetry.Logger, - tracer telemetry.Tracer, - client *storage.Client, - bucket string, -) *GoogleCloudStorageAttachmentRepository { - return &GoogleCloudStorageAttachmentRepository{ - logger: logger.WithService(fmt.Sprintf("%T", &GoogleCloudStorageAttachmentRepository{})), - tracer: tracer, - client: client, - bucket: bucket, - } -} - -// Upload stores attachment data at the given path in GCS -func (s *GoogleCloudStorageAttachmentRepository) Upload(ctx context.Context, path string, data []byte, contentType string) error { - ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger) - defer span.End() - - writer := s.client.Bucket(s.bucket).Object(path).NewWriter(ctx) - writer.ContentType = contentType - - if _, err := writer.Write(data); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot write attachment to GCS path [%s]", path))) - } - - if err := writer.Close(); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot close GCS writer for path [%s]", path))) - } - - ctxLogger.Info(fmt.Sprintf("uploaded attachment to GCS path [%s/%s] with size [%d]", s.bucket, path, len(data))) - return nil -} - -// Download retrieves attachment data from the given path in GCS -func (s *GoogleCloudStorageAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) { - ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger) - defer span.End() - - reader, err := s.client.Bucket(s.bucket).Object(path).NewReader(ctx) - if err != nil { - msg := fmt.Sprintf("cannot open GCS reader for path [%s]", path) - if errors.Is(err, storage.ErrObjectNotExist) { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) - } - return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - defer reader.Close() - - data, err := io.ReadAll(reader) - if err != nil { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot read attachment from GCS path [%s]", path))) - } - - ctxLogger.Info(fmt.Sprintf("downloaded attachment from GCS path [%s/%s] with size [%d]", s.bucket, path, len(data))) - return data, nil -} - -// Delete removes an attachment at the given path in GCS -func (s *GoogleCloudStorageAttachmentRepository) Delete(ctx context.Context, path string) error { - ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger) - defer span.End() - - if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete GCS object at path [%s]", path))) - } - - ctxLogger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path)) - return nil -} diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index 032375668..00a208490 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -292,6 +292,31 @@ func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, use return message, nil } +// ListOutstandingIDs fetches IDs of messages still to be sent by the phone. +// Unlike GetOutstanding it does not change any status: the phone claims each +// message with GetOutstanding before sending, which keeps polling safe. +func (repository *gormMessageRepository) ListOutstandingIDs(ctx context.Context, userID entities.UserID, phoneNumbers []string, limit int) ([]uuid.UUID, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if limit <= 0 || limit > 100 { + limit = 100 + } + + ids := make([]uuid.UUID, 0) + query := repository.db.WithContext(ctx).Model(&entities.Message{}). + Where("user_id = ?", userID). + Where("status IN ?", []entities.MessageStatus{entities.MessageStatusScheduled, entities.MessageStatusPending, entities.MessageStatusExpired}) + if len(phoneNumbers) > 0 { + query = query.Where("owner IN ?", phoneNumbers) + } + if err := query.Order("order_timestamp ASC").Limit(limit).Pluck("id", &ids).Error; err != nil { + msg := fmt.Sprintf("cannot list outstanding messages with userID [%s]", userID) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return ids, nil +} + func (repository *gormMessageRepository) order(params IndexParams, defaultSortBy string) string { sortBy := defaultSortBy if len(params.SortBy) > 0 { diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go index c8f85fbb0..dd71429d1 100644 --- a/api/pkg/repositories/message_repository.go +++ b/api/pkg/repositories/message_repository.go @@ -33,6 +33,11 @@ type MessageRepository interface { // GetOutstanding fetches an entities.Message which is outstanding GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error) + // ListOutstandingIDs fetches IDs of entities.Message which are outstanding, + // without changing any status. The phone claims each message with + // GetOutstanding before sending, which keeps polling safe. + ListOutstandingIDs(ctx context.Context, userID entities.UserID, phoneNumbers []string, limit int) ([]uuid.UUID, error) + // Delete an entities.Message by ID Delete(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error diff --git a/api/pkg/requests/did_auth_request.go b/api/pkg/requests/did_auth_request.go new file mode 100644 index 000000000..386213a26 --- /dev/null +++ b/api/pkg/requests/did_auth_request.go @@ -0,0 +1,41 @@ +package requests + +import "strings" + +// DIDChallenge is the request to issue an authentication challenge for a DID +type DIDChallenge struct { + request + // DID is the did:key identifier to challenge + DID string `json:"did" example:"did:key:z..."` +} + +// Sanitize normalizes the DIDChallenge request +func (input *DIDChallenge) Sanitize() DIDChallenge { + input.DID = strings.TrimSpace(input.DID) + return *input +} + +// DIDVerify is the request to verify DID ownership and sign in +type DIDVerify struct { + request + // ID is the challenge ID returned by the challenge endpoint + ID string `json:"id" example:"32343a19-da5e-4b1b-a767-3298a73703ca"` + // DID is the did:key identifier to authenticate + DID string `json:"did" example:"did:key:z..."` + // Nonce is the challenge nonce returned by the challenge endpoint + Nonce string `json:"nonce" example:"9f86d0818..."` + // Signature is the hex-serialized ML-DSA-87 SignatureBundle over the nonce + Signature string `json:"signature" example:"0101..."` + // Scheme identifies the signature algorithm; only "mldsa" is supported + Scheme string `json:"scheme" example:"mldsa"` +} + +// Sanitize normalizes the DIDVerify request +func (input *DIDVerify) Sanitize() DIDVerify { + input.ID = strings.TrimSpace(input.ID) + input.DID = strings.TrimSpace(input.DID) + input.Nonce = strings.TrimSpace(strings.ToLower(input.Nonce)) + input.Signature = strings.TrimSpace(strings.ToLower(input.Signature)) + input.Scheme = strings.TrimSpace(strings.ToLower(input.Scheme)) + return *input +} diff --git a/api/pkg/responses/did_auth_responses.go b/api/pkg/responses/did_auth_responses.go new file mode 100644 index 000000000..08d6d9a8f --- /dev/null +++ b/api/pkg/responses/did_auth_responses.go @@ -0,0 +1,13 @@ +package responses + +// DIDChallengeResponse is the response with the challenge nonce +type DIDChallengeResponse struct { + response + Data DIDChallengeData `json:"data"` +} + +// DIDChallengeData holds the challenge identifier and nonce +type DIDChallengeData struct { + ID string `json:"id" example:"32343a19-da5e-4b1b-a767-3298a73703ca"` + Nonce string `json:"nonce" example:"9f86d0818..."` +} \ No newline at end of file diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go deleted file mode 100644 index 85060bb4e..000000000 --- a/api/pkg/services/emulator_fcm_client.go +++ /dev/null @@ -1,100 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "firebase.google.com/go/messaging" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" -) - -// EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. -type EmulatorFCMClient struct { - httpClient *http.Client - endpoint string - logger telemetry.Logger -} - -// NewEmulatorFCMClient creates a new EmulatorFCMClient. -func NewEmulatorFCMClient(httpClient *http.Client, endpoint string, logger telemetry.Logger) *EmulatorFCMClient { - return &EmulatorFCMClient{ - httpClient: httpClient, - endpoint: endpoint, - logger: logger, - } -} - -// emulatorFCMRequest is the payload sent to the emulator's FCM endpoint. -type emulatorFCMRequest struct { - Message *emulatorFCMMessage `json:"message"` -} - -type emulatorFCMMessage struct { - Token string `json:"token"` - Data map[string]string `json:"data,omitempty"` - Android *emulatorAndroid `json:"android,omitempty"` -} - -type emulatorAndroid struct { - Priority string `json:"priority,omitempty"` -} - -// emulatorFCMResponse is the response from the emulator. -type emulatorFCMResponse struct { - Name string `json:"name"` -} - -// Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { - payload := &emulatorFCMRequest{ - Message: &emulatorFCMMessage{ - Token: message.Token, - Data: message.Data, - }, - } - if message.Android != nil { - payload.Message.Android = &emulatorAndroid{ - Priority: message.Android.Priority, - } - } - - body, err := json.Marshal(payload) - if err != nil { - return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator") - } - - url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM") - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to emulator at [%s]", url)) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", stacktrace.Propagate(err, "cannot read emulator FCM response body") - } - - if resp.StatusCode != http.StatusOK { - return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody)) - } - - var result emulatorFCMResponse - if err = json.Unmarshal(respBody, &result); err != nil { - return "", stacktrace.Propagate(err, "cannot decode emulator FCM response") - } - - c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name)) - return result.Name, nil -} diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go deleted file mode 100644 index 4e56f3167..000000000 --- a/api/pkg/services/fcm_client.go +++ /dev/null @@ -1,28 +0,0 @@ -package services - -import ( - "context" - - "firebase.google.com/go/messaging" -) - -// FCMClient is the interface for sending Firebase Cloud Messaging notifications. -type FCMClient interface { - // Send sends a message via FCM and returns the message name on success. - Send(ctx context.Context, message *messaging.Message) (string, error) -} - -// FirebaseFCMClient wraps the real Firebase messaging.Client. -type FirebaseFCMClient struct { - client *messaging.Client -} - -// NewFirebaseFCMClient creates a new FirebaseFCMClient. -func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { - return &FirebaseFCMClient{client: client} -} - -// Send sends a message via the real Firebase SDK. -func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { - return c.client.Send(ctx, message) -} diff --git a/api/pkg/services/google_cloud_push_queue_service.go b/api/pkg/services/google_cloud_push_queue_service.go deleted file mode 100644 index d22dac83d..000000000 --- a/api/pkg/services/google_cloud_push_queue_service.go +++ /dev/null @@ -1,111 +0,0 @@ -package services - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/avast/retry-go/v5" - - cloudtasks "cloud.google.com/go/cloudtasks/apiv2" - "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" - "google.golang.org/protobuf/types/known/timestamppb" -) - -type googlePushQueue struct { - queueConfig PushQueueConfig - logger telemetry.Logger - tracer telemetry.Tracer - client *cloudtasks.Client -} - -// NewGooglePushQueue creates a new googlePushQueue -func NewGooglePushQueue( - logger telemetry.Logger, - tracer telemetry.Tracer, - client *cloudtasks.Client, - queueConfig PushQueueConfig, -) PushQueue { - return &googlePushQueue{ - client: client, - tracer: tracer, - logger: logger, - queueConfig: queueConfig, - } -} - -// Enqueue a task to the queue -func (queue *googlePushQueue) Enqueue(ctx context.Context, task *PushQueueTask, timeout time.Duration) (queueID string, err error) { - err = retry.New(retry.Attempts(3)).Do(func() error { - queueID, err = queue.enqueueImpl(ctx, task, timeout) - return err - }) - return queueID, err -} - -// enqueueImpl a task to the queue -func (queue *googlePushQueue) enqueueImpl(ctx context.Context, task *PushQueueTask, timeout time.Duration) (queueID string, err error) { - ctx, span := queue.tracer.Start(ctx) - defer span.End() - - ctxLogger := queue.tracer.CtxLogger(queue.logger, span) - - headers := map[string]string{"Content-Type": "application/json"} - for key, value := range task.Headers { - headers[key] = value - } - - // Build the Task payload. - req := &cloudtaskspb.CreateTaskRequest{ - Parent: queue.queueConfig.Name, - Task: &cloudtaskspb.Task{ - MessageType: &cloudtaskspb.Task_HttpRequest{ - HttpRequest: &cloudtaskspb.HttpRequest{ - Headers: headers, - HttpMethod: queue.httpMethodToProtoHTTPMethod(task.Method), - Url: task.URL, - }, - }, - ScheduleTime: timestamppb.New(time.Now().UTC().Add(timeout)), - }, - } - - // Add a payload message if one is present. - req.Task.GetHttpRequest().Body = task.Body - - requestCtx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - - queueTask, err := queue.client.CreateTask(requestCtx, req) - if err != nil { - msg := fmt.Sprintf("cannot schedule task [%s] to URL [%s]", string(task.Body), task.URL) - ctxLogger.Error(stacktrace.Propagate(err, msg)) - return queueID, queue.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - ctxLogger.Info(fmt.Sprintf( - "item added to [%s] queue with id [%s] and schedule [%s]", - queue.queueConfig.Name, - queueTask.Name, - queueTask.GetScheduleTime().AsTime(), - )) - - return queueTask.Name, nil -} - -func (queue *googlePushQueue) httpMethodToProtoHTTPMethod(httpMethod string) cloudtaskspb.HttpMethod { - method, ok := map[string]cloudtaskspb.HttpMethod{ - http.MethodGet: cloudtaskspb.HttpMethod_GET, - http.MethodPost: cloudtaskspb.HttpMethod_POST, - http.MethodPut: cloudtaskspb.HttpMethod_PUT, - }[httpMethod] - - if !ok { - return cloudtaskspb.HttpMethod_POST - } - - return method -} diff --git a/api/pkg/services/marketting_service.go b/api/pkg/services/marketting_service.go index 860297648..b05643c33 100644 --- a/api/pkg/services/marketting_service.go +++ b/api/pkg/services/marketting_service.go @@ -3,12 +3,11 @@ package services import ( "context" "fmt" - "strings" semconv "go.opentelemetry.io/otel/semconv/v1.10.0" - "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" plunk "github.com/NdoleStudio/plunk-go" "github.com/gofiber/fiber/v3" @@ -17,24 +16,24 @@ import ( // MarketingService is handles marketing requests type MarketingService struct { - logger telemetry.Logger - tracer telemetry.Tracer - authClient *auth.Client - plunkClient *plunk.Client + logger telemetry.Logger + tracer telemetry.Tracer + userRepository repositories.UserRepository + plunkClient *plunk.Client } // NewMarketingService creates a new instance of the MarketingService func NewMarketingService( logger telemetry.Logger, tracer telemetry.Tracer, - authClient *auth.Client, + userRepository repositories.UserRepository, plunkClient *plunk.Client, ) *MarketingService { return &MarketingService{ - logger: logger.WithService(fmt.Sprintf("%T", &MarketingService{})), - tracer: tracer, - authClient: authClient, - plunkClient: plunkClient, + logger: logger.WithService(fmt.Sprintf("%T", &MarketingService{})), + tracer: tracer, + userRepository: userRepository, + plunkClient: plunkClient, } } @@ -67,18 +66,18 @@ func (service *MarketingService) CreateContact(ctx context.Context, userID entit ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - userRecord, err := service.authClient.GetUser(ctx, userID.String()) + user, err := service.userRepository.Load(ctx, userID) if err != nil { - msg := fmt.Sprintf("cannot get auth user with id [%s]", userID) + msg := fmt.Sprintf("cannot load user with id [%s]", userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - data := service.attributes(userRecord) + data := fiber.Map{} data[string(semconv.ServiceNameKey)] = "httpsms.com" - data[string(semconv.EnduserIDKey)] = userRecord.UID + data[string(semconv.EnduserIDKey)] = string(user.ID) event, _, err := service.plunkClient.Tracker.TrackEvent(ctx, &plunk.TrackEventRequest{ - Email: userRecord.Email, + Email: user.Email, Event: "contact.created", Subscribed: true, Data: data, @@ -91,22 +90,3 @@ func (service *MarketingService) CreateContact(ctx context.Context, userID entit ctxLogger.Info(fmt.Sprintf("user [%s] added to marketting list with contact ID [%s] and event ID [%s]", userID, event.Data.Contact, event.Data.Event)) return nil } - -func (service *MarketingService) attributes(user *auth.UserRecord) map[string]any { - name := strings.TrimSpace(user.DisplayName) - if name == "" { - return fiber.Map{} - } - - parts := strings.Split(name, " ") - if len(parts) == 1 { - return fiber.Map{ - "firstName": name, - } - } - - return fiber.Map{ - "firstName": strings.Join(parts[0:len(parts)-1], " "), - "lastName": parts[len(parts)-1], - } -} diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 56766c981..1d8b316cc 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -109,6 +109,28 @@ func (service *MessageService) GetOutstanding(ctx context.Context, params Messag return message, nil } +// MessageListOutstandingParams parameters for listing outstanding messages +type MessageListOutstandingParams struct { + UserID entities.UserID + PhoneNumbers []string + Limit int +} + +// ListOutstandingIDs fetches IDs of messages still to be sent by the phone. +// Statuses are left untouched: the phone claims each message with +// GetOutstanding before sending. +func (service *MessageService) ListOutstandingIDs(ctx context.Context, params MessageListOutstandingParams) ([]uuid.UUID, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + ids, err := service.repository.ListOutstandingIDs(ctx, params.UserID, params.PhoneNumbers, params.Limit) + if err != nil { + msg := fmt.Sprintf("could not list outstanding messages with params [%s]", spew.Sdump(params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return ids, nil +} + // DeleteAllForUser deletes all entities.Message for an entities.UserID. func (service *MessageService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 79b430378..fcd055359 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -9,7 +9,6 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" - "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -18,7 +17,9 @@ import ( "go.opentelemetry.io/otel/trace" ) -// PhoneNotificationService sends out notifications to mobile phones +// PhoneNotificationService sends out notifications to mobile phones. +// Phones without push support poll for outstanding messages instead, so no +// push client is needed. type PhoneNotificationService struct { service logger telemetry.Logger @@ -26,7 +27,6 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient eventDispatcher *EventDispatcher } @@ -34,18 +34,16 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, dispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ - logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), - tracer: tracer, - messagingClient: messagingClient, - phoneNotificationRepository: phoneNotificationRepository, - phoneRepository: phoneRepository, + logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), + tracer: tracer, + phoneNotificationRepository: phoneNotificationRepository, + phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, eventDispatcher: dispatcher, } @@ -90,33 +88,8 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - if phone.FcmToken == nil { - msg := fmt.Sprintf("phone with id [%s] has no FCM token", phone.ID) - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - result, err := service.messagingClient.Send(ctx, &messaging.Message{ - Data: map[string]string{ - "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), - }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, - }) - if err != nil { - msg := fmt.Sprintf("cannot send heartbeat FCM to phone with id [%s] for user [%s]", phone.ID, phone.UserID) - ctxLogger.Warn(stacktrace.Propagate(err, msg)) - return nil - } - - ctxLogger.Info(fmt.Sprintf( - "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", - result, - payload.PhoneID, - payload.UserID, - payload.MonitorID, - )) + // Phones poll for heartbeats and outstanding messages; there is no push. + ctxLogger.Info(fmt.Sprintf("skipping heartbeat push for phone with ID [%s], phone polls instead", phone.ID)) return nil } @@ -141,37 +114,10 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone return service.handleNotificationFailed(ctx, errors.New(msg), params) } - if phone.FcmToken == nil { - msg := fmt.Sprintf("phone with id [%s] has no FCM token", phone.ID) - return service.handleNotificationFailed(ctx, errors.New(msg), params) - } - - ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ - Data: map[string]string{ - "KEY_MESSAGE_ID": params.MessageID.String(), - }, - Android: &messaging.AndroidConfig{ - Priority: "normal", - TTL: &ttl, - }, - Token: *phone.FcmToken, - }) - if err != nil { - ctxLogger.Warn(stacktrace.Propagate( - err, - fmt.Sprintf( - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", - phone.ID, - phone.UserID, - params.MessageID, - ), - )) - msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) - return service.handleNotificationFailed(ctx, errors.New(msg), params) - } - - return service.handleNotificationSent(ctx, phone, result, params) + // Phones poll for outstanding messages; the notification is handed off + // here and the phone claims each message with GetOutstanding. + ctxLogger.Info(fmt.Sprintf("leaving notification [%s] for message [%s] for phone [%s] to poll", params.PhoneNotificationID, params.MessageID, phone.ID)) + return service.handleNotificationSent(ctx, phone, "poll", params) } // PhoneNotificationScheduleParams are parameters for sending a notification diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index 20c924b44..c237dd9ff 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -7,7 +7,6 @@ import ( "net/http" "time" - "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/emails" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/lemonsqueezy-go" @@ -29,7 +28,6 @@ type UserService struct { mailer emails.Mailer repository repositories.UserRepository dispatcher *EventDispatcher - authClient *auth.Client lemonsqueezyClient *lemonsqueezy.Client httpClient *http.Client } @@ -43,7 +41,6 @@ func NewUserService( emailFactory emails.UserEmailFactory, lemonsqueezyClient *lemonsqueezy.Client, dispatcher *EventDispatcher, - authClient *auth.Client, httpClient *http.Client, ) (s *UserService) { return &UserService{ @@ -53,7 +50,6 @@ func NewUserService( emailFactory: emailFactory, repository: repository, dispatcher: dispatcher, - authClient: authClient, lemonsqueezyClient: lemonsqueezyClient, httpClient: httpClient, } @@ -536,16 +532,12 @@ func (service *UserService) UpdateSubscription(ctx context.Context, params *even return nil } -// DeleteAuthUser deletes an entities.AuthContext from firebase +// DeleteAuthUser removes the external auth identity for a user. +// External auth is DID/API-key based, so there is nothing to delete remotely. func (service *UserService) DeleteAuthUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - if err := service.authClient.DeleteUser(ctx, userID.String()); err != nil { - msg := fmt.Sprintf("could not delete [entities.AuthContext] from firebase with ID [%s]", userID) - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - ctxLogger.Info(fmt.Sprintf("deleted [entities.AuthContext] from firebase for user with ID [%s]", userID)) + ctxLogger.Info(fmt.Sprintf("no external auth identity to delete for user with ID [%s]", userID)) return nil } diff --git a/api/pkg/validators/did_auth_handler_validator.go b/api/pkg/validators/did_auth_handler_validator.go new file mode 100644 index 000000000..33c77622c --- /dev/null +++ b/api/pkg/validators/did_auth_handler_validator.go @@ -0,0 +1,73 @@ +package validators + +import ( + "context" + "fmt" + "net/url" + + "github.com/NdoleStudio/httpsms/pkg/did" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" +) + +// DIDAuthHandlerValidator validates models used in handlers.DIDAuthHandler +type DIDAuthHandlerValidator struct { + validator + logger telemetry.Logger + tracer telemetry.Tracer +} + +// NewDIDAuthHandlerValidator creates a new handlers.DIDAuthHandler validator +func NewDIDAuthHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, +) (v *DIDAuthHandlerValidator) { + return &DIDAuthHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", v)), + tracer: tracer, + } +} + +// ValidateChallenge validates requests.DIDChallenge +func (validator *DIDAuthHandlerValidator) ValidateChallenge(_ context.Context, request requests.DIDChallenge) url.Values { + return validator.validateDID(request.DID) +} + +// ValidateVerify validates requests.DIDVerify +func (validator *DIDAuthHandlerValidator) ValidateVerify(_ context.Context, request requests.DIDVerify) url.Values { + errors := validator.validateDID(request.DID) + + if request.ID == "" { + errors.Add("id", "The id field is required") + } + + if request.Nonce == "" { + errors.Add("nonce", "The nonce field is required") + } + + if request.Signature == "" { + errors.Add("signature", "The signature field is required") + } + + if request.Scheme != "" && request.Scheme != "mldsa" { + errors.Add("scheme", "The scheme field must be \"mldsa\"") + } + + return errors +} + +// validateDID validates a did:key identifier +func (validator *DIDAuthHandlerValidator) validateDID(value string) url.Values { + errors := url.Values{} + + if value == "" { + errors.Add("did", "The did field is required") + return errors + } + + if !did.IsValidPQDID(value) { + errors.Add("did", "The did field must be a valid ML-DSA-87 did:key identifier") + } + + return errors +} diff --git a/web/Dockerfile b/web/Dockerfile index cadaac92d..09f6219de 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,8 +5,8 @@ WORKDIR /app COPY package.json pnpm-lock.yaml ./ -# Install pnpm -RUN npm install -g pnpm +# Install pnpm pinned to packageManager (unpinned latest breaks musl version switch) +RUN npm install -g pnpm@10.33.0 RUN pnpm install --ignore-scripts && pnpm rebuild diff --git a/web/app/components/DidLogin.vue b/web/app/components/DidLogin.vue new file mode 100644 index 000000000..c8c0b780a --- /dev/null +++ b/web/app/components/DidLogin.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/web/app/components/FirebaseAuth.vue b/web/app/components/FirebaseAuth.vue deleted file mode 100644 index 1c8e076af..000000000 --- a/web/app/components/FirebaseAuth.vue +++ /dev/null @@ -1,491 +0,0 @@ - - - - - diff --git a/web/app/components/MessageThreadHeader.vue b/web/app/components/MessageThreadHeader.vue index 6d13a7e20..6b2819fc1 100644 --- a/web/app/components/MessageThreadHeader.vue +++ b/web/app/components/MessageThreadHeader.vue @@ -1,6 +1,5 @@