Discover signed runtime artifacts from public repository fallback

This commit is contained in:
Codex
2026-10-13 21:56:27 +03:00
parent d625733207
commit 261ee28f77
3 changed files with 154 additions and 5 deletions
+24 -3
View File
@@ -3,7 +3,11 @@
The new ISO includes an independent updater and an independently pinned Ed25519
public key. Discovery uses the public Gitea repository at
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source.
An empty releases list is normal and reports no update.
If Gitea has no published runtime release candidate, discovery checks the fixed
public repository path `releases/stable/` on branch `main`. If that path has no
manifest (HTTP 404), it reports no update. Both locations use the same independently
pinned signature key; Git branch contents and repository ownership do not establish
release trust.
```sh
hydra update check
@@ -82,7 +86,16 @@ python3 dev/release.py \
The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`.
Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed
version. Each new release needs a strictly increasing integer sequence; never
version. Alternatively, when the native Gitea release list has no runtime candidates,
publish those same three passive artifacts together in `releases/stable/` on the
repository's `main` branch. The raw HTTPS prefix is fixed:
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/`.
The branch serves as a discovery location; the verified manifest supplies its
version and sequence. Keep historical signed artifacts under immutable versions
outside this moving stable pointer. Native Gitea runtime releases take precedence;
a present native candidate, including one already installed, disables this fallback.
Each new release needs a strictly increasing integer sequence; never
reuse a sequence or mutate assets at an existing version. Rollback retains the
highest accepted sequence independently of the current installed version.
The builder is deterministic for identical source, version, sequence, key and
@@ -105,7 +118,15 @@ The maximum manifest is 256 KiB, archive/expanded payload 16 MiB, individual fil
per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery
checks up to the latest 20 published release records, skipping draft/prerelease
records. Publish runtime releases regularly enough that the current stable release
remains in that window.
remains in that window. Only an otherwise successful discovery with no runtime
candidates activates the repository fallback. API errors, malformed native assets,
tag mismatches and bad signatures remain visible errors and never trigger fallback.
For the repository path, only a missing `manifest.json` (404) means no release;
a missing signature, server error or invalid signature is an error. The ZIP is
fetched only for apply after manifest verification, and its signed hash/size and
all existing download bounds/redirect restrictions still apply. A branch update
between requests may cause a safe verification failure; retry after publication
finishes.
For offline/local verification and application:
+18 -1
View File
@@ -15,6 +15,7 @@ import stat
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
@@ -26,6 +27,7 @@ FILE_LIMIT = 4 * 1024 * 1024
MAX_FILES = 256
ORIGIN = 'https://git.shanaty.ru'
RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20'
REPOSITORY_RELEASE = ORIGIN + '/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc',
'usr/share/hydra/fastfetch.txt'}
@@ -516,6 +518,21 @@ def fetch(url, limit):
return b''.join(chunks)
def discover_repository(public_key):
# The mutable branch locates passive bytes; only the pinned signature grants trust.
try:
raw = fetch(REPOSITORY_RELEASE + 'manifest.json', MANIFEST_LIMIT)
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
exc.close()
return None
# A present manifest with missing/broken companions is an error, never "no release".
signature = fetch(REPOSITORY_RELEASE + 'manifest.sig', 64)
manifest = validate_manifest(raw, signature, public_key)
return manifest, raw, signature, REPOSITORY_RELEASE + 'runtime.zip'
def discover(public_key):
releases = parse_json(fetch(RELEASES, 1024 * 1024))
if not isinstance(releases, list) or len(releases) > 20:
@@ -549,7 +566,7 @@ def discover(public_key):
raise UpdateError('Release tag does not match signed immutable version')
candidates.append((manifest, raw, signature, found['runtime.zip']))
if not candidates:
return None
return discover_repository(public_key)
sequences = [item[0]['sequence'] for item in candidates]
if len(sequences) != len(set(sequences)):
raise UpdateError('Duplicate release sequence')
+112 -1
View File
@@ -250,7 +250,14 @@ CALL
def test_empty_releases_and_verified_local_check_do_not_create_state(self):
module = engine()
from unittest.mock import patch
with patch.object(module, 'fetch', return_value=b'[]'):
from urllib.error import HTTPError
def empty_releases(url, limit):
if url == module.RELEASES:
return b'[]'
if url == 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/manifest.json':
raise HTTPError(url, 404, 'Not Found', {}, None)
raise AssertionError('Unexpected discovery URL: ' + url)
with patch.object(module, 'fetch', side_effect=empty_releases):
self.assertIsNone(module.discover(self.pub))
self.package()
result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root),
@@ -346,6 +353,7 @@ CALL
with patch.object(module, 'fetch', side_effect=fetch):
found = module.discover(self.pub)
self.assertEqual(found[0]['version'], '1.2.3')
self.assertFalse(any('/raw/branch/' in url for url in requested))
self.assertNotIn(urls['runtime.zip'], requested)
release['tag_name'] = 'latest'
with self.assertRaisesRegex(ValueError, 'tag'):
@@ -462,3 +470,106 @@ CALL
self.assertEqual(updater.status(), authoritative)
finally:
updater.base.chmod(0o700)
def test_signed_repository_fallback_applies_and_preserves_replay_protection(self):
self.package()
module = engine()
from unittest.mock import patch
from contextlib import redirect_stdout, redirect_stderr
import io
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
requested = []
def fetch(url, limit):
requested.append(url)
if url == module.RELEASES:
return b'[]'
if url not in {base + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}:
raise AssertionError('Unexpected fallback URL: ' + url)
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
with patch.object(module, 'fetch', side_effect=fetch):
found = module.discover(self.pub)
self.assertIsNotNone(found)
self.assertEqual(found[0]['version'], '1.2.3')
self.assertEqual(found[3], base + 'runtime.zip')
self.assertNotIn(base + 'runtime.zip', requested)
with redirect_stdout(io.StringIO()) as output:
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
self.assertEqual(code, 0)
self.assertTrue(json.loads(output.getvalue())['applied'])
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
with redirect_stderr(io.StringIO()) as error:
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
self.assertEqual(code, 1)
self.assertIn('replay', error.getvalue())
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
def test_repository_fallback_refuses_tamper_missing_signature_and_server_errors(self):
self.package()
module = engine()
from unittest.mock import patch
from urllib.error import HTTPError
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
for failure in ('signature', 'missing_signature', 'server_error'):
with self.subTest(failure=failure):
requested = []
def fetch(url, limit):
requested.append(url)
if url == module.RELEASES:
return b'[]'
if url == base + 'manifest.json':
if failure == 'server_error':
raise HTTPError(url, 503, 'Unavailable', {}, None)
return (self.artifacts / 'manifest.json').read_bytes()
if url == base + 'manifest.sig':
if failure == 'missing_signature':
raise HTTPError(url, 404, 'Not Found', {}, None)
return b'x' * 64
raise AssertionError('Unsigned archive must not be fetched')
with patch.object(module, 'fetch', side_effect=fetch):
with self.assertRaises((ValueError, HTTPError)):
module.discover(self.pub)
self.assertNotIn(base + 'runtime.zip', requested)
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
def test_native_release_errors_do_not_fall_back_to_repository_assets(self):
self.package()
module = engine()
from unittest.mock import patch
from urllib.error import HTTPError
assets = [{'name': name, 'browser_download_url': 'https://git.shanaty.ru/attachments/' + name}
for name in ('manifest.json', 'manifest.sig', 'runtime.zip')]
for failure in ('tag', 'incomplete', 'signature', 'api_unavailable'):
with self.subTest(failure=failure):
def fetch(url, limit):
if '/raw/branch/' in url:
raise AssertionError('API failures must not activate fallback')
if url == module.RELEASES:
if failure == 'api_unavailable':
raise HTTPError(url, 503, 'Unavailable', {}, None)
return json.dumps([{'tag_name': 'wrong' if failure == 'tag' else '1.2.3',
'assets': assets[:1] if failure == 'incomplete' else assets}]).encode()
if failure == 'signature' and url.endswith('manifest.sig'):
return b'x' * 64
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
with patch.object(module, 'fetch', side_effect=fetch):
with self.assertRaises((ValueError, HTTPError)):
module.discover(self.pub)
def test_repository_fallback_downloads_use_existing_size_and_redirect_checks(self):
module = engine()
from unittest.mock import patch
import io
import urllib.request
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
def oversized(request, timeout):
if request.full_url == module.RELEASES:
return io.BytesIO(b'[]')
return io.BytesIO(b'x' * (module.MANIFEST_LIMIT + 1))
with patch('urllib.request.OpenerDirector.open', side_effect=oversized):
with self.assertRaisesRegex(ValueError, 'size'):
module.discover(self.pub)
request = urllib.request.Request(base + 'manifest.json')
for destination in ('http://git.shanaty.ru/manifest.json', 'https://other.example/manifest.json'):
with self.subTest(destination=destination), self.assertRaises(ValueError):
module.SafeRedirect().redirect_request(request, None, 302, '', {}, destination)