fix(enrollment): keep the stored pairing until the new device is bound #21
No files matched your search
@@ -20,6 +20,8 @@ The server and its identity provider govern session lifetime, renewal and curren
|
||||
|
||||
Explicit sign-in can replace unreadable saved QQQ session data only after fresh server authentication and, when device proof survives, authoritative device binding. Rejected binding leaves the unreadable file untouched. Successful recovery preserves its ciphertext in a uniquely named `qqq-session.recovery-*.dpapi` file in the protected data directory.
|
||||
|
||||
Enrollment replaces the pairing and its session as one staged step. The session for the new pairing is first written as `qqq-session.staged.dpapi`; once the pairing is replaced, the next read of the session (enrollment itself, a sign-in or a service start) moves it into place, preserving an unreadable earlier session as `qqq-session.recovery-*.dpapi`. A staged session is discarded when it does not belong to the stored pairing, or when the stored session already does or was saved after it; one that cannot be read is preserved under a recovery name. Until a staged session can be moved into place it is still the one that is used. The activity log records each of these once it has happened; a leftover that cannot be removed yet is dealt with, and logged, by a later read.
|
||||
|
||||
If device proof itself is unreadable, sign-in does not enable printing or reconstruct that proof. Explicit enrollment is required; it preserves the old ciphertext as `connection.recovery-*.dpapi`, printer choices and the old connection's journal without attaching those receipts to the new device. Read-only status remains available. Explicit sign-out can quarantine unreadable session data locally, but cannot revoke an unavailable server session; revoke that previous session in QQQ. Automatic connection recovery does not perform this explicit credential replacement.
|
||||
|
||||
## Replace a revoked or expired pairing
|
||||
|
||||
@@ -82,8 +82,13 @@ public sealed partial class AgentRuntime
|
||||
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(_authorizationBlocked)
|
||||
{
|
||||
// Whoever held the gate (a sign-out, a failed enrollment) blocked authorization while this call was waiting.
|
||||
return false;
|
||||
}
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
AgentAuthentication? saved = store.LoadAuthentication();
|
||||
AgentAuthentication? saved = LoadSavedSession(store, forRecovery: false);
|
||||
AgentConfiguration? configuration = store.LoadConfiguration();
|
||||
if(saved?.Credential is null || (saved.Binding.HasApprovedDevice
|
||||
&& (configuration is null || !SameOrigin(saved.Binding.Origin, configuration.ServerUrl))))
|
||||
@@ -120,6 +125,52 @@ public sealed partial class AgentRuntime
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the saved session and logs what that read did about a pairing change that was staged and not finished. The
|
||||
/// store has no log, and moving, discarding or setting aside a credential file must leave a line behind.
|
||||
/// </summary>
|
||||
private AgentAuthentication? LoadSavedSession(AgentStore store, bool forRecovery)
|
||||
{
|
||||
try
|
||||
{
|
||||
return forRecovery ? store.LoadAuthenticationForRecovery() : store.LoadAuthentication();
|
||||
}
|
||||
finally
|
||||
{
|
||||
NoteStagedPairing(store);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes the log line for what the store last did about a staged pairing change, including when the read that did it then failed.</summary>
|
||||
private void NoteStagedPairing(AgentStore store)
|
||||
{
|
||||
switch(store.StagedPairing)
|
||||
{
|
||||
case StagedPairingOutcome.Completed:
|
||||
WriteLog("INFO", "Finished a pairing change that was interrupted: the session saved with the new pairing is now in place.");
|
||||
break;
|
||||
case StagedPairingOutcome.CompletedAfterQuarantine:
|
||||
WriteLog("WARN", "Finished a pairing change that was interrupted: the session saved with the new pairing is now in place, and the earlier session data, which could not be read, was moved aside for recovery.");
|
||||
break;
|
||||
case StagedPairingOutcome.Deferred:
|
||||
WriteLog("WARN", "A pairing change is waiting to finish: the session saved with the new pairing is in use but could not be moved into place yet, and will be tried again. Reason: "
|
||||
+ (store.StagedPairingFailure is null ? "not recorded." : QqqSessionDiagnostics.Describe(store.StagedPairingFailure)));
|
||||
break;
|
||||
case StagedPairingOutcome.Discarded:
|
||||
WriteLog("INFO", "Discarded a session left over from a pairing change: it was for another pairing, or the stored session is newer or already belongs to this one.");
|
||||
break;
|
||||
case StagedPairingOutcome.SetAside:
|
||||
WriteLog("WARN", "A session staged for a pairing change could not be read and was moved aside for recovery.");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Explains, for the log, why the service cannot talk to QQQ yet: no session, signed out, no pairing, or a session for a server other than the paired one.</summary>
|
||||
private static string DescribeMissingAuthorization(AgentAuthentication? saved, AgentConfiguration? configuration) => saved is null
|
||||
? $"No saved {AppIdentity.ServerName} session: sign in from the companion to enable printing."
|
||||
|
||||
@@ -15,10 +15,34 @@ namespace Qqq.PrintAgent.Windows;
|
||||
/// </summary>
|
||||
public sealed partial class AgentRuntime
|
||||
{
|
||||
/// <summary>Exchanges the enrollment code for a device credential using the live user session on the selected server. Authentication changes are serialized by a gate, the identity is checked again after binding, and the old connection state is reset first so nothing from the previous pairing survives.</summary>
|
||||
/// <summary>How far an enrollment got, which decides what a failure has to report and whether the live session can still be trusted.</summary>
|
||||
private enum EnrollmentProgress
|
||||
{
|
||||
/// <summary>The server has not bound a new device to the session, and the stored pairing and session are untouched.</summary>
|
||||
NothingChanged,
|
||||
|
||||
/// <summary>The server bound the new device to the live session; the stored pairing and session still describe the old one.</summary>
|
||||
DeviceBound,
|
||||
|
||||
/// <summary>The new pairing is stored with its session staged beside it, which any later reader moves into place; the session is not active yet.</summary>
|
||||
Saved,
|
||||
|
||||
/// <summary>The new pairing and session are stored and the session is active.</summary>
|
||||
Activated,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges the enrollment code for a device credential using the live user session on the selected server, binds the
|
||||
/// new device to that session and checks the identity again. Only then are the stored pairing and stored session replaced,
|
||||
/// as one staged step, and the connection state reset, so a refused or interrupted bind leaves both files as they were.
|
||||
/// Authentication changes are serialized by a gate.
|
||||
/// </summary>
|
||||
private async Task<string> EnrollNativeAsync(PipeRequest request, CancellationToken token)
|
||||
{
|
||||
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
|
||||
EnrollmentProgress progress = EnrollmentProgress.NothingChanged;
|
||||
try
|
||||
{
|
||||
WriteLog("INFO", $"Enrollment requested on {request.ServerUrl ?? "(no server)"} with a {(request.EnrollmentCode ?? "").Length}-character enrollment code (never logged), computer name {Environment.MachineName}, agent {PrintApiClient.AgentVersion}.");
|
||||
@@ -33,17 +57,27 @@ public sealed partial class AgentRuntime
|
||||
using var api = PrintApiClient.ForSession(session);
|
||||
WriteLog("INFO", "Step 2 of 4: exchanging the enrollment code for a device credential.");
|
||||
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, PrintApiClient.AgentVersion), token).ConfigureAwait(false);
|
||||
WriteLog("INFO", $"Enrollment accepted: device {response.DeviceId}, poll interval {response.PollIntervalSeconds} s. Saving the pairing to protected storage.");
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
|
||||
WriteLog("INFO", $"Enrollment accepted: device {response.DeviceId}, poll interval {response.PollIntervalSeconds} s.");
|
||||
QqqSessionBinding expected = identity with { DeviceId = response.DeviceId };
|
||||
store.SaveAuthentication(new AgentAuthentication(expected, session.ExportCredential()));
|
||||
ResetConnectionState();
|
||||
WriteLog("INFO", "Step 3 of 4: binding the new device to the session and confirming the identity.");
|
||||
QqqSessionBinding actual = await session.BindDeviceAsync(new QqqDeviceCredential(response.DeviceId, response.DeviceToken), cancellationToken: token).ConfigureAwait(false);
|
||||
progress = EnrollmentProgress.DeviceBound;
|
||||
RequireSameIdentity(expected, actual);
|
||||
WriteLog("INFO", "Step 4 of 4: activating the session for printing.");
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the stored pairing and session are replaced only after the //
|
||||
// server bound the device and the identity matched, and as one //
|
||||
// staged step: a failure leaves either both files as they were, //
|
||||
// or the new pairing with a session any later reader completes //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
WriteLog("INFO", "Step 4 of 4: saving the pairing to protected storage and activating the session for printing.");
|
||||
AgentAuthentication authentication = new(actual, session.ExportCredential());
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
store.StagePairing(new AgentConfiguration(request.ServerUrl, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds), authentication);
|
||||
progress = EnrollmentProgress.Saved;
|
||||
FinishStagedPairing(store);
|
||||
ResetConnectionState();
|
||||
ActivateSession(session, actual, store);
|
||||
progress = EnrollmentProgress.Activated;
|
||||
lock(_stateLock)
|
||||
{
|
||||
_queuedJobs = [];
|
||||
@@ -52,9 +86,9 @@ public sealed partial class AgentRuntime
|
||||
WakePoll();
|
||||
return $"Enrolled device {response.DeviceId}";
|
||||
}
|
||||
catch(Exception exception) when(exception is not InvalidOperationException)
|
||||
catch(Exception exception) when(progress != EnrollmentProgress.NothingChanged || exception is not InvalidOperationException)
|
||||
{
|
||||
WriteFailure("Enrollment failed; any existing pairing is unchanged", exception);
|
||||
ReportEnrollmentFailure(progress, exception);
|
||||
throw;
|
||||
}
|
||||
finally { _authenticationGate.Release(); }
|
||||
@@ -62,6 +96,50 @@ public sealed partial class AgentRuntime
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Moves the session staged with the new pairing into place. When that cannot happen yet the enrollment fails with the
|
||||
/// cause, having already saved everything a sign-in or a restart needs to finish it. An earlier session that could not
|
||||
/// be read is reported, because it was moved aside rather than replaced.
|
||||
/// </summary>
|
||||
private void FinishStagedPairing(AgentStore store)
|
||||
{
|
||||
switch(store.CompleteStagedPairing())
|
||||
{
|
||||
case StagedPairingOutcome.Deferred:
|
||||
throw new IOException("The session saved with the new pairing could not be moved into place yet.", store.StagedPairingFailure);
|
||||
case StagedPairingOutcome.CompletedAfterQuarantine:
|
||||
WriteLog("WARN", "The earlier session data could not be read and was moved aside for recovery.");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Stops the live session being used once the server has bound it to the new device while this computer has not finished
|
||||
/// switching to it, then says what the failed enrollment left behind and how to recover. Such a session no longer matches
|
||||
/// the stored pairing or the state the runtime holds, so printing waits for the operator instead of running on a mismatch
|
||||
/// until the next renewal finds it. Blocking comes first, so a failure while logging cannot leave the session usable. In
|
||||
/// both blocked states an ordinary sign-in recovers: before the save it binds the old device again, and after it the
|
||||
/// store hands the sign-in the session that belongs to the new pairing.
|
||||
/// </summary>
|
||||
private void ReportEnrollmentFailure(EnrollmentProgress progress, Exception exception)
|
||||
{
|
||||
if(progress is EnrollmentProgress.DeviceBound or EnrollmentProgress.Saved)
|
||||
{ BlockAuthorization(); }
|
||||
WriteFailure(progress switch
|
||||
{
|
||||
EnrollmentProgress.NothingChanged => "Enrollment failed; the stored pairing and session are unchanged",
|
||||
EnrollmentProgress.DeviceBound => "Enrollment failed after the server bound the new device; the stored pairing and session are unchanged, and printing is blocked until you sign in again",
|
||||
EnrollmentProgress.Saved => "Enrollment failed after the new pairing and its session were saved; printing is blocked until you sign in again or the service restarts, either of which finishes activating it",
|
||||
_ => "Enrollment completed, but a later step failed",
|
||||
}, exception);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs why an enrollment request was refused before it reached the server, and returns the exception to throw.</summary>
|
||||
private InvalidOperationException Refused(string reason, InvalidOperationException exception)
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed partial class AgentRuntime
|
||||
WriteLog("WARN", $"Sign-in refused: a new pairing needs a session without device proof, but the session already names device {binding.DeviceId}.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, $"New pairing requires a fresh {AppIdentity.ServerName} session without device proof.");
|
||||
}
|
||||
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
|
||||
AgentAuthentication? saved = LoadSavedSession(store, forRecovery: true);
|
||||
if(!request.StartNewPairing && configuration is not null && SameOrigin(credential.Origin, configuration.ServerUrl))
|
||||
{
|
||||
WriteLog("INFO", $"Step 3 of 4: the session is for the paired server; binding device {configuration.DeviceId}{(request.ClaimLegacyDevice ? " and claiming it as a legacy device" : "")}.");
|
||||
@@ -155,8 +155,8 @@ public sealed partial class AgentRuntime
|
||||
WriteLog("INFO", "Sign-out requested: blocking printing, removing the saved session credential and ending the server session. Pairing and history are kept.");
|
||||
BlockAuthorization();
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
|
||||
bool unreadableSession = store.QuarantineUnreadableAuthentication();
|
||||
AgentAuthentication? saved = LoadSavedSession(store, forRecovery: true);
|
||||
bool unreadableSession = store.QuarantineUnreadableAuthentication() || store.StagedPairing is StagedPairingOutcome.CompletedAfterQuarantine or StagedPairingOutcome.SetAside;
|
||||
if(unreadableSession)
|
||||
{ WriteLog("WARN", $"The saved session data could not be read and was moved aside for recovery; revoke the previous session in {AppIdentity.ServerName}."); }
|
||||
if(saved is not null)
|
||||
|
||||
@@ -11,15 +11,29 @@ using Qqq.PrintAgent.Core.Printing;
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Saved QQQ session part of the store. The session identity and credential are kept in one DPAPI-protected file; an
|
||||
/// unreadable file is quarantined (moved aside, never deleted) so a user can sign in again without losing evidence.
|
||||
/// Saved QQQ session part of the store. The session identity and credential are kept in one DPAPI-protected file. While a
|
||||
/// pairing change is in progress, the session that belongs to the new pairing waits beside it under a staged name until
|
||||
/// it is moved into place. An unreadable session file, stored or staged, is moved aside under a recovery name and never
|
||||
/// deleted, so a user can sign in again without losing evidence.
|
||||
/// </summary>
|
||||
public sealed partial class AgentStore
|
||||
{
|
||||
private const string SessionFile = "qqq-session.dpapi";
|
||||
private const string StagedSessionFile = "qqq-session.staged.dpapi";
|
||||
private bool _authenticationRecoveryRequired;
|
||||
|
||||
|
||||
|
||||
/// <summary>What the last read of the session did about a staged pairing change, for the caller to log.</summary>
|
||||
internal StagedPairingOutcome StagedPairing { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Why the staged session could not be moved into place when the outcome is <see cref="StagedPairingOutcome.Deferred"/>; otherwise null.</summary>
|
||||
internal Exception? StagedPairingFailure { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Loads the saved session for explicit recovery: a file that cannot be decrypted or parsed is flagged for quarantine and reported as absent, so setup can proceed instead of failing on a damaged file.</summary>
|
||||
internal AgentAuthentication? LoadAuthenticationForRecovery()
|
||||
{
|
||||
@@ -41,7 +55,7 @@ public sealed partial class AgentStore
|
||||
{
|
||||
if(_authenticationRecoveryRequired)
|
||||
{
|
||||
File.Move(Path.Combine(_directory, "qqq-session.dpapi"), Path.Combine(_directory, "qqq-session.recovery-" + Guid.NewGuid().ToString("N") + ".dpapi"));
|
||||
File.Move(Path.Combine(_directory, SessionFile), Path.Combine(_directory, "qqq-session.recovery-" + Guid.NewGuid().ToString("N") + ".dpapi"));
|
||||
_authenticationRecoveryRequired = false;
|
||||
return true;
|
||||
}
|
||||
@@ -51,12 +65,18 @@ public sealed partial class AgentStore
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads and decrypts the saved session, or returns null when none exists. The file is size-bounded, decrypted for the
|
||||
/// current service account, parsed and validated, and the decrypted bytes are zeroed afterwards. A damaged or invalid file throws.
|
||||
/// Returns the saved session to act on, or null when there is none. A pairing change that was staged and not finished
|
||||
/// is dealt with first: a staged session that belongs to the stored pairing is the one returned, whether or not it
|
||||
/// could be moved into place yet, so a caller never acts on a session left over from the previous pairing. Files are
|
||||
/// size-bounded, decrypted for the current service account, parsed and validated. A damaged stored file throws.
|
||||
/// </summary>
|
||||
public AgentAuthentication? LoadAuthentication()
|
||||
public AgentAuthentication? LoadAuthentication() => ResolveStagedPairing() ?? ReadAuthentication(Path.Combine(_directory, SessionFile));
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads, decrypts, parses and validates one protected session file, or returns null when it does not exist.</summary>
|
||||
private static AgentAuthentication? ReadAuthentication(string path)
|
||||
{
|
||||
string path = Path.Combine(_directory, "qqq-session.dpapi");
|
||||
if(!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
@@ -104,19 +124,204 @@ public sealed partial class AgentStore
|
||||
|
||||
/// <summary>
|
||||
/// Saves the session protected for the service account, writing a temporary file and moving it into place so a crash cannot
|
||||
/// leave a half-written secret. An unreadable earlier file is quarantined first, and the plaintext buffer is zeroed on every path.
|
||||
/// leave a half-written secret. An unreadable earlier file is quarantined first. A staged session from an unfinished pairing
|
||||
/// change is removed, because what is saved here is newer than it. When it cannot be removed, the saved file is stamped
|
||||
/// later than the staged one, so the order the two were written in does not depend on the clock having moved forward.
|
||||
/// </summary>
|
||||
public void SaveAuthentication(AgentAuthentication authentication)
|
||||
{
|
||||
string path = Path.Combine(_directory, SessionFile);
|
||||
string staged = Path.Combine(_directory, StagedSessionFile);
|
||||
WriteProtected(path + ".tmp", authentication);
|
||||
_ = QuarantineUnreadableAuthentication();
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
if(!DeleteQuietly(staged) && File.GetLastWriteTimeUtc(path) <= File.GetLastWriteTimeUtc(staged))
|
||||
{
|
||||
File.SetLastWriteTimeUtc(path, File.GetLastWriteTimeUtc(staged).AddTicks(1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Starts replacing the pairing and its session as one step. The session is written first, flushed to disk, under a
|
||||
/// staged name, and the pairing is replaced second. From then on the change is decided: every reader of the session
|
||||
/// uses the staged one and moves it into place, because it names the stored pairing's server and device. If the pairing
|
||||
/// cannot be written the staged session is removed and nothing has changed; a staged session left by a crash before the
|
||||
/// pairing was replaced names a different device and is discarded by the next reader. The staged file is never stamped
|
||||
/// earlier than the stored session it is about to replace, whatever the clock did in between.
|
||||
/// </summary>
|
||||
internal void StagePairing(AgentConfiguration configuration, AgentAuthentication authentication)
|
||||
{
|
||||
string staged = Path.Combine(_directory, StagedSessionFile);
|
||||
string path = Path.Combine(_directory, SessionFile);
|
||||
WriteProtected(staged + ".tmp", authentication);
|
||||
File.Move(staged + ".tmp", staged, overwrite: true);
|
||||
if(File.Exists(path) && File.GetLastWriteTimeUtc(path) > File.GetLastWriteTimeUtc(staged))
|
||||
{
|
||||
File.SetLastWriteTimeUtc(staged, File.GetLastWriteTimeUtc(path));
|
||||
}
|
||||
try
|
||||
{
|
||||
SaveConfiguration(configuration);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_ = DeleteQuietly(staged);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Deals with a staged pairing change now, as every read of the session does, and reports what was done.</summary>
|
||||
internal StagedPairingOutcome CompleteStagedPairing()
|
||||
{
|
||||
_ = ResolveStagedPairing();
|
||||
return StagedPairing;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Decides what a staged session is and acts on it. One that belongs to the stored pairing, while the stored session
|
||||
/// does not and is older, is the session of a pairing change in progress: it is moved into place when that is possible,
|
||||
/// an unreadable earlier session being moved aside first, and returned either way. One that names another server or
|
||||
/// device, or that the stored session has superseded, is a leftover and is deleted. One that cannot be read is moved aside.
|
||||
/// </summary>
|
||||
private AgentAuthentication? ResolveStagedPairing()
|
||||
{
|
||||
StagedPairing = StagedPairingOutcome.None;
|
||||
StagedPairingFailure = null;
|
||||
string staged = Path.Combine(_directory, StagedSessionFile);
|
||||
_ = DeleteQuietly(staged + ".tmp");
|
||||
AgentAuthentication? session = File.Exists(staged) ? ReadStagedOrSetAside(staged) : null;
|
||||
if(session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string path = Path.Combine(_directory, SessionFile);
|
||||
AgentConfiguration? pairing = LoadPairingOrNull();
|
||||
AgentAuthentication? stored = ReadStoredOrFlag(path);
|
||||
if(!BelongsToPairing(session, pairing) || (stored is not null && (BelongsToPairing(stored, pairing) || File.GetLastWriteTimeUtc(path) > File.GetLastWriteTimeUtc(staged))))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// a leftover: it is for another pairing, or the stored session is //
|
||||
// already this pairing's, or was saved after it and so is newer. One //
|
||||
// that cannot be deleted now must not fail the read, and is reported //
|
||||
// as discarded only once it is gone //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
StagedPairing = DeleteQuietly(staged) ? StagedPairingOutcome.Discarded : StagedPairingOutcome.None;
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
bool quarantined = QuarantineUnreadableAuthentication();
|
||||
File.Move(staged, path, overwrite: true);
|
||||
StagedPairing = quarantined ? StagedPairingOutcome.CompletedAfterQuarantine : StagedPairingOutcome.Completed;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Something holds one of the two files open. The staged session is still the right one to act on, and the next reader moves it.
|
||||
StagedPairing = StagedPairingOutcome.Deferred;
|
||||
StagedPairingFailure = exception;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the staged session; one that cannot be decrypted or parsed is moved aside under a recovery name, because after the pairing was replaced it may be the only copy.</summary>
|
||||
private AgentAuthentication? ReadStagedOrSetAside(string staged)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ReadAuthentication(staged);
|
||||
}
|
||||
catch(Exception exception) when(exception is CryptographicException or JsonException or InvalidDataException or ArgumentException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(staged, Path.Combine(_directory, "qqq-session.recovery-" + Guid.NewGuid().ToString("N") + ".dpapi"));
|
||||
StagedPairing = StagedPairingOutcome.SetAside;
|
||||
}
|
||||
catch(Exception moveFailure) when(moveFailure is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// It stays where it is and the next reader sets it aside; the stored session is still the answer to this read.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the stored session for comparison; an unreadable one is flagged for quarantine and treated as absent.</summary>
|
||||
private AgentAuthentication? ReadStoredOrFlag(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ReadAuthentication(path);
|
||||
}
|
||||
catch(Exception exception) when(exception is CryptographicException or JsonException or InvalidDataException or ArgumentException)
|
||||
{
|
||||
_authenticationRecoveryRequired = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the stored pairing, treating one that cannot be read as absent: a pairing change only ever leaves a readable pairing behind.</summary>
|
||||
private AgentConfiguration? LoadPairingOrNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
return LoadConfiguration();
|
||||
}
|
||||
catch(Exception exception) when(exception is CryptographicException or JsonException or ArgumentException or UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a session names the same server and device as the pairing.</summary>
|
||||
private static bool BelongsToPairing(AgentAuthentication session, AgentConfiguration? pairing)
|
||||
=> pairing is not null
|
||||
&& string.Equals(session.Binding.DeviceId, pairing.DeviceId, StringComparison.Ordinal)
|
||||
&& Uri.TryCreate(pairing.ServerUrl, UriKind.Absolute, out Uri? server)
|
||||
&& string.Equals(server.GetLeftPart(UriPartial.Authority), session.Binding.Origin.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes one session to a file protected for the service account and flushes it to disk, zeroing the plaintext buffer on every path.</summary>
|
||||
private static void WriteProtected(string path, AgentAuthentication authentication)
|
||||
{
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(authentication, PrintApiClient.JsonOptions);
|
||||
try
|
||||
{
|
||||
byte[] encrypted = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
|
||||
string path = Path.Combine(_directory, "qqq-session.dpapi");
|
||||
File.WriteAllBytes(path + ".tmp", encrypted);
|
||||
QuarantineUnreadableAuthentication();
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
using FileStream stream = new(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
stream.Write(ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Deletes a leftover file without failing the operation that noticed it, and says whether the file is gone.</summary>
|
||||
private static bool DeleteQuietly(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A staged session is examined again by the next reader, which discards it by the same rules, and a temporary file is overwritten by the next write.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// What reading the saved session did about a pairing change that was staged and not finished. The store has no log of
|
||||
/// its own, so it reports this and the runtime writes the line: moving, discarding or setting aside a credential file
|
||||
/// should never happen silently.
|
||||
/// </summary>
|
||||
internal enum StagedPairingOutcome
|
||||
{
|
||||
/// <summary>Nothing was staged, or a leftover could not be removed yet and waits for the next read.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>The staged session belonged to the stored pairing and was moved into place.</summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>As <see cref="Completed"/>, after an unreadable earlier session was moved aside so it was not overwritten.</summary>
|
||||
CompletedAfterQuarantine,
|
||||
|
||||
/// <summary>The staged session belongs to the stored pairing and is being used, but could not be moved into place yet; the next reader tries again.</summary>
|
||||
Deferred,
|
||||
|
||||
/// <summary>The staged session did not belong to the stored pairing, or the stored session already did or was saved after it, and it was deleted.</summary>
|
||||
Discarded,
|
||||
|
||||
/// <summary>The staged session could not be read and was moved aside under a recovery name instead of being deleted.</summary>
|
||||
SetAside,
|
||||
}
|
||||
@@ -161,6 +161,558 @@ public sealed class AgentNewPairingTests
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The server can accept the enrollment code and then fail the next step, which binds the new device to the session: either it
|
||||
/// refuses, or the connection is lost. Neither may rewrite the stored pairing or the stored session, whether or not the old
|
||||
/// pairing could still be read, and neither blocks printing, because the operator is told both are unchanged. The operator
|
||||
/// stays signed in, and a later attempt with a fresh code completes on a new journal scope.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, true)]
|
||||
public async Task BindFailureAfterAcceptedEnrollmentKeepsTheStoredPairingAndSession(bool connectionLost, bool unreadable)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.Local.SeedSpooledJob();
|
||||
if(unreadable)
|
||||
{ ChangeOldConfiguration(fixture, unreadable: true); }
|
||||
else
|
||||
{ fixture.SaveSession(); }
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] original = File.ReadAllBytes(path);
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
fixture.NextSession = UnboundSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential(), StartNewPairing: true), timeout.Token);
|
||||
byte[] signedIn = File.ReadAllBytes(authPath);
|
||||
fixture.NextSession.FailBindingTransport = connectionLost;
|
||||
fixture.NextSession.DenyBinding = !connectionLost;
|
||||
_ = await Assert.ThrowsAnyAsync<IOException>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
Assert.Equal(new[] { "devices/enroll" }, fixture.NextSession.Paths, StringComparer.Ordinal);
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
Assert.Equal(signedIn, File.ReadAllBytes(authPath));
|
||||
Assert.Empty(Directory.GetFiles(fixture.Local.DirectoryPath, "connection.recovery-*.dpapi"));
|
||||
AgentSnapshot failed = runtime.GetSnapshot();
|
||||
Assert.True(failed.Authentication!.SignedIn);
|
||||
Assert.DoesNotContain("authorization is required", failed.Authentication.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(unreadable ? null : "device", failed.DeviceId);
|
||||
Assert.Contains(failed.Logs, entry => entry.Message.Contains("Enrollment failed; the stored pairing and session are unchanged", StringComparison.Ordinal));
|
||||
fixture.NextSession.FailBindingTransport = false;
|
||||
fixture.NextSession.DenyBinding = false;
|
||||
_ = await runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-second-code"), timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
}
|
||||
Assert.Equal(unreadable ? 1 : 0, Directory.GetFiles(fixture.Local.DirectoryPath, "connection.recovery-*.dpapi").Length);
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
|
||||
Assert.Null(store.GetState("job"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A pairing that is working keeps working when a second enrollment is accepted but the new device is never bound, because the
|
||||
/// server refused or the connection dropped: neither file is rewritten, and the next authorization pass still succeeds.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task BindFailureLeavesAWorkingPairingWorking(bool connectionLost)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] original = File.ReadAllBytes(path);
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
byte[] session = File.ReadAllBytes(authPath);
|
||||
fixture.Session.FailBindingTransport = connectionLost;
|
||||
fixture.Session.DenyBinding = !connectionLost;
|
||||
_ = await Assert.ThrowsAnyAsync<IOException>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
Assert.Equal(session, File.ReadAllBytes(authPath));
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
fixture.Session.FailBindingTransport = false;
|
||||
fixture.Session.DenyBinding = false;
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
AgentSnapshot afterNextPass = runtime.GetSnapshot();
|
||||
Assert.True(afterNextPass.Authentication!.Authorized);
|
||||
Assert.Equal("device", afterNextPass.DeviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Once the server has bound the new device, the live session no longer matches what this computer has stored or holds in
|
||||
/// memory. If the bound identity then turns out to belong to another user, the session cannot be exported, or the pairing
|
||||
/// cannot be written, nothing has been saved and nothing is left staged: printing is blocked instead of running on the
|
||||
/// mismatch, the log says so, and an ordinary sign-in binds the old device again and restores the old pairing.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("identity")]
|
||||
[InlineData("export")]
|
||||
[InlineData("pairing-file")]
|
||||
public async Task FailureAfterTheServerBoundTheDeviceBlocksPrintingUntilSignIn(string failingStep)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] original = File.ReadAllBytes(path);
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
byte[] session = File.ReadAllBytes(authPath);
|
||||
fixture.Session.BindAsAnotherPrincipal = failingStep is "identity";
|
||||
fixture.Session.ExportsBeforeFailure = failingStep is "export" ? 0 : null;
|
||||
if(failingStep is "pairing-file")
|
||||
{
|
||||
// SaveConfiguration writes this temporary name first; a directory in its place makes that write fail.
|
||||
_ = Directory.CreateDirectory(path + ".tmp");
|
||||
}
|
||||
Exception thrown = await Assert.ThrowsAnyAsync<Exception>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
Assert.IsAssignableFrom(failingStep is "pairing-file" ? typeof(UnauthorizedAccessException) : typeof(IOException), thrown);
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
Assert.Equal(session, File.ReadAllBytes(authPath));
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
AgentSnapshot failed = runtime.GetSnapshot();
|
||||
Assert.False(failed.Authentication!.Authorized);
|
||||
Assert.Contains("Sign in again", failed.Authentication.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(failed.Logs, entry => entry.Message.Contains("after the server bound the new device", StringComparison.Ordinal));
|
||||
fixture.Session.BindAsAnotherPrincipal = false;
|
||||
fixture.Session.ExportsBeforeFailure = null;
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
AgentSnapshot recovered = runtime.GetSnapshot();
|
||||
Assert.True(recovered.Authentication!.Authorized);
|
||||
Assert.Equal("device", recovered.DeviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The pairing and its session are two files. If the new pairing is written and the session then cannot be moved into
|
||||
/// place, the session waits beside it under a staged name and printing is blocked. The recovery the operator is told about
|
||||
/// works both ways: an ordinary sign-in, or a restarted service, moves the staged session into place and finishes the switch.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task InterruptedSaveIsFinishedBySignInOrRestart(bool restart)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] original = File.ReadAllBytes(path);
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
AgentRuntime runtime = fixture.Runtime();
|
||||
try
|
||||
{
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
// An open handle that does not share deletion keeps the session file from being replaced, as a scanner or backup might.
|
||||
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
Exception thrown = await Assert.ThrowsAnyAsync<Exception>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
Assert.True(thrown is IOException or UnauthorizedAccessException, thrown.GetType().FullName);
|
||||
}
|
||||
Assert.NotEqual(original, File.ReadAllBytes(path));
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
AgentSnapshot failed = runtime.GetSnapshot();
|
||||
Assert.False(failed.Authentication!.Authorized);
|
||||
Assert.Contains("Sign in again", failed.Authentication.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(failed.Logs, entry => entry.Message.Contains("after the new pairing and its session were saved", StringComparison.Ordinal));
|
||||
if(restart)
|
||||
{
|
||||
runtime.Dispose();
|
||||
runtime = fixture.Runtime();
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
}
|
||||
AgentSnapshot recovered = runtime.GetSnapshot();
|
||||
Assert.True(recovered.Authentication!.Authorized);
|
||||
Assert.Equal("replacement-device", recovered.DeviceId);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// If activation fails after the new pairing and its session are both saved, the files agree with each other and with the
|
||||
/// server. Printing is blocked in the running service, and a restarted service finishes the switch with no operator action.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FailureAfterBothFilesAreSavedIsFinishedByARestart()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
// The first export builds the session to save; the second, during activation, is the one that fails.
|
||||
fixture.Session.ExportsBeforeFailure = 1;
|
||||
_ = await Assert.ThrowsAsync<IOException>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
AgentSnapshot failed = runtime.GetSnapshot();
|
||||
Assert.False(failed.Authentication!.Authorized);
|
||||
Assert.Contains(failed.Logs, entry => entry.Message.Contains("after the new pairing and its session were saved", StringComparison.Ordinal));
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
fixture.Session.ExportsBeforeFailure = null;
|
||||
using AgentRuntime restarted = fixture.Runtime();
|
||||
_ = await restarted.PollControlsOnceAsync(timeout.Token);
|
||||
AgentSnapshot recovered = restarted.GetSnapshot();
|
||||
Assert.True(recovered.Authentication!.Authorized);
|
||||
Assert.Equal("replacement-device", recovered.DeviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// "Sign in again" is read by some operators as sign out, then sign in. That must work even when the staged session still
|
||||
/// cannot be moved at sign-out, here because something holds the staged file open: the sign-out acts on the session that
|
||||
/// belongs to the new pairing, the leftover staged file is discarded later, and the sign-in that follows is accepted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InterruptedSaveFollowedBySignOutStillRecoversBySignIn()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
_ = await Assert.ThrowsAnyAsync<Exception>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
}
|
||||
using(FileStream held = new(StagedSession(fixture), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
_ = await runtime.SignOutAsync(timeout.Token);
|
||||
}
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
Assert.Contains(runtime.GetSnapshot().Logs, entry => entry.Message.Contains("A pairing change is waiting to finish", StringComparison.Ordinal));
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
AgentSnapshot recovered = runtime.GetSnapshot();
|
||||
Assert.True(recovered.Authentication!.Authorized);
|
||||
Assert.Equal("replacement-device", recovered.DeviceId);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Assert.Contains(recovered.Logs, entry => entry.Message.Contains("Discarded a session left over from a pairing change", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// While the stored session is still held open, recovery cannot finish, and it must not do damage either: a sign-in fails,
|
||||
/// the new pairing and its staged session stay as they are, and the same sign-in succeeds once the file is released.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RecoveryWaitsWhileTheStoredSessionIsHeldAndSucceedsAfter()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
_ = await Assert.ThrowsAnyAsync<Exception>(() => runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-accepted-code"), timeout.Token));
|
||||
byte[] pairing = File.ReadAllBytes(path);
|
||||
Exception thrown = await Assert.ThrowsAnyAsync<Exception>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token));
|
||||
Assert.True(thrown is IOException or UnauthorizedAccessException, thrown.GetType().FullName);
|
||||
Assert.Equal(pairing, File.ReadAllBytes(path));
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
}
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
AgentSnapshot recovered = runtime.GetSnapshot();
|
||||
Assert.True(recovered.Authentication!.Authorized);
|
||||
Assert.Equal("replacement-device", recovered.DeviceId);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Assert.Contains(recovered.Logs, entry => entry.Message.Contains("Finished a pairing change that was interrupted", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the store does about a staged session reaches the activity log, and a session file that was moved aside reaches
|
||||
/// the operator at sign-out as a reminder to revoke it on the server.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task SettingASessionFileAsideIsLoggedAndReportedAtSignOut(bool stagedIsUnreadable)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
if(stagedIsUnreadable)
|
||||
{
|
||||
fixture.SaveSession();
|
||||
File.WriteAllBytes(StagedSession(fixture), [2, 4, 6]);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), [9, 8, 7]);
|
||||
Stage(fixture, "device", "https://synthetic.invalid/");
|
||||
}
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
string reply = await runtime.SignOutAsync(timeout.Token);
|
||||
Assert.Contains("revoke the previous session", reply, StringComparison.Ordinal);
|
||||
Assert.Contains(runtime.GetSnapshot().Logs, entry => entry.Message.Contains(
|
||||
stagedIsUnreadable ? "staged for a pairing change could not be read and was moved aside" : "the earlier session data, which could not be read, was moved aside", StringComparison.Ordinal));
|
||||
_ = Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// While something holds the stored session open, a staged session that belongs to the stored pairing cannot be moved
|
||||
/// into place. It is still the one a reader gets, so nothing acts on the session of the previous pairing, and the move
|
||||
/// happens on the first read after the file is released.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StagedSessionIsUsedBeforeItCanBeMovedIntoPlace()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
|
||||
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
using AgentStore blocked = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("replacement-device", blocked.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.Deferred, blocked.StagedPairing);
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.Completed, store.StagedPairing);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.None, store.StagedPairing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Moving a staged session into place over a stored session that cannot be read preserves the unreadable bytes under a recovery name.</summary>
|
||||
[Fact]
|
||||
public void StagedSessionReplacesAnUnreadableStoredSessionWithoutLosingIt()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
byte[] unreadable = [9, 8, 7];
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), unreadable);
|
||||
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.CompletedAfterQuarantine, store.StagedPairing);
|
||||
Assert.Equal(unreadable, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A staged session is a leftover, and is deleted on the next read, when it names another device (a crash before the
|
||||
/// pairing was replaced), another server, when no pairing is stored, or when the stored session already belongs to
|
||||
/// the pairing. The stored session is returned as it was.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("another-device")]
|
||||
[InlineData("another-server")]
|
||||
[InlineData("no-pairing")]
|
||||
[InlineData("already-stored")]
|
||||
public void LeftoverStagedSessionIsDiscardedOnRead(string leftover)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
Stage(fixture, leftover is "another-device" ? "interrupted-device" : "device", leftover is "another-server" ? "https://elsewhere.invalid/" : "https://synthetic.invalid/");
|
||||
using(AgentStore setup = new(fixture.Local.DirectoryPath))
|
||||
{
|
||||
setup.SaveConfiguration(new AgentConfiguration("https://synthetic.invalid", "device", "synthetic-token"));
|
||||
}
|
||||
if(leftover is "no-pairing")
|
||||
{ File.Delete(path); }
|
||||
if(leftover is "another-server" or "another-device")
|
||||
{
|
||||
// Without a stored session only the staged session's own server or device can be the reason it is discarded.
|
||||
File.Delete(authPath);
|
||||
}
|
||||
byte[]? stored = File.Exists(authPath) ? File.ReadAllBytes(authPath) : null;
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
AgentAuthentication? loaded = store.LoadAuthentication();
|
||||
Assert.Equal(StagedPairingOutcome.Discarded, store.StagedPairing);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Assert.Equal(stored is null ? null : "device", loaded?.Binding.DeviceId);
|
||||
Assert.Equal(stored, File.Exists(authPath) ? File.ReadAllBytes(authPath) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A staged session that cannot be read is preserved under a recovery name, not deleted and not trusted; the stored session is returned.</summary>
|
||||
[Fact]
|
||||
public void UnreadableStagedSessionIsSetAside()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
byte[] unreadable = [2, 4, 6];
|
||||
File.WriteAllBytes(StagedSession(fixture), unreadable);
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.SetAside, store.StagedPairing);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Assert.Equal(unreadable, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Saving a session normally removes a readable staged session, because what is saved is newer; and staging again
|
||||
/// replaces what was staged before, so the last pairing change is the one that is finished.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SavingOrStagingAgainSupersedesAStagedSession()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
Stage(fixture, "first-device", "https://synthetic.invalid/");
|
||||
using(AgentStore store = new(fixture.Local.DirectoryPath))
|
||||
{
|
||||
store.SaveAuthentication(new AgentAuthentication(fixture.Session.Binding with { DeviceId = "first-device" }, fixture.Session.ExportCredential()));
|
||||
}
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
Stage(fixture, "second-device", "https://synthetic.invalid/");
|
||||
Stage(fixture, "third-device", "https://synthetic.invalid/");
|
||||
using AgentStore reader = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("third-device", reader.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.Completed, reader.StagedPairing);
|
||||
Assert.Equal("third-device", reader.LoadConfiguration()!.DeviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A leftover staged session that something holds open cannot be deleted or moved aside. The read must still return the
|
||||
/// stored session, must not claim to have discarded or set aside a file that is still there, and the first read after
|
||||
/// the file is released deals with it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void HeldLeftoverStagedSessionDoesNotFailTheReadAndIsDealtWithLater(bool unreadable)
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
if(unreadable)
|
||||
{ File.WriteAllBytes(StagedSession(fixture), [2, 4, 6]); }
|
||||
else
|
||||
{ File.Copy(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), StagedSession(fixture)); }
|
||||
using(FileStream held = new(StagedSession(fixture), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
using AgentStore blocked = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("device", blocked.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.None, blocked.StagedPairing);
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
Assert.Empty(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"));
|
||||
}
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(unreadable ? StagedPairingOutcome.SetAside : StagedPairingOutcome.Discarded, store.StagedPairing);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A session saved after a pairing change was staged is newer than the staged one, even when it does not belong to the
|
||||
/// stored pairing (a sign-in for a new pairing, say) and the staged file could not be removed at the time. The save
|
||||
/// stamps itself later than the staged file, here although the staged file carries a time in the future as it would
|
||||
/// after the clock stepped back, and the staged session does not replace it later.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SessionSavedAfterStagingIsNotReplacedByTheStagedOne()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
|
||||
File.SetLastWriteTimeUtc(StagedSession(fixture), DateTime.UtcNow.AddHours(1));
|
||||
using(FileStream held = new(StagedSession(fixture), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
using AgentStore saving = new(fixture.Local.DirectoryPath);
|
||||
saving.SaveAuthentication(new AgentAuthentication(fixture.Session.Binding with { DeviceId = null }, fixture.Session.ExportCredential()));
|
||||
}
|
||||
Assert.True(File.Exists(StagedSession(fixture)));
|
||||
Assert.True(File.GetLastWriteTimeUtc(authPath) > File.GetLastWriteTimeUtc(StagedSession(fixture)));
|
||||
byte[] saved = File.ReadAllBytes(authPath);
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Null(store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.Discarded, store.StagedPairing);
|
||||
Assert.Equal(saved, File.ReadAllBytes(authPath));
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Staging never stamps the staged session earlier than the stored session it is about to replace, so a clock that
|
||||
/// stepped back between the two writes cannot make the new pairing's session look like a leftover: it is still moved
|
||||
/// into place. The stored session here carries a time in the future, as it would after such a step.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StagedSessionIsNotOlderThanTheSessionItReplaces()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
File.SetLastWriteTimeUtc(authPath, DateTime.UtcNow.AddHours(1));
|
||||
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
|
||||
Assert.Equal(File.GetLastWriteTimeUtc(authPath), File.GetLastWriteTimeUtc(StagedSession(fixture)));
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
|
||||
Assert.Equal(StagedPairingOutcome.Completed, store.StagedPairing);
|
||||
Assert.False(File.Exists(StagedSession(fixture)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stages a pairing for the given device together with a session that names the given device on the given server, without finishing the change.</summary>
|
||||
private static void Stage(SessionFixture fixture, string deviceId, string sessionOrigin)
|
||||
{
|
||||
Uri origin = new(sessionOrigin);
|
||||
using AgentStore store = new(fixture.Local.DirectoryPath);
|
||||
store.StagePairing(new AgentConfiguration("https://synthetic.invalid", deviceId, "synthetic-" + deviceId),
|
||||
new AgentAuthentication(new QqqSessionBinding(origin, "principal", "tenant", deviceId), new QqqSessionCredential(origin, "synthetic-qqq-cookie")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Where the store keeps a session that belongs to a pairing change in progress.</summary>
|
||||
private static string StagedSession(SessionFixture fixture) => Path.Combine(fixture.Local.DirectoryPath, "qqq-session.staged.dpapi");
|
||||
|
||||
|
||||
|
||||
/// <summary>A fake session for a user with no device bound that treats the existing device id as revoked, which is what the server reports after a pairing is withdrawn.</summary>
|
||||
private static SessionFixture.FakeSession UnboundSession()
|
||||
{
|
||||
|
||||
@@ -65,6 +65,16 @@ internal sealed class SessionFixture : IDisposable
|
||||
internal QqqSessionException.FailureKind? ResumeFailure { get; set; }
|
||||
internal bool ClaimedLegacy;
|
||||
internal bool DenyBinding;
|
||||
|
||||
/// <summary>When set, binding a device fails as a lost connection before the server has bound anything.</summary>
|
||||
internal bool FailBindingTransport;
|
||||
/// <summary>When set, a successful bind reports a different principal, as if the device had been bound for another user.</summary>
|
||||
internal bool BindAsAnotherPrincipal;
|
||||
|
||||
/// <summary>How many more credential exports succeed before one fails as a lost connection; null never fails.</summary>
|
||||
internal int? ExportsBeforeFailure;
|
||||
private string? _issuedDeviceId;
|
||||
private string? _deviceBeforeIssue;
|
||||
internal string? RevokedDeviceId;
|
||||
internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
|
||||
internal bool DelayUnauthorizedPoll { get; set; }
|
||||
@@ -89,8 +99,19 @@ internal sealed class SessionFixture : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed synthetic cookie credential for this origin.</summary>
|
||||
public QqqSessionCredential ExportCredential() => new(Origin, "synthetic-qqq-cookie");
|
||||
/// <summary>Returns a fixed synthetic cookie credential for this origin, or fails as a lost connection once the scripted number of exports is used up.</summary>
|
||||
public QqqSessionCredential ExportCredential()
|
||||
{
|
||||
if(ExportsBeforeFailure is { } remaining)
|
||||
{
|
||||
if(remaining == 0)
|
||||
{
|
||||
throw new IOException("Synthetic failure while exporting the session.");
|
||||
}
|
||||
ExportsBeforeFailure = remaining - 1;
|
||||
}
|
||||
return new(Origin, "synthetic-qqq-cookie");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -145,16 +166,35 @@ internal sealed class SessionFixture : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses with permission-denied when binding is denied or the device was revoked; otherwise checks the device id matches and records whether a legacy device was claimed.</summary>
|
||||
/// <summary>
|
||||
/// Fails as a lost connection when scripted to, and refuses with permission-denied when binding is denied or the device was
|
||||
/// revoked. Otherwise it behaves as the server does: the session takes on a device only when that device is bound, so binding
|
||||
/// the device an enrollment issued switches to it, and binding the device held before switches back. Any other device must
|
||||
/// already be the one the session holds. It records whether a legacy device was claimed and returns the binding (as another
|
||||
/// principal when scripted to).
|
||||
/// </summary>
|
||||
public Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(FailBindingTransport)
|
||||
{
|
||||
throw new IOException("Synthetic connection loss while binding the device.");
|
||||
}
|
||||
if(DenyBinding || string.Equals(RevokedDeviceId, credential.DeviceId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "Synthetic binding denied.");
|
||||
}
|
||||
if(string.Equals(credential.DeviceId, _issuedDeviceId, StringComparison.Ordinal) && !string.Equals(Binding.DeviceId, _issuedDeviceId, StringComparison.Ordinal))
|
||||
{
|
||||
_deviceBeforeIssue = Binding.DeviceId;
|
||||
Binding = Binding with { DeviceId = _issuedDeviceId };
|
||||
}
|
||||
else if(_deviceBeforeIssue is not null && string.Equals(credential.DeviceId, _deviceBeforeIssue, StringComparison.Ordinal))
|
||||
{
|
||||
Binding = Binding with { DeviceId = _deviceBeforeIssue };
|
||||
}
|
||||
Assert.Equal(Binding.DeviceId, credential.DeviceId);
|
||||
ClaimedLegacy = claimLegacyDevice;
|
||||
return Task.FromResult(Binding);
|
||||
return Task.FromResult(BindAsAnotherPrincipal ? Binding with { PrincipalId = "someone-else" } : Binding);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +213,7 @@ internal sealed class SessionFixture : IDisposable
|
||||
{
|
||||
return new HttpResponseMessage(Status) { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
}
|
||||
Binding = Binding with { DeviceId = "replacement-device" };
|
||||
_issuedDeviceId = "replacement-device";
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(new EnrollmentResponse(1, "replacement-device", "replacement-token", 5), PrintApiClient.JsonOptions), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
if(DelayUnauthorizedPoll)
|
||||
|
||||
Reference in new issue
Block a user