fix: stop rendering a threshold derived from an invalid shard count

Gating the generate button left the sentence above it still interpolating
defaultThreshold(totalShards) for values that had just been rejected: an
emptied field coerces to 0 and renders "Will require any 1 shards", and 3.5
renders 2. Give Share.vue's `requiredShards` the same contract as Print.vue's
`threshold` — `undefined` unless the count is usable — show an em dash in the
sentence, and guard both the generated-shards block and crypto.share() on it.

Also strengthen the threshold invariant test. Asserting only
`defaultThreshold(n) > n / 2` does not pin the policy: floor(n/2)+2, the very
formula this branch removed, satisfies it for every even n. Assert the
smallest strict majority instead, which floor(n/2)+1 alone satisfies.
This commit is contained in:
Kirill Pimenov 2026-07-27 17:10:01 +02:00
parent 9f74cc3f6c
commit fca0f7fe89
2 changed files with 18 additions and 7 deletions

View File

@ -31,7 +31,7 @@
<p>
<label>3. Shards</label>
<br />
Will require any {{ requiredShards }} shards out of
Will require any {{ requiredShardsLabel }} shards out of
<input
id="totalShards"
v-model.number="totalShards"
@ -67,7 +67,7 @@
</button>
</div>
<div v-if="encryptionMode">
<div v-if="encryptionMode && requiredShards !== undefined">
<div class="card" framed="true" transparent="true">
<label>4. Your passphrase for the recovery is:</label>
<div class="flex justify-between align-center">
@ -131,12 +131,18 @@ export default Vue.extend({
shardCountValid(): boolean {
return isValidShardCount(this.totalShards);
},
requiredShards(): number {
return defaultThreshold(this.totalShards);
// Same contract as Print.vue's `threshold`: `undefined` while the count is
// not usable, so a coerced value ("" divides to 0, giving a bogus 1) can
// neither be printed nor reach ShardInfo's required Number prop.
requiredShards(): number | undefined {
return isValidShardCount(this.totalShards) ? defaultThreshold(this.totalShards) : undefined;
},
requiredShardsLabel(): string {
return this.requiredShards === undefined ? "—" : String(this.requiredShards);
},
shards(): string[] {
this.$eventHub.$emit("clearAlerts");
if (!this.encryptionMode) {
if (!this.encryptionMode || this.requiredShards === undefined) {
return [];
}
try {

View File

@ -16,9 +16,14 @@ describe("defaultThreshold", () => {
expect(defaultThreshold(total)).toBe(expected);
});
test("always requires a strict majority across the whole UI range", () => {
// A lower bound alone is too weak to pin the policy: floor(8/2)+2 = 6 is also
// "more than half of 8". Assert the threshold is the *smallest* strict
// majority, which is the property that makes floor(n/2)+1 the only answer.
test("is the smallest strict majority across the whole UI range", () => {
for (let n = 3; n <= 255; n++) {
expect(defaultThreshold(n)).toBeGreaterThan(n / 2);
const threshold = defaultThreshold(n);
expect(threshold).toBeGreaterThan(n / 2);
expect(threshold - 1).toBeLessThanOrEqual(n / 2);
}
});
});