From 5bd0036ee133b3085ee8f84faaa186ce73332f86 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 23 Oct 2026 23:25:46 +0200 Subject: [PATCH] feat(backend): add a way to proof instance id without sharing id / making id available (#13030) * add a way to proof instance identification without publicly sharing id * fix test --- docs/docs/settings/global.md | 1 + src/backend/InvenTree/InvenTree/api.py | 17 ++++- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/proof.py | 56 +++++++++++++++ src/backend/InvenTree/InvenTree/test_api.py | 68 ++++++++++++++++++- .../InvenTree/common/setting/system.py | 8 +++ .../pages/Index/Settings/SystemSettings.tsx | 1 + 7 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 src/backend/InvenTree/InvenTree/proof.py diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 08273b91e2..48b3313cfc 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -28,6 +28,7 @@ Configuration of basic server settings: {{ globalsetting("INVENTREE_INSTANCE_TITLE") }} {{ globalsetting("INVENTREE_INSTANCE_ID", default="Randomly generated value") }} {{ globalsetting("INVENTREE_ANNOUNCE_ID") }} +{{ globalsetting("INVENTREE_LINK_PROOF") }} {{ globalsetting("INVENTREE_SHOW_SUPERUSER_BANNER") }} {{ globalsetting("INVENTREE_SHOW_ADMIN_BANNER") }} {{ globalsetting("INVENTREE_RESTRICT_ABOUT") }} diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index e48a89d706..c00f2292b3 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -33,6 +33,7 @@ from common.settings import get_global_setting from InvenTree import helpers, ready from InvenTree.auth_overrides import registration_enabled from InvenTree.mixins import ListCreateAPI +from InvenTree.proof import calculate_proof from InvenTree.tasks import batch_offload_tasks from plugin.base.event.events import batch_events from plugin.serializers import MetadataSerializer @@ -248,6 +249,9 @@ class InfoApiSerializer(serializers.Serializer): server = serializers.CharField(read_only=True) id = serializers.CharField(read_only=True, allow_null=True) + link = serializers.CharField( + read_only=True, allow_null=True, help_text='Temporary identity proof' + ) version = serializers.CharField(read_only=True) instance = serializers.CharField(read_only=True) apiVersion = serializers.IntegerField(read_only=True) # noqa: N815 @@ -286,11 +290,20 @@ class InfoView(APIView): return OrmQ.objects.count() @extend_schema( + parameters=[ + OpenApiParameter( + name='link_nonce', + type=str, + location=OpenApiParameter.QUERY, + required=False, + description='Nonce for short-term identity proof', + ) + ], responses={ 200: OpenApiResponse( response=InfoApiSerializer, description='InvenTree server information' ) - } + }, ) def get(self, request, *args, **kwargs): """Serve current server information.""" @@ -343,6 +356,8 @@ class InfoView(APIView): }, 'system_state': {'cors_allow_all': settings.CORS_ALLOW_ALL_ORIGINS}, } + if nonce := request.query_params.get('link_nonce'): + data['link'] = calculate_proof(nonce) return JsonResponse(data) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index ac7063573b..f1c0d96927 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 562 +INVENTREE_API_VERSION = 563 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v563 -> 2026-10-22 : https://github.com/inventree/InvenTree/pull/13030 + - Adds optional 'link_nonce' mechanism to the API root + v562 -> 2026-10-21 : https://github.com/inventree/InvenTree/pull/13034 - Adds new "pricing" user role, covering the PartPricing, PartSellPriceBreak and PartInternalPriceBreak models - The "part" role no longer grants access to part pricing data diff --git a/src/backend/InvenTree/InvenTree/proof.py b/src/backend/InvenTree/InvenTree/proof.py new file mode 100644 index 0000000000..8511869f4d --- /dev/null +++ b/src/backend/InvenTree/InvenTree/proof.py @@ -0,0 +1,56 @@ +"""Functions to use HMAC and HKDF for a limited time proof of identity based on the instance ID.""" + +import functools +import hashlib +import hmac +import re +from datetime import datetime as dt +from datetime import timezone + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from common.settings import get_global_setting +from InvenTree.helpers import str2bool + +PROOF_NONCE_REGEX = re.compile(r'[A-Za-z0-9_-]{1,64}') + + +@functools.lru_cache +def _link_subkey(instance_id: str) -> bytes: + """Use HKDF to calculate a subkey from the instance ID.""" + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, info=b'inventree-link-v1' + ).derive(instance_id.encode('utf-8')) + + +def calculate_proof( + nonce, instance: str | None = None, epoch: str | None = None +) -> str | None: + """Return the proof for a nonce, optionally for a different instance ID.""" + if not nonce or not PROOF_NONCE_REGEX.fullmatch(nonce): + return None + + if not str2bool( + get_global_setting( + 'INVENTREE_LINK_PROOF', environment_key='INVENTREE_LINK_PROOF' + ) + ): + return None + + # Get or use instance id; ensure we do not trigger generation + instance_id = ( + instance + if instance + else get_global_setting('INVENTREE_INSTANCE_ID', backup_value='', create=False) + ) + if not instance_id: + return None + + if epoch is None: + epoch = dt.now(timezone.utc).strftime('%Y-%m') + + _message = (epoch + nonce).encode('utf-8') + return hmac.new( + _link_subkey(str(instance_id)), _message, hashlib.sha256 + ).hexdigest()[:32] diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 6bf84b2018..cb9fc8b344 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -1,12 +1,16 @@ """Low level tests for the InvenTree API.""" import hashlib +import hmac from base64 import b64encode +from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory +from unittest import mock from urllib.parse import parse_qs, urlencode, urlsplit from django.contrib.auth import get_user_model +from django.core.cache import cache from django.core.exceptions import AppRegistryNotReady from django.test import TestCase, override_settings from django.urls import reverse @@ -14,11 +18,14 @@ from django.urls import reverse from oauth2_provider.models import Application from rest_framework import status +from common.models import InvenTreeSetting +from common.settings import set_global_setting from InvenTree.api import read_license_file from InvenTree.api_version import INVENTREE_API_VERSION from InvenTree.apps import DEFAULT_OIDC_APP_ID from InvenTree.exceptions import exception_handler -from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase +from InvenTree.proof import calculate_proof +from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, in_env_context from InvenTree.version import inventreeApiText, parse_version_text from users.models import ApiToken from users.ruleset import RULESET_NAMES @@ -898,6 +905,65 @@ class GeneralApiTests(InvenTreeAPITestCase): self.assertIn('inventree-ui-notification', keys) self.assertIn('inventreelabel', keys) + def test_info_view_link(self): + """Test the link proof in the 'info-view' endpoint.""" + instance_id = 'c0ffee00-1234-4abc-8def-0123456789ab' + set_global_setting('INVENTREE_INSTANCE_ID', instance_id) + self.client.logout() + url = reverse('api-inventree-info') + + prk = hmac.new(b'\x00' * 32, instance_id.encode(), hashlib.sha256).digest() + key = hmac.new(prk, b'inventree-link-v1\x01', hashlib.sha256).digest() + expected = hmac.new(key, b'2026-10abc_DEF-123', hashlib.sha256).hexdigest() + self.assertEqual(expected[:32], '301acdcf01baec22a4f2f5391a55d002') + + # overwrite time + with mock.patch('InvenTree.proof.dt', wraps=datetime) as mock_dt: + mock_dt.now.return_value = datetime(2026, 10, 14, tzinfo=timezone.utc) + response = self.client.get(url, {'link_nonce': 'abc_DEF-123'}) + + data = response.json() + self.assertEqual(data['link'], expected[:32]) + self.assertIsNone(data['id']) + self.assertNotIn(instance_id, response.content.decode()) + + self.assertNotIn('link', self.client.get(url).json()) + + set_global_setting('INVENTREE_LINK_PROOF', False) + response_data = self.client.get(url, {'link_nonce': 'abc'}).json() + self.assertIsNone(response_data['link']) + + def test_link_proof(self): + """Test the link proof helper.""" + set_global_setting('INVENTREE_INSTANCE_ID', 'abc') + + # overwrite time + with mock.patch('InvenTree.proof.dt', wraps=datetime) as mock_dt: + mock_dt.now.return_value = datetime(2026, 10, 1, tzinfo=timezone.utc) + link = calculate_proof('nonce') + self.assertEqual(len(link), 32) + self.assertEqual(link, calculate_proof('nonce')) + self.assertNotEqual(link, calculate_proof('other')) + self.assertIsNotNone(calculate_proof('a' * 64)) + + # broken data scenarios + for nonce in [None, '', 'a' * 65, 'a.b', 'a b', 'abc\n', '\u00e4']: + self.assertIsNone(calculate_proof(nonce), nonce) + + with in_env_context({'INVENTREE_LINK_PROOF': 'False'}): + self.assertIsNone(calculate_proof('nonce')) + + mock_dt.now.return_value = datetime(2026, 11, 1, tzinfo=timezone.utc) + self.assertNotEqual(link, calculate_proof('nonce')) + + # missing id is not created; proof is not provided + InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE_ID').delete() + cache.clear() + self.assertIsNone(calculate_proof('nonce')) + self.assertFalse( + InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE_ID').exists() + ) + def test_generic_metadata_lookup_field_injection(self): """The generic metadata endpoint must not accept arbitrary lookup expressions. diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 1ae86ef800..6a924fa93d 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -237,6 +237,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'INVENTREE_LINK_PROOF': { + 'name': _('Allow Link Proof'), + 'description': _( + 'Allow other instances to verify the server identity without revealing the instance ID' + ), + 'default': True, + 'validator': bool, + }, 'INVENTREE_INSTANCE': { 'name': _('Server Instance Name'), 'default': 'InvenTree', diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index e9b6227e1f..10a58cbe3e 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -52,6 +52,7 @@ export default function SystemSettings() { 'INVENTREE_INSTANCE_TITLE', 'INVENTREE_INSTANCE_ID', 'INVENTREE_ANNOUNCE_ID', + 'INVENTREE_LINK_PROOF', 'INVENTREE_SHOW_SUPERUSER_BANNER', 'INVENTREE_SHOW_ADMIN_BANNER', 'INVENTREE_RESTRICT_ABOUT',