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
This commit is contained in:
Matthias Mair authored and GitHub committed 2026-10-24 08:25:46 +11:00
1 parent d11b74fd85
commit 5bd0036ee1
7 files changed
+153 -3

No files matched your search

+1
View File
@@ -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") }}
+16 -1
View File
@@ -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)
@@ -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
+56
View File
@@ -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]
+67 -1
View File
@@ -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.
@@ -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',
@@ -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',