Public Access
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
214 lines
8.1 KiB
C#
214 lines
8.1 KiB
C#
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>
|
|
/// Compares two RPM version strings the way <c>rpm</c> itself does.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
|
|
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
|
|
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
|
|
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
|
|
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
|
|
/// </para>
|
|
/// <para>
|
|
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
|
|
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
|
|
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
|
|
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
|
|
/// of letters are compared separately and never against each other; everything that is neither is a
|
|
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
|
|
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
|
|
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
|
|
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
|
|
/// except the end of the string.
|
|
/// </para>
|
|
/// <para>
|
|
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
|
|
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
|
|
/// a disagreement with the program that will actually decide.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class RpmVersion
|
|
{
|
|
/// <summary>
|
|
/// Orders two RPM version strings.
|
|
/// </summary>
|
|
/// <param name="left">The left version.</param>
|
|
/// <param name="right">The right version.</param>
|
|
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
|
|
/// <remarks>
|
|
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
|
|
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
|
|
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
|
|
/// leave the client on the build it has rather than throwing on a background timer.
|
|
/// </remarks>
|
|
internal static int Compare(string? left, string? right)
|
|
{
|
|
var one = left ?? string.Empty;
|
|
var two = right ?? string.Empty;
|
|
|
|
if (string.Equals(one, two, StringComparison.Ordinal))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
int i = 0, j = 0;
|
|
|
|
while (i < one.Length || j < two.Length)
|
|
{
|
|
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
|
|
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
|
|
Skip(one, ref i);
|
|
Skip(two, ref j);
|
|
|
|
if (CompareMarkers(one, i, two, j) is { } marker)
|
|
{
|
|
if (marker != 0)
|
|
{
|
|
return marker;
|
|
}
|
|
|
|
// Both sides carried the same marker, so step over it and keep going.
|
|
i++;
|
|
j++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (i >= one.Length || j >= two.Length)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
|
|
{
|
|
return ordering;
|
|
}
|
|
}
|
|
|
|
// Both ran out together, so every segment matched and the only differences were separators.
|
|
if (i >= one.Length && j >= two.Length)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
// Whichever still has characters left has more version, and more version is newer.
|
|
return i < one.Length ? 1 : -1;
|
|
}
|
|
|
|
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
|
|
private static void Skip(string value, ref int at)
|
|
{
|
|
while (at < value.Length && !IsVersionCharacter(value[at]))
|
|
{
|
|
at++;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
|
|
/// the same one, so the caller should step over it; otherwise the ordering.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
|
|
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
|
|
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
|
|
/// is the difference between the two halves of that sentence.
|
|
/// </remarks>
|
|
private static int? CompareMarkers(string one, int i, string two, int j)
|
|
{
|
|
var oneTilde = i < one.Length && one[i] == '~';
|
|
var twoTilde = j < two.Length && two[j] == '~';
|
|
|
|
if (oneTilde || twoTilde)
|
|
{
|
|
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
|
|
}
|
|
|
|
var oneCaret = i < one.Length && one[i] == '^';
|
|
var twoCaret = j < two.Length && two[j] == '^';
|
|
|
|
if (!oneCaret && !twoCaret)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (i >= one.Length)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (j >= two.Length)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes one segment from each side and orders them, advancing past both.
|
|
/// </summary>
|
|
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
|
|
private static int? CompareSegments(string one, ref int i, string two, ref int j)
|
|
{
|
|
var numeric = char.IsAsciiDigit(one[i]);
|
|
|
|
var start1 = i;
|
|
var start2 = j;
|
|
|
|
Take(one, ref i, numeric);
|
|
Take(two, ref j, numeric);
|
|
|
|
var segment1 = one.AsSpan(start1, i - start1);
|
|
var segment2 = two.AsSpan(start2, j - start2);
|
|
|
|
// The two sides are at segments of different kinds — digits against letters. rpm calls the
|
|
// numeric one newer, always, on the reasoning that a version that has started counting is further
|
|
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
|
|
// character.
|
|
if (segment2.IsEmpty)
|
|
{
|
|
return numeric ? 1 : -1;
|
|
}
|
|
|
|
if (numeric)
|
|
{
|
|
segment1 = segment1.TrimStart('0');
|
|
segment2 = segment2.TrimStart('0');
|
|
|
|
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
|
|
// string comparison would call "9" larger than "10".
|
|
if (segment1.Length != segment2.Length)
|
|
{
|
|
return segment1.Length > segment2.Length ? 1 : -1;
|
|
}
|
|
}
|
|
|
|
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
|
|
|
|
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
|
|
}
|
|
|
|
/// <summary>Advances over one run of digits, or one run of letters.</summary>
|
|
private static void Take(string value, ref int at, bool numeric)
|
|
{
|
|
while (at < value.Length
|
|
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
|
|
{
|
|
at++;
|
|
}
|
|
}
|
|
|
|
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
|
|
private static bool IsVersionCharacter(char value) =>
|
|
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
|
|
}
|