diff --git a/BadgeReleaseDemo/.gitignore b/BadgeReleaseDemo/.gitignore
new file mode 100644
index 0000000..098b6a3
--- /dev/null
+++ b/BadgeReleaseDemo/.gitignore
@@ -0,0 +1,9 @@
+bin/
+obj/
+
+# Debug/comparison artifacts
+*.bin
+*.txt
+compare.csx
+program-comparison-snippet.txt
+IppOperations/IppRequestComparison.cs
diff --git a/BadgeReleaseDemo/Auth/AuthHelper.cs b/BadgeReleaseDemo/Auth/AuthHelper.cs
new file mode 100644
index 0000000..7c6c5f6
--- /dev/null
+++ b/BadgeReleaseDemo/Auth/AuthHelper.cs
@@ -0,0 +1,253 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Cryptography;
+using System.Text.Json;
+using BadgeReleaseDemo.GraphApi;
+using BadgeReleaseDemo.Helpers;
+using Microsoft.Identity.Client;
+using Microsoft.IdentityModel.Tokens;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Security;
+
+namespace BadgeReleaseDemo.Auth;
+
+///
+/// Handles authentication for both user (Printer Admin) and printer device flows.
+/// Printer certificate and keys are kept in memory only.
+///
+public class AuthHelper
+{
+ private readonly string appId;
+ private readonly string tenantId;
+ private readonly string graphBaseUrl;
+ private IPublicClientApplication? publicClient;
+ private AuthenticationResult? userAuthResult;
+ private AuthenticationResult? graphAuthResult;
+
+ // Printer identity (in memory only)
+ private PrinterRegistrationResult? registrationResult;
+ private AsymmetricCipherKeyPair? printerKeyPair;
+ private string? printerToken;
+
+ public string UserUpn => userAuthResult?.Account?.Username ?? "unknown";
+
+ public string UserAccessToken => userAuthResult?.AccessToken ?? throw new InvalidOperationException("User not signed in");
+
+ public string PrinterToken => printerToken ?? throw new InvalidOperationException("Printer token not acquired");
+
+ public AuthHelper(string appId, string tenantId, string graphBaseUrl)
+ {
+ this.appId = appId;
+ this.tenantId = tenantId;
+ this.graphBaseUrl = graphBaseUrl;
+ }
+
+ ///
+ /// Signs in the user using interactive browser-based authentication.
+ ///
+ public async Task SignInUserAsync()
+ {
+ var builder = PublicClientApplicationBuilder
+ .Create(appId)
+ .WithRedirectUri("http://localhost");
+
+ if (!string.IsNullOrEmpty(tenantId))
+ {
+ builder = builder.WithAuthority($"https://login.microsoftonline.com/{tenantId}");
+ }
+
+ publicClient = builder.Build();
+
+ var scopes = new[] { "https://print.print.microsoft.com/.default" };
+
+ userAuthResult = await publicClient.AcquireTokenInteractive(scopes)
+ .WithPrompt(Prompt.SelectAccount)
+ .ExecuteAsync();
+
+ return UserUpn;
+ }
+
+ ///
+ /// Gets a fresh user access token, refreshing if needed.
+ ///
+ public async Task GetUserTokenAsync()
+ {
+ if (publicClient == null || userAuthResult == null)
+ {
+ throw new InvalidOperationException("User not signed in. Call SignInUserAsync first.");
+ }
+
+ try
+ {
+ var accounts = await publicClient.GetAccountsAsync();
+ var scopes = new[] { "https://print.print.microsoft.com/.default" };
+ userAuthResult = await publicClient.AcquireTokenSilent(scopes, accounts.FirstOrDefault())
+ .ExecuteAsync();
+ }
+ catch (MsalUiRequiredException)
+ {
+ var scopes = new[] { "https://print.print.microsoft.com/.default" };
+ userAuthResult = await publicClient.AcquireTokenInteractive(scopes)
+ .ExecuteAsync();
+ }
+
+ return UserAccessToken;
+ }
+
+ ///
+ /// Gets a Microsoft Graph token for Graph API calls (sharing, badges, jobs).
+ /// Uses silent acquisition if possible, otherwise prompts interactively.
+ ///
+ public async Task GetGraphTokenAsync()
+ {
+ if (publicClient == null)
+ {
+ throw new InvalidOperationException("User not signed in. Call SignInUserAsync first.");
+ }
+
+ var scopes = new[] { "https://graph.microsoft.com/.default" };
+
+ try
+ {
+ var accounts = await publicClient.GetAccountsAsync();
+ graphAuthResult = await publicClient.AcquireTokenSilent(scopes, accounts.FirstOrDefault())
+ .ExecuteAsync();
+ }
+ catch (MsalUiRequiredException)
+ {
+ graphAuthResult = await publicClient.AcquireTokenInteractive(scopes)
+ .ExecuteAsync();
+ }
+
+ return graphAuthResult.AccessToken;
+ }
+
+ ///
+ /// Stores the printer registration result and keypair for device token acquisition.
+ /// Certificate and keys are kept in memory only — not saved to disk.
+ ///
+ public void SetPrinterCredentials(PrinterRegistrationResult result, AsymmetricCipherKeyPair keyPair)
+ {
+ registrationResult = result;
+ printerKeyPair = keyPair;
+ }
+
+ ///
+ /// Acquires a device token for the printer using the JWT-bearer flow:
+ /// 1. POST grant_type=srv_challenge to get a nonce
+ /// 2. Create a JWT signed with the printer's private key
+ /// 3. POST grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the JWT
+ ///
+ public async Task GetPrinterTokenAsync()
+ {
+ if (registrationResult == null || printerKeyPair == null)
+ {
+ throw new InvalidOperationException("Printer credentials not set. Register printer first.");
+ }
+
+ using var httpClient = new HttpClient();
+ var tokenUrl = registrationResult.DeviceTokenUrl;
+
+ // Step 1: Request a nonce (srv_challenge)
+ var challengeBody = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "srv_challenge",
+ ["windows_api_version"] = "2.0",
+ });
+
+ var challengeResp = await httpClient.PostAsync(tokenUrl, challengeBody);
+ var challengeContent = await challengeResp.Content.ReadAsStringAsync();
+
+ if (!challengeResp.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Nonce request failed: {challengeResp.StatusCode} - {challengeContent}");
+ }
+
+ var challengeDoc = JsonSerializer.Deserialize(challengeContent);
+ var nonce = challengeDoc.GetProperty("Nonce").GetString()
+ ?? throw new InvalidOperationException("No Nonce in srv_challenge response.");
+
+ // Step 2: Create JWT signed with printer's private key
+ var jwt = CreateDeviceJwt(nonce);
+
+ // Step 3: Exchange JWT for access token
+ var tokenBody = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ ["request"] = jwt,
+ });
+
+ var tokenResp = await httpClient.PostAsync(tokenUrl, tokenBody);
+ var tokenContent = await tokenResp.Content.ReadAsStringAsync();
+
+ if (!tokenResp.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Device token request failed: {tokenResp.StatusCode} - {tokenContent}");
+ }
+
+ var tokenDoc = JsonSerializer.Deserialize(tokenContent);
+ printerToken = tokenDoc.GetProperty("access_token").GetString()
+ ?? throw new InvalidOperationException("No access_token in device token response.");
+
+ return printerToken;
+ }
+
+ ///
+ /// Refreshes the printer device token if needed.
+ ///
+ public async Task RefreshPrinterTokenAsync()
+ {
+ return await GetPrinterTokenAsync();
+ }
+
+ ///
+ /// Creates a JWT signed with the printer's private key for the device token flow.
+ ///
+ private string CreateDeviceJwt(string nonce)
+ {
+ var privateKeyParams = (RsaPrivateCrtKeyParameters)printerKeyPair!.Private;
+ var rsaParams = DotNetUtilities.ToRSAParameters(privateKeyParams);
+ var securityKey = new RsaSecurityKey(rsaParams);
+ var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
+
+ // x5c must be the base64 DER of the public cert (no PEM headers)
+ var certBase64 = registrationResult!.CertificatePem
+ .Replace("-----BEGIN CERTIFICATE-----", string.Empty)
+ .Replace("-----END CERTIFICATE-----", string.Empty)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim();
+
+ var header = new JwtHeader(signingCredentials)
+ {
+ { "x5c", new[] { certBase64 } },
+ };
+
+ // Use the app's client_id and the configured desktop redirect URI.
+ var clientId = string.IsNullOrEmpty(registrationResult!.PrinterClientId)
+ ? appId
+ : registrationResult.PrinterClientId;
+ var redirectUri = string.IsNullOrEmpty(registrationResult.PrinterRedirectUri)
+ ? "http://localhost"
+ : registrationResult.PrinterRedirectUri;
+
+ var claims = new[]
+ {
+ new System.Security.Claims.Claim("request_nonce", nonce),
+ new System.Security.Claims.Claim("grant_type", "device_token"),
+ new System.Security.Claims.Claim("resource", registrationResult.PrintServiceResourceId),
+ new System.Security.Claims.Claim("client_id", clientId),
+ new System.Security.Claims.Claim("redirect_uri", redirectUri),
+ new System.Security.Claims.Claim("iss", registrationResult.PrinterId),
+ };
+
+ var payload = new JwtPayload(claims);
+ var jwt = new JwtSecurityToken(header, payload);
+ var handler = new JwtSecurityTokenHandler();
+ return handler.WriteToken(jwt);
+ }
+}
diff --git a/BadgeReleaseDemo/BadgeReleaseDemo.csproj b/BadgeReleaseDemo/BadgeReleaseDemo.csproj
new file mode 100644
index 0000000..c0982d0
--- /dev/null
+++ b/BadgeReleaseDemo/BadgeReleaseDemo.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net8.0
+ BadgeReleaseDemo
+ BadgeReleaseDemo
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/BadgeReleaseDemo/BadgeReleaseDemo.sln b/BadgeReleaseDemo/BadgeReleaseDemo.sln
new file mode 100644
index 0000000..f64a8e0
--- /dev/null
+++ b/BadgeReleaseDemo/BadgeReleaseDemo.sln
@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeReleaseDemo", "BadgeReleaseDemo.csproj", "{C8E0786E-1129-36F5-7092-E300E7CF2EA2}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C8E0786E-1129-36F5-7092-E300E7CF2EA2}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {EDDF716F-FB42-40B6-9578-912FDE8C5DB8}
+ EndGlobalSection
+EndGlobal
diff --git a/BadgeReleaseDemo/GraphApi/BadgeManagement.cs b/BadgeReleaseDemo/GraphApi/BadgeManagement.cs
new file mode 100644
index 0000000..1b2454c
--- /dev/null
+++ b/BadgeReleaseDemo/GraphApi/BadgeManagement.cs
@@ -0,0 +1,176 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using BadgeReleaseDemo.Helpers;
+
+namespace BadgeReleaseDemo.GraphApi;
+
+///
+/// Handles badge collection and badge CRUD via MS Graph API.
+///
+public class BadgeManagement
+{
+ private readonly string graphBaseUrl;
+ private readonly HttpClient httpClient;
+
+ public BadgeManagement(string graphBaseUrl)
+ {
+ this.graphBaseUrl = graphBaseUrl;
+ httpClient = new HttpClient();
+ }
+
+ ///
+ /// Creates a badge collection. Handles 409 Conflict if it already exists.
+ /// Returns the actual collection ID from the service.
+ ///
+ public async Task CreateBadgeCollectionAsync(string accessToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{graphBaseUrl}/print/badgeCollections");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (response.StatusCode == HttpStatusCode.Conflict)
+ {
+ ConsoleHelper.WriteInfo("Badge collection already exists (this is OK).");
+ return await GetBadgeCollectionIdAsync(accessToken);
+ }
+
+ if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Accepted)
+ {
+ throw new HttpRequestException($"Failed to create badge collection: {response.StatusCode} - {responseBody}");
+ }
+
+ ConsoleHelper.WriteInfo("Badge collection creation initiated.");
+
+ // Poll until the badge collection is provisioned (can take up to 10 minutes)
+ if (response.StatusCode == HttpStatusCode.Accepted)
+ {
+ ConsoleHelper.WriteProgress("Waiting for badge collection to be provisioned (this can take up to 10 minutes)...");
+ return await WaitForBadgeCollectionProvisioningAsync(accessToken);
+ }
+
+ return await GetBadgeCollectionIdAsync(accessToken);
+ }
+
+ private async Task WaitForBadgeCollectionProvisioningAsync(string accessToken)
+ {
+ const int maxAttempts = 60;
+ const int delayMilliseconds = 10000;
+
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ var collectionId = await TryGetBadgeCollectionIdAsync(accessToken);
+
+ if (!string.IsNullOrEmpty(collectionId))
+ {
+ return collectionId;
+ }
+
+ if (attempt < maxAttempts)
+ {
+ await Task.Delay(delayMilliseconds);
+ }
+ }
+
+ throw new TimeoutException("Timed out waiting for badge collection provisioning to complete.");
+ }
+
+ private async Task GetBadgeCollectionIdAsync(string accessToken)
+ {
+ var collectionId = await TryGetBadgeCollectionIdAsync(accessToken);
+ return collectionId ?? throw new InvalidOperationException("No badge collection ID was returned by the service.");
+ }
+
+ private async Task TryGetBadgeCollectionIdAsync(string accessToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"{graphBaseUrl}/print/badgeCollections");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"Failed to list badge collections: {response.StatusCode} - {responseBody}");
+ }
+
+ var listDoc = JsonSerializer.Deserialize(responseBody);
+ if (!listDoc.TryGetProperty("value", out var collections) || collections.ValueKind != JsonValueKind.Array)
+ {
+ throw new InvalidOperationException("Badge collections response did not include a valid value array.");
+ }
+
+ foreach (var collection in collections.EnumerateArray())
+ {
+ if (collection.TryGetProperty("id", out var idProperty))
+ {
+ var collectionId = idProperty.GetString();
+ if (!string.IsNullOrWhiteSpace(collectionId))
+ {
+ return collectionId;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Adds a badge to the collection with the given badge ID and user UPN.
+ ///
+ public async Task AddBadgeAsync(string accessToken, string collectionId, string badgeId, string upn)
+ {
+ var requestBody = new
+ {
+ id = badgeId,
+ upn
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ using var request = new HttpRequestMessage(HttpMethod.Post,
+ $"{graphBaseUrl}/print/badgeCollections/{collectionId}/badges");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (response.StatusCode == HttpStatusCode.Conflict)
+ {
+ throw new InvalidOperationException(
+ $"Badge '{badgeId}' already exists. Choose a unique badge ID to avoid overwriting an existing user mapping.");
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to add badge: {response.StatusCode} - {responseBody}");
+ }
+ }
+ ///
+ /// Deletes a badge from the collection.
+ ///
+ public async Task DeleteBadgeAsync(string accessToken, string collectionId, string badgeId)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Delete,
+ $"{graphBaseUrl}/print/badgeCollections/{collectionId}/badges/{badgeId}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var response = await httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.NotFound)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ ConsoleHelper.WriteWarning($"Failed to delete badge: {response.StatusCode} - {body}");
+ }
+ }
+}
diff --git a/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs b/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs
new file mode 100644
index 0000000..8219550
--- /dev/null
+++ b/BadgeReleaseDemo/GraphApi/PrintJobSubmission.cs
@@ -0,0 +1,173 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using BadgeReleaseDemo.Helpers;
+
+namespace BadgeReleaseDemo.GraphApi;
+
+///
+/// Handles print job creation, document upload, and job start via MS Graph API.
+///
+public class PrintJobSubmission
+{
+ private readonly string graphBaseUrl;
+ private readonly HttpClient httpClient;
+
+ public PrintJobSubmission(string graphBaseUrl)
+ {
+ this.graphBaseUrl = graphBaseUrl;
+ httpClient = new HttpClient();
+ }
+
+ ///
+ /// Creates a print job on a printer share.
+ /// Matches the functional test pattern: configuration only, no documents in initial creation.
+ /// Returns (jobId, documentId).
+ ///
+ public async Task<(string JobId, string DocumentId)> CreateJobAsync(
+ string accessToken, string shareId, string displayName)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post,
+ $"{graphBaseUrl}/print/shares/{shareId}/jobs");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ // Match functional test pattern: configuration with copies + dpi, no documents
+ var requestBody = new Dictionary
+ {
+ ["configuration"] = new Dictionary
+ {
+ ["copies"] = 1,
+ ["dpi"] = 600,
+ },
+ };
+
+ var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ });
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ ConsoleHelper.WriteInfo($"POST {graphBaseUrl}/print/shares/{shareId}/jobs");
+ ConsoleHelper.WriteInfo($"Body: {json}");
+
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to create print job: {response.StatusCode} - {responseBody}");
+ }
+
+ var jobDoc = JsonSerializer.Deserialize(responseBody);
+ var jobId = jobDoc.GetProperty("id").GetString()!;
+
+ // The Graph API auto-creates a document when creating a print job
+ var documents = jobDoc.GetProperty("documents");
+ if (documents.GetArrayLength() == 0)
+ {
+ throw new InvalidOperationException("Expected at least one document in the print job response.");
+ }
+
+ var documentId = documents[0].GetProperty("id").GetString()!;
+
+ return (jobId, documentId);
+ }
+
+ ///
+ /// Creates an upload session for a document.
+ /// Returns the upload URL.
+ ///
+ public async Task CreateUploadSessionAsync(
+ string accessToken, string shareId, string jobId, string documentId, long documentSize)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post,
+ $"{graphBaseUrl}/print/shares/{shareId}/jobs/{jobId}/documents/{documentId}/createUploadSession");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var requestBody = new Dictionary
+ {
+ ["properties"] = new Dictionary
+ {
+ ["documentName"] = "SampleDocument.pdf",
+ ["contentType"] = "application/pdf",
+ ["size"] = documentSize,
+ },
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to create upload session: {response.StatusCode} - {responseBody}");
+ }
+
+ var sessionDoc = JsonSerializer.Deserialize(responseBody);
+ return sessionDoc.GetProperty("uploadUrl").GetString()
+ ?? throw new InvalidOperationException("No uploadUrl in response.");
+ }
+
+ ///
+ /// Uploads a PDF document to the upload session in chunks smaller than 10 MB.
+ ///
+ public async Task UploadDocumentAsync(string uploadUrl, byte[] pdfData)
+ {
+ // Universal Print upload sessions require each PUT to be less than 10 MB.
+ const int maxChunkSizeBytes = 9 * 1024 * 1024;
+ var totalLength = pdfData.Length;
+ var offset = 0;
+
+ while (offset < totalLength)
+ {
+ var chunkLength = Math.Min(maxChunkSizeBytes, totalLength - offset);
+ var rangeStart = offset;
+ var rangeEnd = offset + chunkLength - 1;
+
+ using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
+ request.Content = new ByteArrayContent(pdfData, offset, chunkLength);
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
+ request.Content.Headers.ContentLength = chunkLength;
+ request.Content.Headers.Add("Content-Range", $"bytes {rangeStart}-{rangeEnd}/{totalLength}");
+
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to upload document range {rangeStart}-{rangeEnd}: {response.StatusCode} - {responseBody}");
+ }
+
+ offset += chunkLength;
+ }
+
+ ConsoleHelper.WriteInfo($"Uploaded {totalLength} bytes.");
+ }
+
+ ///
+ /// Starts the print job after document upload.
+ ///
+ public async Task StartJobAsync(string accessToken, string shareId, string jobId)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post,
+ $"{graphBaseUrl}/print/shares/{shareId}/jobs/{jobId}/start");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
+
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to start print job: {response.StatusCode} - {responseBody}");
+ }
+
+ ConsoleHelper.WriteInfo("Print job started.");
+ }
+}
diff --git a/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs b/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs
new file mode 100644
index 0000000..b96043b
--- /dev/null
+++ b/BadgeReleaseDemo/GraphApi/PrinterRegistration.cs
@@ -0,0 +1,162 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using BadgeReleaseDemo.Helpers;
+
+namespace BadgeReleaseDemo.GraphApi;
+
+///
+/// Handles printer registration via the Universal Print Registration Service
+/// (https://register.print.microsoft.com).
+///
+public class PrinterRegistration
+{
+ private readonly string registrationBaseUrl;
+ private readonly HttpClient httpClient;
+
+ public PrinterRegistration(string registrationBaseUrl)
+ {
+ this.registrationBaseUrl = registrationBaseUrl.TrimEnd('/');
+ httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
+ }
+
+ ///
+ /// Registers a new printer with Universal Print via the registration service.
+ /// Returns a record with all fields needed for device token acquisition.
+ ///
+ public async Task RegisterPrinterAsync(
+ string accessToken,
+ string displayName,
+ string csrContent,
+ string transportKey)
+ {
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ // Step 1: Initiate registration via POST /api/v1.0/register
+ // Registration API uses snake_case field names
+ var requestBody = new Dictionary
+ {
+ ["name"] = displayName,
+ ["manufacturer"] = "Badge Release Demo",
+ ["model"] = "Virtual Printer",
+ ["device_type"] = "printer",
+ ["device_capabilities"] = new[] { "print" },
+ ["preferred_lang"] = "en-us",
+ ["certificate_request"] = new Dictionary
+ {
+ ["type"] = "pkcs10",
+ ["data"] = csrContent,
+ ["transport_key"] = transportKey,
+ },
+ ["has_physical_device"] = false,
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var regUrl = $"{registrationBaseUrl}/api/v1.0/register";
+ var response = await httpClient.PostAsync(regUrl, content);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to initiate printer registration: {response.StatusCode} - {responseBody}");
+ }
+
+ // Parse the registration ID from the response
+ var regResp = JsonSerializer.Deserialize(responseBody);
+ var registrationId = regResp.GetProperty("registration_id").GetString()
+ ?? throw new InvalidOperationException("No registration_id in registration response.");
+
+ ConsoleHelper.WriteInfo($"Registration initiated (ID: {registrationId})");
+
+ // Step 2: Poll GET /api/v1.0/register?registration_id={id} until 200 OK
+ ConsoleHelper.WriteProgress("Polling registration status...");
+ var statusUrl = $"{registrationBaseUrl}/api/v1.0/register?registration_id={registrationId}";
+
+ var startTime = DateTime.UtcNow;
+ var maxWait = TimeSpan.FromMinutes(5);
+
+ while (DateTime.UtcNow - startTime < maxWait)
+ {
+ var statusResp = await httpClient.GetAsync(statusUrl);
+ var statusBody = await statusResp.Content.ReadAsStringAsync();
+
+ if (statusResp.StatusCode == HttpStatusCode.OK)
+ {
+ // Registration succeeded — parse printer info
+ var printerInfo = JsonSerializer.Deserialize(statusBody);
+ var printerId = printerInfo.GetProperty("cloud_device_id").GetString()!;
+
+ var certPem = string.Empty;
+ if (printerInfo.TryGetProperty("certificate", out var certProp))
+ {
+ certPem = certProp.GetString() ?? string.Empty;
+ }
+
+ var deviceTokenUrl = string.Empty;
+ if (printerInfo.TryGetProperty("device_token_url", out var tokenUrlProp))
+ {
+ deviceTokenUrl = tokenUrlProp.GetString() ?? string.Empty;
+ }
+
+ var printerClientId = string.Empty;
+ if (printerInfo.TryGetProperty("printer_client_id", out var clientIdProp))
+ {
+ printerClientId = clientIdProp.GetString() ?? string.Empty;
+ }
+
+ var printerRedirectUri = string.Empty;
+ if (printerInfo.TryGetProperty("printer_redirect_uri", out var redirectProp))
+ {
+ printerRedirectUri = redirectProp.GetString() ?? string.Empty;
+ }
+
+ var printServiceResourceId = string.Empty;
+ if (printerInfo.TryGetProperty("mcp_svc_resource_id", out var resourceProp))
+ {
+ printServiceResourceId = resourceProp.GetString() ?? string.Empty;
+ }
+
+ return new PrinterRegistrationResult(
+ printerId, certPem, deviceTokenUrl,
+ printerClientId, printerRedirectUri, printServiceResourceId);
+ }
+ else if (statusResp.StatusCode == HttpStatusCode.Accepted)
+ {
+ // Still processing — wait for the interval specified in the response
+ var statusDoc = JsonSerializer.Deserialize(statusBody);
+ int interval = 3;
+ if (statusDoc.TryGetProperty("interval", out var intervalProp))
+ {
+ interval = intervalProp.GetInt32();
+ }
+
+ ConsoleHelper.WriteInfo($"Registration in progress, retrying in {interval}s...");
+ await Task.Delay(interval * 1000);
+ }
+ else
+ {
+ throw new HttpRequestException($"Registration polling failed: {statusResp.StatusCode} - {statusBody}");
+ }
+ }
+
+ throw new TimeoutException("Printer registration timed out after 5 minutes.");
+ }
+}
+
+///
+/// Contains all fields returned from printer registration needed for device token acquisition.
+///
+public record PrinterRegistrationResult(
+ string PrinterId,
+ string CertificatePem,
+ string DeviceTokenUrl,
+ string PrinterClientId,
+ string PrinterRedirectUri,
+ string PrintServiceResourceId);
diff --git a/BadgeReleaseDemo/GraphApi/PrinterSharing.cs b/BadgeReleaseDemo/GraphApi/PrinterSharing.cs
new file mode 100644
index 0000000..486a30e
--- /dev/null
+++ b/BadgeReleaseDemo/GraphApi/PrinterSharing.cs
@@ -0,0 +1,123 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using BadgeReleaseDemo.Helpers;
+
+namespace BadgeReleaseDemo.GraphApi;
+
+///
+/// Handles printer sharing via MS Graph API.
+///
+public class PrinterSharing
+{
+ private readonly string graphBaseUrl;
+ private readonly HttpClient httpClient;
+
+ public PrinterSharing(string graphBaseUrl)
+ {
+ this.graphBaseUrl = graphBaseUrl;
+ httpClient = new HttpClient();
+ }
+
+ ///
+ /// Creates a printer share with allowAllUsers=true.
+ /// Returns the share ID.
+ ///
+ public async Task CreateShareAsync(string accessToken, string printerId, string displayName)
+ {
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var requestBody = new Dictionary
+ {
+ ["displayName"] = displayName,
+ ["allowAllUsers"] = true,
+ ["printer@odata.bind"] = $"{graphBaseUrl}/print/printers/{printerId}",
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await httpClient.PostAsync($"{graphBaseUrl}/print/shares", content);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException($"Failed to create printer share: {response.StatusCode} - {responseBody}");
+ }
+
+ var shareDoc = JsonSerializer.Deserialize(responseBody);
+ return shareDoc.GetProperty("id").GetString()
+ ?? throw new InvalidOperationException("No share ID in response.");
+ }
+
+ ///
+ /// Enables badge release on the printer via Graph PATCH /print/printers/{id}.
+ /// Sets releaseMechanisms to qrCode which enables pull-print/badge release.
+ ///
+ public async Task EnableBadgeReleaseAsync(string graphToken, string printerId)
+ {
+ var patchBody = new
+ {
+ releaseMechanisms = new[]
+ {
+ new
+ {
+ releaseType = "qrCode",
+ },
+ },
+ };
+
+ var json = JsonSerializer.Serialize(patchBody);
+ using var request = new HttpRequestMessage(HttpMethod.Patch,
+ $"{graphBaseUrl}/print/printers/{printerId}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"Failed to enable badge release: {response.StatusCode} - {responseBody}");
+ }
+ }
+
+ ///
+ /// Deletes a printer share.
+ ///
+ public async Task DeleteShareAsync(string accessToken, string shareId)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Delete,
+ $"{graphBaseUrl}/print/shares/{shareId}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var response = await httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ ConsoleHelper.WriteWarning($"Failed to delete share: {response.StatusCode} - {body}");
+ }
+ }
+
+ ///
+ /// Deletes a printer.
+ ///
+ public async Task DeletePrinterAsync(string accessToken, string printerId)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Delete,
+ $"{graphBaseUrl}/print/printers/{printerId}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var response = await httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ ConsoleHelper.WriteWarning($"Failed to delete printer: {response.StatusCode} - {body}");
+ }
+ }
+}
diff --git a/BadgeReleaseDemo/Helpers/ConsoleHelper.cs b/BadgeReleaseDemo/Helpers/ConsoleHelper.cs
new file mode 100644
index 0000000..1d44b93
--- /dev/null
+++ b/BadgeReleaseDemo/Helpers/ConsoleHelper.cs
@@ -0,0 +1,92 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+namespace BadgeReleaseDemo.Helpers;
+
+///
+/// Lightweight console helpers with Unicode indicators and colored text.
+///
+public static class ConsoleHelper
+{
+ public static void WriteHeader(string title)
+ {
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.WriteLine();
+ Console.WriteLine(new string('═', 60));
+ Console.WriteLine($" {title}");
+ Console.WriteLine(new string('═', 60));
+ Console.ResetColor();
+ }
+
+ public static void WriteStep(string emoji, string description)
+ {
+ Console.ForegroundColor = ConsoleColor.White;
+ Console.WriteLine();
+ Console.Write($" {emoji} ");
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.WriteLine(description);
+ Console.ResetColor();
+ }
+
+ public static void WriteSuccess(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($" ✅ {message}");
+ Console.ResetColor();
+ }
+
+ public static void WriteError(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine($" ❌ {message}");
+ Console.ResetColor();
+ }
+
+ public static void WriteInfo(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.DarkGray;
+ Console.WriteLine($" {message}");
+ Console.ResetColor();
+ }
+
+ public static void WriteWarning(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine($" ⚠️ {message}");
+ Console.ResetColor();
+ }
+
+ public static void WriteProgress(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.DarkYellow;
+ Console.WriteLine($" ⏳ {message}");
+ Console.ResetColor();
+ }
+
+ public static string Prompt(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write($" ➤ {message}: ");
+ Console.ResetColor();
+ return Console.ReadLine()?.Trim() ?? string.Empty;
+ }
+
+ public static bool PromptYesNo(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write($" ➤ {message} (y/n): ");
+ Console.ResetColor();
+ var input = Console.ReadLine()?.Trim().ToLowerInvariant();
+ return input == "y" || input == "yes";
+ }
+
+ public static void WriteKeyValue(string key, string? value)
+ {
+ Console.ForegroundColor = ConsoleColor.DarkGray;
+ Console.Write($" {key}: ");
+ Console.ForegroundColor = ConsoleColor.White;
+ Console.WriteLine(value ?? "(null)");
+ Console.ResetColor();
+ }
+}
diff --git a/BadgeReleaseDemo/Helpers/CryptoHelper.cs b/BadgeReleaseDemo/Helpers/CryptoHelper.cs
new file mode 100644
index 0000000..d43ae3a
--- /dev/null
+++ b/BadgeReleaseDemo/Helpers/CryptoHelper.cs
@@ -0,0 +1,82 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.OpenSsl;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+
+namespace BadgeReleaseDemo.Helpers;
+
+///
+/// Generates CSR and keypair for printer registration.
+/// Certificate and keys are kept in memory only.
+///
+public static class CryptoHelper
+{
+ private const int KeySize = 2048;
+
+ public static AsymmetricCipherKeyPair GenerateKeyPair()
+ {
+ var generator = new RsaKeyPairGenerator();
+ generator.Init(new KeyGenerationParameters(new SecureRandom(), KeySize));
+ return generator.GenerateKeyPair();
+ }
+
+ public static string GenerateCsr(AsymmetricCipherKeyPair keyPair)
+ {
+ var subject = new X509Name("CN=Universal Print Badge Release Demo");
+ var csr = new Pkcs10CertificationRequest(
+ new Asn1SignatureFactory("SHA256WithRSA", keyPair.Private),
+ subject,
+ keyPair.Public,
+ null);
+
+ using var writer = new StringWriter();
+ var pemWriter = new PemWriter(writer);
+ pemWriter.WriteObject(csr);
+ pemWriter.Writer.Flush();
+
+ // API expects base64-encoded DER without PEM headers
+ var pem = writer.ToString();
+ return pem
+ .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty)
+ .Replace("-----END CERTIFICATE REQUEST-----", string.Empty)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim();
+ }
+
+ public static string GetTransportKey(AsymmetricCipherKeyPair keyPair)
+ {
+ using var writer = new StringWriter();
+ var pemWriter = new PemWriter(writer);
+ pemWriter.WriteObject(keyPair.Public);
+ pemWriter.Writer.Flush();
+
+ var pem = writer.ToString();
+ // Remove PEM header/footer for the transport key
+ return pem
+ .Replace("-----BEGIN PUBLIC KEY-----", string.Empty)
+ .Replace("-----END PUBLIC KEY-----", string.Empty)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim();
+ }
+
+ ///
+ /// Parses a PEM certificate string into an X509Certificate.
+ /// Used to extract the printer certificate from the registration response.
+ ///
+ public static X509Certificate ParseCertificate(string pemCertificate)
+ {
+ using var reader = new StringReader(pemCertificate);
+ var pemReader = new PemReader(reader);
+ return (X509Certificate)pemReader.ReadObject();
+ }
+}
diff --git a/BadgeReleaseDemo/IppOperations/MinimalIpp.cs b/BadgeReleaseDemo/IppOperations/MinimalIpp.cs
new file mode 100644
index 0000000..ec4b5cd
--- /dev/null
+++ b/BadgeReleaseDemo/IppOperations/MinimalIpp.cs
@@ -0,0 +1,606 @@
+//
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+
+using System.Globalization;
+using System.Text;
+
+namespace BadgeReleaseDemo.IppOperations;
+
+///
+/// Minimal, auditable IPP (Internet Printing Protocol) implementation for Universal Print.
+/// Implements only the 5 operations needed: Get-Jobs, Fetch-Job, Acknowledge-Job, Fetch-Document, Update-Job-Status.
+/// Based on RFC 8011 (IPP/1.1) with Microsoft-specific extensions for Badge Release.
+///
+/// This is NOT a general-purpose IPP library. It is custom, minimal code with the narrowest possible
+/// attack surface for Badge Release Demo operations.
+///
+public static class MinimalIpp
+{
+ // IPP Protocol Constants (RFC 8011)
+ private const byte IPP_VERSION_MAJOR = 2;
+ private const byte IPP_VERSION_MINOR = 0;
+
+ // Operation codes (RFC 8011 + Microsoft extensions)
+ private const ushort OP_GET_JOBS = 0x000a;
+ private const ushort OP_FETCH_JOB = 0x0043;
+ private const ushort OP_ACKNOWLEDGE_JOB = 0x0041;
+ private const ushort OP_FETCH_DOCUMENT = 0x0042;
+ private const ushort OP_UPDATE_JOB_STATUS = 0x0048;
+
+ // Attribute group tags
+ private const byte TAG_OPERATION_ATTRIBUTES = 0x01;
+ private const byte TAG_PRINTER_ATTRIBUTES = 0x04;
+ private const byte TAG_JOB_ATTRIBUTES = 0x02;
+ private const byte TAG_END_OF_ATTRIBUTES = 0x03;
+
+ // Attribute value tags
+ private const byte TAG_INTEGER = 0x21;
+ private const byte TAG_BOOLEAN = 0x22;
+ private const byte TAG_ENUM = 0x23;
+ private const byte TAG_STRING = 0x30;
+ private const byte TAG_KEYWORD = 0x44;
+ private const byte TAG_URI = 0x45;
+ private const byte TAG_NAME_WITHOUT_LANGUAGE = 0x42;
+ private const byte TAG_TEXT_WITHOUT_LANGUAGE = 0x41;
+
+ // Microsoft custom tags (used in Universal Print IPP extensions)
+ private const byte TAG_MS_CHARSET = 0x47; // Custom: attributes-charset, output-device-uuid
+ private const byte TAG_MS_LANGUAGE = 0x48; // Custom: attributes-natural-language
+
+ ///
+ /// Builds a Get-Jobs IPP request to query fetchable jobs for a user.
+ /// Operation code: 0x000a (Get-Jobs)
+ /// Attribute order must match IppLibrary for compatibility with Universal Print service.
+ ///
+ public static byte[] BuildGetJobsRequest(
+ ushort requestId, string printerUri, string jobType, string requestingUserUri, string outputDeviceUuid = "")
+ {
+ using var stream = new MemoryStream();
+
+ // IPP Header
+ WriteIppHeader(stream, requestId, OP_GET_JOBS);
+
+ // Operation Attributes Group
+ stream.WriteByte(TAG_OPERATION_ATTRIBUTES);
+
+ // Must start with charset and language (required by RFC 8011)
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8");
+ WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us");
+
+ // Add requesting-user-uri (can be empty but attribute should be present)
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri);
+ }
+
+ // Add my-jobs attribute (boolean) AFTER requesting-user-uri when it's provided
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_BOOLEAN, "my-jobs", "true");
+ }
+
+ // Add printer-uri
+ WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri);
+
+ // Add output-device-uuid if provided
+ if (!string.IsNullOrEmpty(outputDeviceUuid))
+ {
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}");
+ }
+
+ // Add which-jobs attribute (keyword) AFTER printer-uri/output-device-uuid
+ if (!string.IsNullOrEmpty(jobType))
+ {
+ WriteAttributeWithTag(stream, TAG_KEYWORD, "which-jobs", jobType);
+ }
+
+ // Requested attributes (always add)
+ WriteAttributeWithTag(stream, TAG_KEYWORD, "requested-attributes", "all");
+
+ // End of attributes
+ stream.WriteByte(TAG_END_OF_ATTRIBUTES);
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Writes an attribute with a specific value tag.
+ ///
+ private static void WriteAttributeWithTag(MemoryStream stream, byte valueTag, string name, string value)
+ {
+ // Special handling for boolean values
+ if (valueTag == TAG_BOOLEAN)
+ {
+ WriteBooleanAttribute(stream, name, value == "true" || value == "1");
+ return;
+ }
+
+ stream.WriteByte(valueTag);
+ WriteString(stream, name);
+ WriteString(stream, value);
+ }
+
+ private static void WriteBooleanAttribute(MemoryStream stream, string name, bool value)
+ {
+ stream.WriteByte(TAG_BOOLEAN);
+ WriteString(stream, name);
+ WriteUInt16BigEndian(stream, 1); // length is always 1 for boolean
+ stream.WriteByte(value ? (byte)0x01 : (byte)0x00);
+ }
+ ///
+ /// Builds a Fetch-Job IPP request to retrieve job metadata.
+ /// Operation code: 0x0029 (Fetch-Job)
+ ///
+ public static byte[] BuildFetchJobRequest(
+ ushort requestId, string printerUri, int jobId, string outputDeviceUuid = "", string requestingUserUri = "")
+ {
+ using var stream = new MemoryStream();
+
+ WriteIppHeader(stream, requestId, OP_FETCH_JOB);
+
+ stream.WriteByte(TAG_OPERATION_ATTRIBUTES);
+
+ // Mandatory attributes
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8");
+ WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us");
+
+ // Add requesting-user-uri if provided
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri);
+ }
+
+ // Printer URI
+ WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri);
+
+ // Add output-device-uuid if provided
+ if (!string.IsNullOrEmpty(outputDeviceUuid))
+ {
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}");
+ }
+
+ // Job ID
+ WriteIntegerAttribute(stream, "job-id", jobId);
+
+ stream.WriteByte(TAG_END_OF_ATTRIBUTES);
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Builds an Acknowledge-Job IPP request to confirm job receipt.
+ /// Operation code: 0x0041 (AcknowledgeJob)
+ ///
+ public static byte[] BuildAcknowledgeJobRequest(
+ ushort requestId, string printerUri, int jobId, string statusMessage, string outputDeviceUuid = "", string requestingUserUri = "")
+ {
+ using var stream = new MemoryStream();
+
+ WriteIppHeader(stream, requestId, OP_ACKNOWLEDGE_JOB);
+
+ stream.WriteByte(TAG_OPERATION_ATTRIBUTES);
+
+ // Mandatory attributes
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8");
+ WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us");
+
+ // Add requesting-user-uri if provided
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri);
+ }
+
+ // Printer URI
+ WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri);
+
+ // Add output-device-uuid if provided
+ if (!string.IsNullOrEmpty(outputDeviceUuid))
+ {
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}");
+ }
+
+ // Job ID
+ WriteIntegerAttribute(stream, "job-id", jobId);
+
+ // Fetch status message (if provided)
+ if (!string.IsNullOrEmpty(statusMessage))
+ {
+ WriteStringAttribute(stream, "fetch-status-message", statusMessage);
+ }
+
+ stream.WriteByte(TAG_END_OF_ATTRIBUTES);
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Builds a Fetch-Document IPP request to download the print document.
+ /// Operation code: 0x0042 (Fetch-Document)
+ ///
+ public static byte[] BuildFetchDocumentRequest(
+ ushort requestId, string printerUri, int jobId, int documentNumber, string outputDeviceUuid = "", string requestingUserUri = "", string jobUri = "")
+ {
+ using var stream = new MemoryStream();
+
+ WriteIppHeader(stream, requestId, OP_FETCH_DOCUMENT);
+
+ stream.WriteByte(TAG_OPERATION_ATTRIBUTES);
+
+ // Mandatory attributes
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8");
+ WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us");
+
+ // Requesting user URI (matches other working operations)
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri);
+ }
+
+ // Printer URI
+ WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri);
+
+ // Output device UUID (matches other working operations)
+ if (!string.IsNullOrEmpty(outputDeviceUuid))
+ {
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}");
+ }
+
+ // Job URI (some services require it explicitly for Fetch-Document)
+ if (!string.IsNullOrEmpty(jobUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "job-uri", jobUri);
+ }
+
+ // Job ID and document number
+ WriteIntegerAttribute(stream, "job-id", jobId);
+ WriteIntegerAttribute(stream, "document-number", documentNumber);
+
+ stream.WriteByte(TAG_END_OF_ATTRIBUTES);
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Builds an Update-Job-Status IPP request to mark a job as completed.
+ /// Operation code: 0x0045 (UpdateActiveJobs) — Microsoft extension
+ ///
+ public static byte[] BuildUpdateJobStatusRequest(
+ ushort requestId, string printerUri, int jobId, int jobState, string outputDeviceUuid = "", string requestingUserUri = "")
+ {
+ using var stream = new MemoryStream();
+
+ WriteIppHeader(stream, requestId, OP_UPDATE_JOB_STATUS);
+
+ stream.WriteByte(TAG_OPERATION_ATTRIBUTES);
+
+ // Mandatory attributes
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "attributes-charset", "UTF-8");
+ WriteAttributeWithTag(stream, TAG_MS_LANGUAGE, "attributes-natural-language", "en-us");
+
+ // Requesting user URI (matches other working operations)
+ if (!string.IsNullOrEmpty(requestingUserUri))
+ {
+ WriteAttributeWithTag(stream, TAG_URI, "requesting-user-uri", requestingUserUri);
+ }
+
+ // Printer URI
+ WriteAttributeWithTag(stream, TAG_URI, "printer-uri", printerUri);
+
+ // Output device UUID (matches other working operations)
+ if (!string.IsNullOrEmpty(outputDeviceUuid))
+ {
+ WriteAttributeWithTag(stream, TAG_MS_CHARSET, "output-device-uuid", $"urn:uuid:{outputDeviceUuid}");
+ }
+
+ // Job ID in operation attributes
+ WriteIntegerAttribute(stream, "job-id", jobId);
+
+ // Job state in job attributes group
+ stream.WriteByte(TAG_JOB_ATTRIBUTES);
+ WriteEnumAttribute(stream, "output-device-job-state", jobState);
+
+ stream.WriteByte(TAG_END_OF_ATTRIBUTES);
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Parses an IPP response and extracts status code and job attributes.
+ /// Returns (statusCode, jobAttributes) where jobAttributes is a collection of dictionaries per job.
+ ///
+ public static (ushort StatusCode, List> JobAttributes) ParseGetJobsResponse(byte[] responseData)
+ {
+ if (responseData.Length < 8)
+ {
+ throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header.");
+ }
+
+ using var stream = new MemoryStream(responseData);
+ using var reader = new BinaryReader(stream);
+
+ // IPP Header (4 bytes + 4 bytes + 4 bytes = 12 bytes minimum)
+ byte versionMajor = reader.ReadByte();
+ byte versionMinor = reader.ReadByte();
+ ushort statusCode = ReadUInt16BigEndian(reader);
+ uint requestId = ReadUInt32BigEndian(reader);
+
+ var jobs = new List>();
+
+ // Parse attribute groups
+ while (stream.Position < stream.Length)
+ {
+ byte tag = reader.ReadByte();
+
+ if (tag == TAG_END_OF_ATTRIBUTES)
+ break;
+
+ if (tag == TAG_JOB_ATTRIBUTES)
+ {
+ var jobAttrs = ParseAttributeGroup(reader);
+ jobs.Add(jobAttrs);
+ }
+ else
+ {
+ // Skip unknown attribute group
+ SkipAttributeGroup(reader);
+ }
+ }
+
+ return (statusCode, jobs);
+ }
+
+ ///
+ /// Parses an IPP response for Fetch-Job and extracts job attributes.
+ ///
+ public static (ushort StatusCode, Dictionary JobAttributes, byte[]? DocumentData) ParseFetchJobResponse(byte[] responseData)
+ {
+ if (responseData.Length < 8)
+ {
+ throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header.");
+ }
+
+ using var stream = new MemoryStream(responseData);
+ using var reader = new BinaryReader(stream);
+
+ byte versionMajor = reader.ReadByte();
+ byte versionMinor = reader.ReadByte();
+ ushort statusCode = ReadUInt16BigEndian(reader);
+ uint requestId = ReadUInt32BigEndian(reader);
+
+ var jobAttrs = new Dictionary();
+
+ // Parse attribute groups
+ while (stream.Position < stream.Length)
+ {
+ byte tag = reader.ReadByte();
+
+ if (tag == TAG_END_OF_ATTRIBUTES)
+ break;
+
+ if (tag == TAG_JOB_ATTRIBUTES)
+ {
+ jobAttrs = ParseAttributeGroup(reader);
+ }
+ else
+ {
+ SkipAttributeGroup(reader);
+ }
+ }
+
+ // Remaining bytes are the document data (if any)
+ byte[] documentData = reader.ReadBytes((int)(stream.Length - stream.Position));
+ return (statusCode, jobAttrs, documentData.Length > 0 ? documentData : null);
+ }
+
+ ///
+ /// Parses an IPP response and extracts status code.
+ ///
+ public static ushort ParseStatusCodeResponse(byte[] responseData)
+ {
+ if (responseData.Length < 8)
+ {
+ throw new InvalidDataException("Invalid IPP response: expected at least 8 bytes for header.");
+ }
+
+ return (ushort)((responseData[2] << 8) | responseData[3]);
+ }
+
+ // ---- Private Helpers ----
+
+ private static void WriteIppHeader(MemoryStream stream, ushort requestId, ushort operationCode)
+ {
+ stream.WriteByte(IPP_VERSION_MAJOR);
+ stream.WriteByte(IPP_VERSION_MINOR);
+ WriteUInt16BigEndian(stream, operationCode);
+ WriteUInt32BigEndian(stream, requestId);
+ }
+
+ private static void WriteKeywordAttribute(MemoryStream stream, string name, string value)
+ {
+ stream.WriteByte(TAG_KEYWORD);
+ WriteString(stream, name);
+ WriteString(stream, value);
+ }
+
+ private static void WriteStringAttribute(MemoryStream stream, string name, string value)
+ {
+ stream.WriteByte(TAG_TEXT_WITHOUT_LANGUAGE);
+ WriteString(stream, name);
+ WriteString(stream, value);
+ }
+
+ private static void WriteIntegerAttribute(MemoryStream stream, string name, int value)
+ {
+ stream.WriteByte(TAG_INTEGER);
+ WriteString(stream, name);
+ WriteUInt16BigEndian(stream, 4); // 4 bytes for integer value
+ WriteInt32BigEndian(stream, value);
+ }
+
+ private static void WriteEnumAttribute(MemoryStream stream, string name, int value)
+ {
+ stream.WriteByte(TAG_ENUM);
+ WriteString(stream, name);
+ WriteUInt16BigEndian(stream, 4); // 4 bytes for enum value
+ WriteInt32BigEndian(stream, value);
+ }
+
+ private static void WriteString(MemoryStream stream, string value)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(value);
+ WriteUInt16BigEndian(stream, (ushort)bytes.Length);
+ stream.Write(bytes, 0, bytes.Length);
+ }
+
+ private static Dictionary ParseAttributeGroup(BinaryReader reader)
+ {
+ var attrs = new Dictionary();
+ string? lastAttributeName = null;
+
+ while (reader.BaseStream.Position < reader.BaseStream.Length)
+ {
+ byte tag = reader.ReadByte();
+
+ if (IsDelimiterTag(tag))
+ {
+ reader.BaseStream.Seek(-1, SeekOrigin.Current);
+ break;
+ }
+
+ string name = ReadString(reader);
+ object value = ReadValue(reader, tag);
+
+ var attributeName = string.IsNullOrEmpty(name) ? lastAttributeName : name;
+ if (string.IsNullOrEmpty(attributeName))
+ {
+ throw new InvalidDataException("Malformed IPP attribute group: encountered empty attribute name without a previous name.");
+ }
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ lastAttributeName = name;
+ }
+
+ if (attrs.TryGetValue(attributeName, out var existingValue))
+ {
+ if (existingValue is List