diff --git a/docs/implplan/SPRINT_160_export_evidence.md b/docs/implplan/SPRINT_160_export_evidence.md
index 886a1f4e5..ab3b11a6e 100644
--- a/docs/implplan/SPRINT_160_export_evidence.md
+++ b/docs/implplan/SPRINT_160_export_evidence.md
@@ -19,7 +19,7 @@ Depends on: Sprint 110.A - AdvisoryAI, Sprint 120.A - AirGap, Sprint 130.A - Sca
Summary: Export & Evidence focus on ExportCenter (phase I).
Task ID | State | Task description | Owners (Source)
--- | --- | --- | ---
-DVOFF-64-001 | DOING (2025-11-04) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
+DVOFF-64-001 | DONE (2025-11-05) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest.
2025-11-05: Worker builds reproducible bundle, persists manifest/checksum/DSSE signature under `//`, and documents verification flow in `devportal-offline.md`. Unit coverage added for job + signer. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
DVOFF-64-002 | TODO | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. Dependencies: DVOFF-64-001. | DevPortal Offline Guild, AirGap Controller Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
EXPORT-AIRGAP-56-001 | TODO | Extend Export Center to build Mirror Bundles as export profiles, including advisories/VEX/policy packs manifesting DSSE/TUF metadata. | Exporter Service Guild, Mirror Creator Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md)
EXPORT-AIRGAP-56-002 | TODO | Package Bootstrap Pack (images + charts) into OCI archives with signed manifests for air-gapped deployment. Dependencies: EXPORT-AIRGAP-56-001. | Exporter Service Guild, DevOps Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md)
diff --git a/docs/modules/export-center/architecture.md b/docs/modules/export-center/architecture.md
index 109f6e8c3..581a3a23a 100644
--- a/docs/modules/export-center/architecture.md
+++ b/docs/modules/export-center/architecture.md
@@ -84,7 +84,8 @@ All endpoints require Authority-issued JWT + DPoP tokens with scopes `export:run
- Supports optional encryption of `/data` subtree (age/AES-GCM) with key wrapping stored in `provenance.json`.
- **DevPortal (`devportal:offline`).**
- Packages developer portal static assets, OpenAPI specs, SDK releases, and changelog content into a reproducible archive with manifest/checksum pairs.
- - Emits `manifest.json`, `checksums.txt`, and helper scripts described in [DevPortal Offline Bundle Specification](devportal-offline.md); signing/DSSE wiring follows the shared Export Center signing service.
+ - Emits `manifest.json`, `checksums.txt`, helper scripts, and a DSSE signature document (`manifest.dsse.json`) as described in [DevPortal Offline Bundle Specification](devportal-offline.md).
+ - Stores artefacts under `//` and signs manifests via the Export Center signing adapter (HMAC-SHA256 v1, tenant scoped).
Adapters expose structured telemetry events (`adapter.start`, `adapter.chunk`, `adapter.complete`) with record counts and byte totals per chunk. Failures emit `adapter.error` with reason codes.
diff --git a/docs/modules/export-center/devportal-offline.md b/docs/modules/export-center/devportal-offline.md
index 05513137a..088bf355e 100644
--- a/docs/modules/export-center/devportal-offline.md
+++ b/docs/modules/export-center/devportal-offline.md
@@ -81,7 +81,14 @@ root
The `root` value is the SHA-256 hash of the serialized manifest and is exposed separately in the result object for downstream signing.
-## 4. Verification script
+## 4. DSSE signature and storage
+
+- The export job signs `manifest.json` with an HMAC-SHA256 based DSSE envelope, producing `manifest.dsse.json` alongside the bundle artefacts.
+- The signature document captures the bundle identifier, manifest root hash, signing timestamp, algorithm metadata, and the DSSE payload/signature.
+- Operators verify the manifest by validating `manifest.dsse.json`, then cross-checking the `payload` base64 with the downloaded manifest and the `rootHash` in `checksums.txt`.
+- All artefacts are written under a deterministic storage prefix `//` with the bundle archive stored as `bundle.tgz` (or the configured file name).
+
+## 5. Verification script
`verify-offline.sh` is a POSIX-compatible helper that:
@@ -91,7 +98,7 @@ The `root` value is the SHA-256 hash of the serialized manifest and is exposed s
Operators can override the archive name via the first argument (`./verify-offline.sh mybundle.tgz`).
-## 5. Content categories
+## 6. Content categories
| Category | Target prefix | Notes |
|-----------|---------------|-------|
@@ -102,14 +109,13 @@ Operators can override the archive name via the first argument (`./verify-offlin
Paths are normalised to forward slashes and guarded against directory traversal.
-## 6. Determinism and hashing rules
+## 7. Determinism and hashing rules
- Files are enumerated and emitted in ordinal path order.
- SHA-256 digests use lowercase hex encoding.
- Optional directories (specs, SDKs, changelog) are skipped when absent; at least one category must contain files or the builder fails fast.
-## 7. Next steps
+## 8. Next steps
-- Attach DSSE signing + timestamping (`signature.json`) once Export Center signing infrastructure is ready.
-- Integrate the builder into the Export Center worker profile (`devportal --offline`) and plumb orchestration/persistence.
-- Produce CLI validation tooling (`stella devportal verify`) per DVOFF-64-002 and document operator workflows under `docs/airgap/devportal-offline.md`.
+- Expand `stella devportal verify` (DVOFF-64-002) to validate DSSE signatures and bundle integrity offline.
+- Document operator workflow under `docs/airgap/devportal-offline.md`.
diff --git a/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md b/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md
index 981b0fc97..7edfa294f 100644
--- a/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md
+++ b/src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md
@@ -3,5 +3,5 @@
## Sprint 64 – Bundle Implementation
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
-| DVOFF-64-001 | DOING (2025-11-04) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
+| DVOFF-64-001 | DONE (2025-11-05) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
| DVOFF-64-002 | TODO | DevPortal Offline Guild, AirGap Controller Guild | DVOFF-64-001 | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. | CLI command validates signatures; integration test covers corrupted bundle; runbook updated. |
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs
new file mode 100644
index 000000000..5826a4ec5
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJob.cs
@@ -0,0 +1,176 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace StellaOps.ExportCenter.Core.DevPortalOffline;
+
+///
+/// Coordinates bundle construction, manifest signing, and artefact persistence for the devportal offline export.
+///
+public sealed class DevPortalOfflineJob
+{
+ private static readonly JsonSerializerOptions SignatureSerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true
+ };
+
+ private readonly DevPortalOfflineBundleBuilder _builder;
+ private readonly IDevPortalOfflineObjectStore _objectStore;
+ private readonly IDevPortalOfflineManifestSigner _manifestSigner;
+ private readonly ILogger _logger;
+
+ public DevPortalOfflineJob(
+ DevPortalOfflineBundleBuilder builder,
+ IDevPortalOfflineObjectStore objectStore,
+ IDevPortalOfflineManifestSigner manifestSigner,
+ ILogger logger)
+ {
+ _builder = builder ?? throw new ArgumentNullException(nameof(builder));
+ _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
+ _manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task ExecuteAsync(
+ DevPortalOfflineJobRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var bundleResult = _builder.Build(request.BundleRequest, cancellationToken);
+ _logger.LogInformation("DevPortal offline bundle constructed with {EntryCount} entries.", bundleResult.Manifest.Entries.Count);
+
+ var signature = await _manifestSigner.SignAsync(
+ request.BundleRequest.BundleId,
+ bundleResult.ManifestJson,
+ bundleResult.RootHash,
+ cancellationToken).ConfigureAwait(false);
+
+ var storagePrefix = BuildStoragePrefix(request.StoragePrefix, request.BundleRequest.BundleId);
+ var bundleFileName = SanitizeFileName(request.BundleFileName);
+
+ var manifestKey = $"{storagePrefix}/manifest.json";
+ var signatureKey = $"{storagePrefix}/manifest.dsse.json";
+ var checksumsKey = $"{storagePrefix}/checksums.txt";
+ var bundleKey = $"{storagePrefix}/{bundleFileName}";
+
+ var manifestMetadata = await StoreTextAsync(
+ bundleResult.ManifestJson,
+ manifestKey,
+ MediaTypes.Json,
+ cancellationToken).ConfigureAwait(false);
+
+ var signatureJson = JsonSerializer.Serialize(signature, SignatureSerializerOptions);
+ var signatureMetadata = await StoreTextAsync(
+ signatureJson,
+ signatureKey,
+ MediaTypes.Json,
+ cancellationToken).ConfigureAwait(false);
+
+ var checksumsMetadata = await StoreTextAsync(
+ bundleResult.Checksums,
+ checksumsKey,
+ MediaTypes.Text,
+ cancellationToken).ConfigureAwait(false);
+
+ DevPortalOfflineStorageMetadata bundleMetadata;
+ using (bundleResult.BundleStream)
+ {
+ bundleResult.BundleStream.Position = 0;
+ bundleMetadata = await _objectStore.StoreAsync(
+ bundleResult.BundleStream,
+ new DevPortalOfflineObjectStoreOptions(bundleKey, MediaTypes.GZip),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogInformation(
+ "DevPortal offline bundle stored at {BundleKey} ({SizeBytes} bytes).",
+ bundleMetadata.StorageKey,
+ bundleMetadata.SizeBytes);
+
+ return new DevPortalOfflineJobOutcome(
+ bundleResult.Manifest,
+ bundleResult.RootHash,
+ signature,
+ manifestMetadata,
+ signatureMetadata,
+ checksumsMetadata,
+ bundleMetadata);
+ }
+
+ private Task StoreTextAsync(
+ string content,
+ string storageKey,
+ string contentType,
+ CancellationToken cancellationToken)
+ {
+ var bytes = Encoding.UTF8.GetBytes(content);
+ var stream = new MemoryStream(bytes, writable: false);
+ return StoreAsync(stream, storageKey, contentType, cancellationToken);
+ }
+
+ private async Task StoreAsync(
+ Stream stream,
+ string storageKey,
+ string contentType,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await _objectStore.StoreAsync(
+ stream,
+ new DevPortalOfflineObjectStoreOptions(storageKey, contentType),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ await stream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private static string BuildStoragePrefix(string? prefix, Guid bundleId)
+ {
+ var trimmed = string.IsNullOrWhiteSpace(prefix)
+ ? string.Empty
+ : prefix.Trim().Trim('/').Replace('\\', '/');
+
+ return string.IsNullOrEmpty(trimmed)
+ ? bundleId.ToString("D")
+ : $"{trimmed}/{bundleId:D}";
+ }
+
+ private static string SanitizeFileName(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return "devportal-offline-bundle.tgz";
+ }
+
+ var fileName = Path.GetFileName(value);
+ if (string.IsNullOrEmpty(fileName))
+ {
+ return "devportal-offline-bundle.tgz";
+ }
+
+ if (fileName.Contains("..", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Bundle file name cannot contain path traversal sequences.");
+ }
+
+ return fileName;
+ }
+
+ private static class MediaTypes
+ {
+ public const string Json = "application/json";
+ public const string Text = "text/plain";
+ public const string GZip = "application/gzip";
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs
new file mode 100644
index 000000000..a2ecd8ae3
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineJobModels.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+
+namespace StellaOps.ExportCenter.Core.DevPortalOffline;
+
+///
+/// Represents the inputs required to execute the devportal offline export job.
+///
+/// Bundle builder request describing source directories and bundle metadata.
+/// Relative storage prefix (without bundle identifier) where artefacts are persisted.
+/// File name used for the packaged archive in storage.
+public sealed record DevPortalOfflineJobRequest(
+ DevPortalOfflineBundleRequest BundleRequest,
+ string StoragePrefix,
+ string BundleFileName);
+
+///
+/// Captures metadata produced after successfully executing the devportal offline export job.
+///
+/// The manifest describing bundled artefacts.
+/// SHA-256 hash of the manifest JSON payload.
+/// DSSE envelope and metadata for the manifest.
+/// Storage metadata for the manifest JSON artefact.
+/// Storage metadata for the manifest signature document.
+/// Storage metadata for the checksum file.
+/// Storage metadata for the bundled archive.
+public sealed record DevPortalOfflineJobOutcome(
+ DevPortalOfflineBundleManifest Manifest,
+ string RootHash,
+ DevPortalOfflineManifestSignatureDocument Signature,
+ DevPortalOfflineStorageMetadata ManifestStorage,
+ DevPortalOfflineStorageMetadata SignatureStorage,
+ DevPortalOfflineStorageMetadata ChecksumsStorage,
+ DevPortalOfflineStorageMetadata BundleStorage);
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs
new file mode 100644
index 000000000..2498a2b54
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/DevPortalOffline/DevPortalOfflineManifestSignature.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StellaOps.ExportCenter.Core.DevPortalOffline;
+
+///
+/// Provides DSSE signing for devportal offline bundle manifests.
+///
+public interface IDevPortalOfflineManifestSigner
+{
+ Task SignAsync(
+ Guid bundleId,
+ string manifestJson,
+ string rootHash,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// DSSE signature document emitted for devportal manifest verification.
+///
+/// Identifier of the bundle being signed.
+/// SHA-256 hash of the manifest JSON.
+/// UTC timestamp when the signature was created.
+/// Signing algorithm identifier (for example HMACSHA256).
+/// Identifier of the signing key.
+/// DSSE envelope containing payload and signatures.
+public sealed record DevPortalOfflineManifestSignatureDocument(
+ [property: JsonPropertyName("bundleId")] Guid BundleId,
+ [property: JsonPropertyName("rootHash")] string RootHash,
+ [property: JsonPropertyName("signedAt")] DateTimeOffset SignedAtUtc,
+ [property: JsonPropertyName("algorithm")] string Algorithm,
+ [property: JsonPropertyName("keyId")] string KeyId,
+ [property: JsonPropertyName("envelope")] DevPortalOfflineManifestDsseEnvelope Envelope);
+
+///
+/// Standard DSSE envelope carrying payload and signatures.
+///
+/// Type of the payload (for example application/json).
+/// Base64-encoded manifest payload.
+/// Collection of DSSE signatures.
+public sealed record DevPortalOfflineManifestDsseEnvelope(
+ [property: JsonPropertyName("payloadType")] string PayloadType,
+ [property: JsonPropertyName("payload")] string Payload,
+ [property: JsonPropertyName("signatures")] IReadOnlyList Signatures);
+
+///
+/// Represents an individual DSSE signature entry.
+///
+/// Base64-encoded signature.
+/// Identifier for the key used to sign.
+public sealed record DevPortalOfflineManifestDsseSignature(
+ [property: JsonPropertyName("sig")] string Signature,
+ [property: JsonPropertyName("keyid")] string? KeyId);
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj
index e4808f0d8..032c5eebc 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj
@@ -3,16 +3,15 @@
-
-
-
- net10.0
- enable
- enable
- preview
- true
-
-
-
-
+
+ net10.0
+ enable
+ enable
+ preview
+ true
+
+
+
+
+
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs
new file mode 100644
index 000000000..aa7e046a6
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineManifestSigningOptions.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+
+public sealed class DevPortalOfflineManifestSigningOptions
+{
+ [Required]
+ public string KeyId { get; set; } = "devportal-offline-local";
+
+ [Required]
+ public string Secret { get; set; } = null!;
+
+ [Required]
+ public string Algorithm { get; set; } = "HMACSHA256";
+
+ [Required]
+ public string PayloadType { get; set; } = "application/vnd.stella.devportal.manifest+json";
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs
new file mode 100644
index 000000000..411dcf19d
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/DevPortalOfflineStorageOptions.cs
@@ -0,0 +1,9 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+
+public sealed class DevPortalOfflineStorageOptions
+{
+ [Required]
+ public string RootPath { get; set; } = null!;
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs
new file mode 100644
index 000000000..b1e4be6b7
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/FileSystemDevPortalOfflineObjectStore.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+
+namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+
+public sealed class FileSystemDevPortalOfflineObjectStore : IDevPortalOfflineObjectStore
+{
+ private readonly IOptionsMonitor _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public FileSystemDevPortalOfflineObjectStore(
+ IOptionsMonitor options,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task StoreAsync(
+ Stream content,
+ DevPortalOfflineObjectStoreOptions storeOptions,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+ ArgumentNullException.ThrowIfNull(storeOptions);
+
+ var root = EnsureRootPath();
+ var storageKey = SanitizeKey(storeOptions.StorageKey);
+ var fullPath = GetFullPath(root, storageKey);
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+
+ content.Seek(0, SeekOrigin.Begin);
+ using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ var buffer = ArrayPool.Shared.Rent(128 * 1024);
+ long totalBytes = 0;
+
+ try
+ {
+ int read;
+ while ((read = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ hash.AppendData(buffer, 0, read);
+ totalBytes += read;
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ content.Seek(0, SeekOrigin.Begin);
+
+ var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
+ var createdAt = _timeProvider.GetUtcNow();
+
+ _logger.LogDebug("Stored devportal artefact at {Path} ({Bytes} bytes).", fullPath, totalBytes);
+
+ return new DevPortalOfflineStorageMetadata(
+ storageKey,
+ storeOptions.ContentType,
+ totalBytes,
+ sha,
+ createdAt);
+ }
+
+ public Task ExistsAsync(string storageKey, CancellationToken cancellationToken)
+ {
+ var root = EnsureRootPath();
+ var fullPath = GetFullPath(root, SanitizeKey(storageKey));
+ var exists = File.Exists(fullPath);
+ return Task.FromResult(exists);
+ }
+
+ public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken)
+ {
+ var root = EnsureRootPath();
+ var fullPath = GetFullPath(root, SanitizeKey(storageKey));
+ if (!File.Exists(fullPath))
+ {
+ throw new FileNotFoundException($"DevPortal offline artefact '{storageKey}' was not found.", fullPath);
+ }
+
+ Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return Task.FromResult(stream);
+ }
+
+ private string EnsureRootPath()
+ {
+ var root = _options.CurrentValue.RootPath;
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ throw new InvalidOperationException("DevPortal offline storage root path is not configured.");
+ }
+
+ var full = Path.GetFullPath(root);
+ if (!Directory.Exists(full))
+ {
+ Directory.CreateDirectory(full);
+ }
+
+ if (!full.EndsWith(Path.DirectorySeparatorChar))
+ {
+ full += Path.DirectorySeparatorChar;
+ }
+
+ return full;
+ }
+
+ private static string SanitizeKey(string storageKey)
+ {
+ if (string.IsNullOrWhiteSpace(storageKey))
+ {
+ throw new ArgumentException("Storage key cannot be empty.", nameof(storageKey));
+ }
+
+ if (storageKey.Contains("..", StringComparison.Ordinal))
+ {
+ throw new ArgumentException("Storage key cannot contain path traversal sequences.", nameof(storageKey));
+ }
+
+ var trimmed = storageKey.Trim().Trim('/').Replace('\\', '/');
+ return trimmed;
+ }
+
+ private static string GetFullPath(string root, string storageKey)
+ {
+ var combined = Path.Combine(root, storageKey.Replace('/', Path.DirectorySeparatorChar));
+ var fullPath = Path.GetFullPath(combined);
+ if (!fullPath.StartsWith(root, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Storage key resolves outside of configured root path.");
+ }
+
+ return fullPath;
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs
new file mode 100644
index 000000000..fe11a4db8
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/DevPortalOffline/HmacDevPortalOfflineManifestSigner.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Buffers.Binary;
+using System.ComponentModel.DataAnnotations;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+
+namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+
+public sealed class HmacDevPortalOfflineManifestSigner : IDevPortalOfflineManifestSigner
+{
+ private readonly IOptionsMonitor _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public HmacDevPortalOfflineManifestSigner(
+ IOptionsMonitor options,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task SignAsync(
+ Guid bundleId,
+ string manifestJson,
+ string rootHash,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(manifestJson))
+ {
+ throw new ArgumentException("Manifest JSON is required.", nameof(manifestJson));
+ }
+
+ if (string.IsNullOrWhiteSpace(rootHash))
+ {
+ throw new ArgumentException("Root hash is required.", nameof(rootHash));
+ }
+
+ var options = _options.CurrentValue;
+ ValidateOptions(options);
+
+ var signedAt = _timeProvider.GetUtcNow();
+ var payloadBytes = Encoding.UTF8.GetBytes(manifestJson);
+ var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes);
+ var signature = ComputeSignature(options, pae);
+ var payloadBase64 = Convert.ToBase64String(payloadBytes);
+
+ _logger.LogDebug("Signed devportal manifest for bundle {BundleId}.", bundleId);
+
+ var envelope = new DevPortalOfflineManifestDsseEnvelope(
+ options.PayloadType,
+ payloadBase64,
+ new[]
+ {
+ new DevPortalOfflineManifestDsseSignature(signature, options.KeyId)
+ });
+
+ var document = new DevPortalOfflineManifestSignatureDocument(
+ bundleId,
+ rootHash,
+ signedAt,
+ options.Algorithm,
+ options.KeyId,
+ envelope);
+
+ return Task.FromResult(document);
+ }
+
+ private static void ValidateOptions(DevPortalOfflineManifestSigningOptions options)
+ {
+ Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
+ if (!string.Equals(options.Algorithm, "HMACSHA256", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new NotSupportedException($"Algorithm '{options.Algorithm}' is not supported for devportal manifest signing.");
+ }
+ }
+
+ private static string ComputeSignature(DevPortalOfflineManifestSigningOptions options, byte[] pae)
+ {
+ var secretBytes = Convert.FromBase64String(options.Secret);
+ using var hmac = new HMACSHA256(secretBytes);
+ var signatureBytes = hmac.ComputeHash(pae);
+ return Convert.ToBase64String(signatureBytes);
+ }
+
+ private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes)
+ {
+ var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
+ const string prefix = "DSSEv1";
+
+ var totalLength = prefix.Length +
+ sizeof(ulong) +
+ sizeof(ulong) + typeBytes.Length +
+ sizeof(ulong) + payloadBytes.Length;
+
+ var buffer = new byte[totalLength];
+ var span = buffer.AsSpan();
+
+ var offset = Encoding.UTF8.GetBytes(prefix, span);
+ BinaryPrimitives.WriteUInt64BigEndian(span[offset..], 2);
+ offset += sizeof(ulong);
+
+ BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)typeBytes.Length);
+ offset += sizeof(ulong);
+ typeBytes.CopyTo(span[offset..]);
+ offset += typeBytes.Length;
+
+ BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)payloadBytes.Length);
+ offset += sizeof(ulong);
+ payloadBytes.CopyTo(span[offset..]);
+
+ return buffer;
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj
index fcc97aea8..dca48658b 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj
@@ -1,28 +1,19 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- net10.0
- enable
- enable
- preview
- true
-
-
-
-
-
+
+
+
+ net10.0
+ enable
+ enable
+ preview
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs
index d47c7f5e9..b5b5e0eb8 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineBundleBuilderTests.cs
@@ -52,7 +52,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero);
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(fixedNow));
- var result = builder.Build(request);
+ var result = builder.Build(request, TestContext.Current.CancellationToken);
Assert.Equal(request.BundleId, result.Manifest.BundleId);
Assert.Equal("devportal-offline/v1", result.Manifest.Version);
@@ -65,12 +65,12 @@ public sealed class DevPortalOfflineBundleBuilderTests
var expectedPaths = new[]
{
+ "changelog/CHANGELOG.md",
"portal/assets/app.js",
"portal/index.html",
- "specs/openapi.yaml",
"sdks/dotnet/stellaops.sdk.nupkg",
"sdks/python/stellaops_sdk.whl",
- "changelog/CHANGELOG.md"
+ "specs/openapi.yaml"
};
Assert.Equal(expectedPaths, result.Manifest.Entries.Select(entry => entry.Path).ToArray());
@@ -119,7 +119,10 @@ public sealed class DevPortalOfflineBundleBuilderTests
}
finally
{
- tempRoot.Dispose();
+ if (Directory.Exists(tempRoot.FullName))
+ {
+ Directory.Delete(tempRoot.FullName, recursive: true);
+ }
}
}
@@ -129,7 +132,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid());
- var exception = Assert.Throws(() => builder.Build(request));
+ var exception = Assert.Throws(() => builder.Build(request, TestContext.Current.CancellationToken));
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
}
@@ -145,7 +148,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "");
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
- var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot));
+ var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot), TestContext.Current.CancellationToken);
Assert.Single(result.Manifest.Entries);
Assert.True(result.Manifest.Sources.PortalIncluded);
@@ -155,7 +158,10 @@ public sealed class DevPortalOfflineBundleBuilderTests
}
finally
{
- tempRoot.Dispose();
+ if (Directory.Exists(tempRoot.FullName))
+ {
+ Directory.Delete(tempRoot.FullName, recursive: true);
+ }
}
}
@@ -166,7 +172,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing);
- Assert.Throws(() => builder.Build(request));
+ Assert.Throws(() => builder.Build(request, TestContext.Current.CancellationToken));
}
private static string CalculateFileHash(string path)
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs
new file mode 100644
index 000000000..f1aea25f0
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/DevPortalOfflineJobTests.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace StellaOps.ExportCenter.Tests;
+
+public class DevPortalOfflineJobTests
+{
+ [Fact]
+ public async Task ExecuteAsync_StoresArtefacts()
+ {
+ var tempRoot = Directory.CreateTempSubdirectory();
+ try
+ {
+ var portalRoot = Path.Combine(tempRoot.FullName, "portal");
+ Directory.CreateDirectory(portalRoot);
+ File.WriteAllText(Path.Combine(portalRoot, "index.html"), "offline");
+
+ var specsRoot = Path.Combine(tempRoot.FullName, "specs");
+ Directory.CreateDirectory(specsRoot);
+ File.WriteAllText(Path.Combine(specsRoot, "api.json"), "{\"openapi\":\"3.1.0\"}");
+
+ var sdkRoot = Path.Combine(tempRoot.FullName, "sdk-dotnet");
+ Directory.CreateDirectory(sdkRoot);
+ File.WriteAllText(Path.Combine(sdkRoot, "stellaops.sdk.nupkg"), "binary");
+
+ var changelogRoot = Path.Combine(tempRoot.FullName, "changelog");
+ Directory.CreateDirectory(changelogRoot);
+ File.WriteAllText(Path.Combine(changelogRoot, "CHANGELOG.md"), "# 2025.11.0");
+
+ var bundleId = Guid.Parse("5e76bb6f-2925-41ea-8e72-1fd8a384dd1a");
+ var request = new DevPortalOfflineBundleRequest(
+ bundleId,
+ portalRoot,
+ specsRoot,
+ new[] { new DevPortalSdkSource("dotnet", sdkRoot) },
+ changelogRoot,
+ new Dictionary { ["releaseVersion"] = "2025.11.0" });
+
+ var fixedNow = new DateTimeOffset(2025, 11, 4, 18, 15, 0, TimeSpan.Zero);
+ var timeProvider = new FixedTimeProvider(fixedNow);
+ var builder = new DevPortalOfflineBundleBuilder(timeProvider);
+ var objectStore = new InMemoryObjectStore(timeProvider);
+ var signer = new TestManifestSigner(timeProvider);
+ var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger.Instance);
+
+ var outcome = await job.ExecuteAsync(
+ new DevPortalOfflineJobRequest(request, "exports/devportal", "bundle.tgz"),
+ TestContext.Current.CancellationToken);
+
+ var expectedPrefix = $"exports/devportal/{bundleId:D}";
+ Assert.Equal($"{expectedPrefix}/manifest.json", outcome.ManifestStorage.StorageKey);
+ Assert.Equal($"{expectedPrefix}/manifest.dsse.json", outcome.SignatureStorage.StorageKey);
+ Assert.Equal($"{expectedPrefix}/checksums.txt", outcome.ChecksumsStorage.StorageKey);
+ Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey);
+
+ var manifestText = objectStore.GetText(outcome.ManifestStorage.StorageKey);
+ using (var manifestDoc = JsonDocument.Parse(manifestText))
+ {
+ Assert.Equal("devportal-offline/v1", manifestDoc.RootElement.GetProperty("version").GetString());
+ Assert.Equal("2025.11.0", manifestDoc.RootElement.GetProperty("metadata").GetProperty("releaseVersion").GetString());
+ }
+
+ var signatureText = objectStore.GetText(outcome.SignatureStorage.StorageKey);
+ Assert.Contains(bundleId.ToString("D"), signatureText, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(outcome.RootHash, signatureText, StringComparison.Ordinal);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot.FullName))
+ {
+ Directory.Delete(tempRoot.FullName, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SanitizesBundleFileName()
+ {
+ var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
+ var objectStore = new InMemoryObjectStore(new FixedTimeProvider(DateTimeOffset.UtcNow));
+ var signer = new TestManifestSigner(new FixedTimeProvider(DateTimeOffset.UtcNow));
+ var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger.Instance);
+
+ var tempRoot = Directory.CreateTempSubdirectory();
+ try
+ {
+ var portalRoot = Path.Combine(tempRoot.FullName, "portal");
+ Directory.CreateDirectory(portalRoot);
+ File.WriteAllText(Path.Combine(portalRoot, "index.html"), "");
+
+ var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot);
+ var outcome = await job.ExecuteAsync(
+ new DevPortalOfflineJobRequest(request, "exports", "../bundle.tgz"),
+ TestContext.Current.CancellationToken);
+
+ var expectedPrefix = $"exports/{request.BundleId:D}";
+ Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot.FullName))
+ {
+ Directory.Delete(tempRoot.FullName, recursive: true);
+ }
+ }
+ }
+
+ private sealed class InMemoryObjectStore : IDevPortalOfflineObjectStore
+ {
+ private readonly Dictionary _entries = new(StringComparer.Ordinal);
+ private readonly TimeProvider _timeProvider;
+
+ public InMemoryObjectStore(TimeProvider timeProvider)
+ {
+ _timeProvider = timeProvider;
+ }
+
+ public Task StoreAsync(
+ Stream content,
+ DevPortalOfflineObjectStoreOptions options,
+ CancellationToken cancellationToken)
+ {
+ using var memory = new MemoryStream();
+ content.CopyTo(memory);
+ var bytes = memory.ToArray();
+ content.Seek(0, SeekOrigin.Begin);
+
+ var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ var metadata = new DevPortalOfflineStorageMetadata(
+ options.StorageKey,
+ options.ContentType,
+ bytes.Length,
+ sha,
+ _timeProvider.GetUtcNow());
+
+ _entries[options.StorageKey] = new InMemoryEntry(options.ContentType, bytes);
+ return Task.FromResult(metadata);
+ }
+
+ public Task ExistsAsync(string storageKey, CancellationToken cancellationToken)
+ => Task.FromResult(_entries.ContainsKey(storageKey));
+
+ public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken)
+ {
+ if (!_entries.TryGetValue(storageKey, out var entry))
+ {
+ throw new FileNotFoundException(storageKey);
+ }
+
+ Stream stream = new MemoryStream(entry.Content, writable: false);
+ return Task.FromResult(stream);
+ }
+
+ public string GetText(string storageKey)
+ {
+ if (!_entries.TryGetValue(storageKey, out var entry))
+ {
+ throw new FileNotFoundException(storageKey);
+ }
+
+ return Encoding.UTF8.GetString(entry.Content);
+ }
+
+ private sealed record InMemoryEntry(string ContentType, byte[] Content);
+ }
+
+ private sealed class TestManifestSigner : IDevPortalOfflineManifestSigner
+ {
+ private readonly TimeProvider _timeProvider;
+
+ public TestManifestSigner(TimeProvider timeProvider)
+ {
+ _timeProvider = timeProvider;
+ }
+
+ public Task SignAsync(
+ Guid bundleId,
+ string manifestJson,
+ string rootHash,
+ CancellationToken cancellationToken = default)
+ {
+ var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
+ return Task.FromResult(new DevPortalOfflineManifestSignatureDocument(
+ bundleId,
+ rootHash,
+ _timeProvider.GetUtcNow(),
+ "TEST-SHA256",
+ "test-key",
+ new DevPortalOfflineManifestDsseEnvelope(
+ "application/json",
+ payload,
+ new[] { new DevPortalOfflineManifestDsseSignature("c2lnbmF0dXJl", "test-key") })));
+ }
+ }
+
+ private sealed class FixedTimeProvider : TimeProvider
+ {
+ private readonly DateTimeOffset _utcNow;
+
+ public FixedTimeProvider(DateTimeOffset utcNow)
+ {
+ _utcNow = utcNow;
+ }
+
+ public override DateTimeOffset GetUtcNow() => _utcNow;
+
+ public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs
new file mode 100644
index 000000000..37f8c586d
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/HmacDevPortalOfflineManifestSignerTests.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+
+namespace StellaOps.ExportCenter.Tests;
+
+public class HmacDevPortalOfflineManifestSignerTests
+{
+ [Fact]
+ public async Task SignAsync_ComputesDeterministicSignature()
+ {
+ var options = new DevPortalOfflineManifestSigningOptions
+ {
+ KeyId = "devportal-test",
+ Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")),
+ Algorithm = "HMACSHA256",
+ PayloadType = "application/vnd.stella.devportal.manifest+json"
+ };
+
+ var now = new DateTimeOffset(2025, 11, 4, 19, 0, 0, TimeSpan.Zero);
+ var signer = new HmacDevPortalOfflineManifestSigner(
+ new StaticOptionsMonitor(options),
+ new FixedTimeProvider(now),
+ NullLogger.Instance);
+
+ const string manifest = "{\"version\":\"v1\",\"entries\":[]}";
+ var rootHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest))).ToLowerInvariant();
+ var bundleId = Guid.Parse("9ca2aafb-42b7-4df9-85f7-5a1d46c4e0ef");
+
+ var document = await signer.SignAsync(bundleId, manifest, rootHash, TestContext.Current.CancellationToken);
+
+ Assert.Equal(bundleId, document.BundleId);
+ Assert.Equal(rootHash, document.RootHash);
+ Assert.Equal(now, document.SignedAtUtc);
+ Assert.Equal(options.Algorithm, document.Algorithm);
+ Assert.Equal(options.KeyId, document.KeyId);
+ Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest)), document.Envelope.Payload);
+ Assert.Equal(options.PayloadType, document.Envelope.PayloadType);
+
+ var signature = Assert.Single(document.Envelope.Signatures);
+ Assert.Equal(options.KeyId, signature.KeyId);
+
+ var expectedSignature = ComputeExpectedSignature(options, manifest);
+ Assert.Equal(expectedSignature, signature.Signature);
+ }
+
+ [Fact]
+ public async Task SignAsync_ThrowsForUnsupportedAlgorithm()
+ {
+ var options = new DevPortalOfflineManifestSigningOptions
+ {
+ KeyId = "devportal-test",
+ Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")),
+ Algorithm = "RSA",
+ PayloadType = "application/json"
+ };
+
+ var signer = new HmacDevPortalOfflineManifestSigner(
+ new StaticOptionsMonitor(options),
+ new FixedTimeProvider(DateTimeOffset.UtcNow),
+ NullLogger.Instance);
+
+ await Assert.ThrowsAsync(() =>
+ signer.SignAsync(Guid.NewGuid(), "{}", "root", TestContext.Current.CancellationToken));
+ }
+
+ private static string ComputeExpectedSignature(DevPortalOfflineManifestSigningOptions options, string manifest)
+ {
+ var payloadBytes = Encoding.UTF8.GetBytes(manifest);
+ var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes);
+
+ var secret = Convert.FromBase64String(options.Secret);
+ using var hmac = new HMACSHA256(secret);
+ var signature = hmac.ComputeHash(pae);
+ return Convert.ToBase64String(signature);
+ }
+
+ private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes)
+ {
+ var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
+ const string prefix = "DSSEv1";
+
+ var buffer = new byte[prefix.Length + sizeof(ulong) + sizeof(ulong) + typeBytes.Length + sizeof(ulong) + payloadBytes.Length];
+ var span = buffer.AsSpan();
+
+ var written = Encoding.UTF8.GetBytes(prefix, span);
+ span = span[written..];
+
+ BinaryPrimitives.WriteUInt64BigEndian(span, 2);
+ span = span[sizeof(ulong)..];
+
+ BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)typeBytes.Length);
+ span = span[sizeof(ulong)..];
+
+ typeBytes.CopyTo(span);
+ span = span[typeBytes.Length..];
+
+ BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)payloadBytes.Length);
+ span = span[sizeof(ulong)..];
+
+ payloadBytes.CopyTo(span);
+ return buffer;
+ }
+ private sealed class FixedTimeProvider : TimeProvider
+ {
+ private readonly DateTimeOffset _utcNow;
+
+ public FixedTimeProvider(DateTimeOffset utcNow)
+ {
+ _utcNow = utcNow;
+ }
+
+ public override DateTimeOffset GetUtcNow() => _utcNow;
+
+ public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
+ }
+
+ private sealed class StaticOptionsMonitor : IOptionsMonitor
+ {
+ private readonly T _value;
+
+ public StaticOptionsMonitor(T value)
+ {
+ _value = value;
+ }
+
+ public T CurrentValue => _value;
+
+ public T Get(string? name) => _value;
+
+ public IDisposable OnChange(Action listener) => NullDisposable.Instance;
+
+ private sealed class NullDisposable : IDisposable
+ {
+ public static readonly NullDisposable Instance = new();
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs
new file mode 100644
index 000000000..2827ab30a
--- /dev/null
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/DevPortalOfflineWorkerOptions.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+
+namespace StellaOps.ExportCenter.Worker;
+
+public sealed class DevPortalOfflineWorkerOptions
+{
+ public bool Enabled { get; set; }
+ public Guid? BundleId { get; set; }
+ public string? StoragePrefix { get; set; }
+ public string? BundleFileName { get; set; }
+ public string? PortalDirectory { get; set; }
+ public string? SpecsDirectory { get; set; }
+ public string? ChangelogDirectory { get; set; }
+ public List? SdkSources { get; set; }
+ public Dictionary? Metadata { get; set; }
+}
+
+public sealed record DevPortalOfflineWorkerSdkSource(string Name, string Directory);
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs
index 8ecd6600c..f6d801251 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Program.cs
@@ -1,7 +1,22 @@
-using StellaOps.ExportCenter.Worker;
-
-var builder = Host.CreateApplicationBuilder(args);
-builder.Services.AddHostedService();
-
-var host = builder.Build();
-host.Run();
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
+using StellaOps.ExportCenter.Worker;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+builder.Services.AddSingleton(TimeProvider.System);
+
+builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline"));
+builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline:Signing"));
+builder.Services.Configure(builder.Configuration.GetSection("DevPortalOffline:Storage"));
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+host.Run();
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs
index 9f2e34087..1ed945f0c 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/Worker.cs
@@ -1,16 +1,87 @@
-namespace StellaOps.ExportCenter.Worker;
-
-public class Worker(ILogger logger) : BackgroundService
-{
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- while (!stoppingToken.IsCancellationRequested)
- {
- if (logger.IsEnabled(LogLevel.Information))
- {
- logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
- }
- await Task.Delay(1000, stoppingToken);
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.ExportCenter.Core.DevPortalOffline;
+
+namespace StellaOps.ExportCenter.Worker;
+
+public sealed class Worker : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly DevPortalOfflineJob _job;
+ private readonly IOptions _options;
+
+ public Worker(
+ ILogger logger,
+ DevPortalOfflineJob job,
+ IOptions options)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _job = job ?? throw new ArgumentNullException(nameof(job));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var options = _options.Value ?? new DevPortalOfflineWorkerOptions();
+
+ if (!options.Enabled)
+ {
+ _logger.LogInformation("DevPortal offline job disabled. Worker idling.");
+ await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
+ return;
+ }
+
+ try
+ {
+ var request = BuildRequest(options);
+ var outcome = await _job.ExecuteAsync(request, stoppingToken).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "DevPortal offline export completed. Bundle stored at {Key}. Manifest signature key {SignatureKey}.",
+ outcome.BundleStorage.StorageKey,
+ outcome.SignatureStorage.StorageKey);
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ _logger.LogError(ex, "DevPortal offline export job failed.");
+ throw;
+ }
+
+ await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
+ }
+
+ private static DevPortalOfflineJobRequest BuildRequest(DevPortalOfflineWorkerOptions options)
+ {
+ var bundleId = options.BundleId ?? Guid.NewGuid();
+ var metadata = options.Metadata is null || options.Metadata.Count == 0
+ ? null
+ : new Dictionary(options.Metadata, StringComparer.Ordinal);
+
+ var sdkSources = options.SdkSources is { Count: > 0 }
+ ? options.SdkSources
+ .Select(source => new DevPortalSdkSource(source.Name, source.Directory))
+ .ToArray()
+ : Array.Empty();
+
+ var bundleRequest = new DevPortalOfflineBundleRequest(
+ bundleId,
+ options.PortalDirectory,
+ options.SpecsDirectory,
+ sdkSources,
+ options.ChangelogDirectory,
+ metadata);
+
+ var storagePrefix = options.StoragePrefix ?? "devportal/offline";
+ var bundleFileName = string.IsNullOrWhiteSpace(options.BundleFileName)
+ ? "devportal-offline-bundle.tgz"
+ : options.BundleFileName;
+
+ return new DevPortalOfflineJobRequest(
+ bundleRequest,
+ storagePrefix,
+ bundleFileName);
+ }
+}
diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json
index 690176464..695eae252 100644
--- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json
+++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/appsettings.json
@@ -1,8 +1,25 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.Hosting.Lifetime": "Information"
- }
- }
-}
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "DevPortalOffline": {
+ "Enabled": false,
+ "StoragePrefix": "devportal/offline",
+ "BundleFileName": "devportal-offline-bundle.tgz",
+ "Metadata": {
+ "releaseChannel": "stable"
+ },
+ "Storage": {
+ "RootPath": "./out/devportal-offline"
+ },
+ "Signing": {
+ "KeyId": "devportal-offline-local",
+ "Secret": "ZGV2cG9ydGFsLW9mZmxpbmUtc2lnbmVyLXNlY3JldA==",
+ "Algorithm": "HMACSHA256",
+ "PayloadType": "application/vnd.stella.devportal.manifest+json"
+ }
+ }
+}