Discover signed runtime artifacts from public repository fallback
This commit is contained in:
+24
-3
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user