@colophon-claims/verify 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/dist/admission/contracts.d.ts +483 -0
- package/dist/admission/contracts.js +285 -0
- package/dist/admission/index.d.ts +2 -0
- package/dist/admission/index.js +2 -0
- package/dist/admission/prompted-commitment.d.ts +19 -0
- package/dist/admission/prompted-commitment.js +52 -0
- package/dist/admission/prompted-selection.d.ts +24 -0
- package/dist/admission/prompted-selection.js +85 -0
- package/dist/admission/verification.d.ts +17 -3
- package/dist/admission/verification.js +230 -49
- package/dist/assets.d.ts +21 -1
- package/dist/assets.js +60 -3
- package/dist/binding/beacon-binding.d.ts +230 -0
- package/dist/binding/beacon-binding.js +325 -0
- package/dist/binding/report-face.d.ts +45 -0
- package/dist/binding/report-face.js +153 -0
- package/dist/cli.js +74 -12
- package/dist/index.d.ts +14 -3
- package/dist/index.js +16 -3
- package/dist/manifest.d.ts +30 -4
- package/dist/manifest.js +30 -0
- package/dist/materialize.d.ts +7 -0
- package/dist/materialize.js +7 -0
- package/dist/outcome.d.ts +31 -0
- package/dist/outcome.js +45 -0
- package/dist/profile/binary-qualification.js +6 -0
- package/dist/profile/claim-consistency.d.ts +8 -1
- package/dist/profile/claim-consistency.js +4 -4
- package/dist/profile/claim.d.ts +203 -1
- package/dist/profile/claim.js +192 -43
- package/dist/profile/disclosure.d.ts +273 -0
- package/dist/profile/disclosure.js +240 -0
- package/dist/profile/pinning-evidence.d.ts +1 -1
- package/dist/profile/run-results.d.ts +8 -2
- package/dist/profile/run-results.js +9 -3
- package/dist/profile/task-selection.d.ts +69 -0
- package/dist/profile/task-selection.js +140 -0
- package/dist/reader-instructions.d.ts +38 -1
- package/dist/reader-instructions.js +42 -2
- package/dist/schema.d.ts +13 -2
- package/dist/schema.js +39 -8
- package/dist/signers.d.ts +48 -0
- package/dist/signers.js +77 -0
- package/dist/verify.d.ts +20 -6
- package/dist/verify.js +156 -31
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -14
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** The procedure identifier a binding record must carry. */
|
|
3
|
+
export declare const BEACON_BINDING_PROCEDURE: "beacon-binding/1";
|
|
4
|
+
/**
|
|
5
|
+
* How strongly a source's own round index proves that its value postdates a given instant.
|
|
6
|
+
*
|
|
7
|
+
* `deterministic-round-time` sources index rounds by a published arithmetic schedule, so the
|
|
8
|
+
* proof is offline arithmetic. `attributive-height` sources index by block height, whose time
|
|
9
|
+
* needs headers the reader must obtain separately -- the claim is then what the chain asserts,
|
|
10
|
+
* checked elsewhere, and never an offline proof.
|
|
11
|
+
*/
|
|
12
|
+
export type BeaconSourceTimeBasis = "deterministic-round-time" | "attributive-height";
|
|
13
|
+
export interface BeaconSourceDefinition {
|
|
14
|
+
readonly timeBasis: BeaconSourceTimeBasis;
|
|
15
|
+
/** Unix seconds of round 1. Present only on `deterministic-round-time` sources. */
|
|
16
|
+
readonly genesisTimeSeconds?: number;
|
|
17
|
+
/** Seconds between rounds. Present only on `deterministic-round-time` sources. */
|
|
18
|
+
readonly periodSeconds?: number;
|
|
19
|
+
/** Reader-facing name used by the report face. */
|
|
20
|
+
readonly displayName: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The beacons this procedure admits. Values are the sources' own published chain parameters and
|
|
24
|
+
* are part of the derivation: a reader recomputing `beaconRoundInstant` needs exactly these
|
|
25
|
+
* numbers, so they live in the code rather than in a comment.
|
|
26
|
+
*/
|
|
27
|
+
export declare const BEACON_SOURCES: {
|
|
28
|
+
readonly "drand/quicknet": {
|
|
29
|
+
readonly timeBasis: "deterministic-round-time";
|
|
30
|
+
readonly genesisTimeSeconds: 1692803367;
|
|
31
|
+
readonly periodSeconds: 3;
|
|
32
|
+
readonly displayName: "drand quicknet";
|
|
33
|
+
};
|
|
34
|
+
readonly "drand/default": {
|
|
35
|
+
readonly timeBasis: "deterministic-round-time";
|
|
36
|
+
readonly genesisTimeSeconds: 1595431050;
|
|
37
|
+
readonly periodSeconds: 30;
|
|
38
|
+
readonly displayName: "drand default chain";
|
|
39
|
+
};
|
|
40
|
+
readonly "bitcoin/mainnet": {
|
|
41
|
+
readonly timeBasis: "attributive-height";
|
|
42
|
+
readonly displayName: "Bitcoin mainnet";
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export type BeaconSourceId = keyof typeof BEACON_SOURCES;
|
|
46
|
+
export declare const BEACON_SOURCE_IDS: readonly BeaconSourceId[];
|
|
47
|
+
/**
|
|
48
|
+
* A schema-level sanity ceiling on a round index. Not a beacon limit, and deliberately not the
|
|
49
|
+
* representability guarantee either: the ceiling that matters is per-source, because it falls out
|
|
50
|
+
* of each source's own period, and one shared number cannot be sound for all of them. Quicknet's
|
|
51
|
+
* 3-second period puts round 1,000,000,000,000 some 95,000 years out and inside what `Date`
|
|
52
|
+
* represents; the default chain's 30-second period puts the same round ten times further out and
|
|
53
|
+
* outside it. So this bound only rejects the absurd, and `beaconRoundInstant` -- not the schema --
|
|
54
|
+
* owns the guarantee that the arithmetic stays representable. Every real Bitcoin height is eight
|
|
55
|
+
* orders of magnitude below this.
|
|
56
|
+
*/
|
|
57
|
+
export declare const MAX_BEACON_ROUND = 1000000000000;
|
|
58
|
+
/**
|
|
59
|
+
* A public beacon reference: which beacon, which round or height, and the value it published
|
|
60
|
+
* there. `round` is the source's own index -- a drand round number, a Bitcoin block height.
|
|
61
|
+
*/
|
|
62
|
+
export declare const BeaconReferenceSchema: z.ZodObject<{
|
|
63
|
+
source: z.ZodEnum<{
|
|
64
|
+
"drand/quicknet": "drand/quicknet";
|
|
65
|
+
"drand/default": "drand/default";
|
|
66
|
+
"bitcoin/mainnet": "bitcoin/mainnet";
|
|
67
|
+
}>;
|
|
68
|
+
round: z.ZodNumber;
|
|
69
|
+
value: z.ZodString;
|
|
70
|
+
}, z.core.$strict>;
|
|
71
|
+
export type BeaconReference = z.infer<typeof BeaconReferenceSchema>;
|
|
72
|
+
/**
|
|
73
|
+
* The two shapes, disjoint by construction so no reader can mistake the weaker binding for the
|
|
74
|
+
* stronger one:
|
|
75
|
+
*
|
|
76
|
+
* - `sampled` -- a slate drawn from a larger pool. `sample` is the claim; the verifier recomputes
|
|
77
|
+
* it and fails on mismatch.
|
|
78
|
+
* - `census` -- the whole declared population runs, so there is no draw. `order` is the claim, and
|
|
79
|
+
* it binds execution order only.
|
|
80
|
+
*/
|
|
81
|
+
export declare const RunBindingSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
82
|
+
mode: z.ZodLiteral<"sampled">;
|
|
83
|
+
poolItemSha256s: z.ZodArray<z.ZodString>;
|
|
84
|
+
sampleSize: z.ZodNumber;
|
|
85
|
+
sample: z.ZodArray<z.ZodString>;
|
|
86
|
+
procedure: z.ZodLiteral<"beacon-binding/1">;
|
|
87
|
+
sealDigest: z.ZodString;
|
|
88
|
+
sealedAt: z.ZodString;
|
|
89
|
+
beacon: z.ZodObject<{
|
|
90
|
+
source: z.ZodEnum<{
|
|
91
|
+
"drand/quicknet": "drand/quicknet";
|
|
92
|
+
"drand/default": "drand/default";
|
|
93
|
+
"bitcoin/mainnet": "bitcoin/mainnet";
|
|
94
|
+
}>;
|
|
95
|
+
round: z.ZodNumber;
|
|
96
|
+
value: z.ZodString;
|
|
97
|
+
}, z.core.$strict>;
|
|
98
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
99
|
+
mode: z.ZodLiteral<"census">;
|
|
100
|
+
itemSha256s: z.ZodArray<z.ZodString>;
|
|
101
|
+
order: z.ZodArray<z.ZodString>;
|
|
102
|
+
procedure: z.ZodLiteral<"beacon-binding/1">;
|
|
103
|
+
sealDigest: z.ZodString;
|
|
104
|
+
sealedAt: z.ZodString;
|
|
105
|
+
beacon: z.ZodObject<{
|
|
106
|
+
source: z.ZodEnum<{
|
|
107
|
+
"drand/quicknet": "drand/quicknet";
|
|
108
|
+
"drand/default": "drand/default";
|
|
109
|
+
"bitcoin/mainnet": "bitcoin/mainnet";
|
|
110
|
+
}>;
|
|
111
|
+
round: z.ZodNumber;
|
|
112
|
+
value: z.ZodString;
|
|
113
|
+
}, z.core.$strict>;
|
|
114
|
+
}, z.core.$strict>], "mode">;
|
|
115
|
+
export type RunBinding = z.infer<typeof RunBindingSchema>;
|
|
116
|
+
export declare class RunBindingError extends Error {
|
|
117
|
+
readonly name = "RunBindingError";
|
|
118
|
+
readonly path: string;
|
|
119
|
+
constructor(path: string, detail: string);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The instant a `deterministic-round-time` beacon published `round`, as an RFC 3339 UTC string, or
|
|
123
|
+
* `undefined` for a source whose round index carries no offline time. The schedule is
|
|
124
|
+
* `genesis + (round - 1) * period`: round 1 is published at genesis.
|
|
125
|
+
*
|
|
126
|
+
* Refuses (throws `RunBindingError`) when that instant falls outside the range `Date` represents.
|
|
127
|
+
* The check lives here rather than on the schema because the largest representable round is a
|
|
128
|
+
* function of the source's own period -- the default chain runs out ten times earlier than
|
|
129
|
+
* quicknet does -- so a single schema ceiling would leave the slower source's tail passing
|
|
130
|
+
* validation and then throwing an untyped `RangeError` from `toISOString` deep inside
|
|
131
|
+
* verification. Guarding the arithmetic where the arithmetic happens makes the typed refusal a
|
|
132
|
+
* property of this function, and therefore true for every source and every caller.
|
|
133
|
+
*/
|
|
134
|
+
export declare function beaconRoundInstant(beacon: Pick<BeaconReference, "source" | "round">): string | undefined;
|
|
135
|
+
/** The one round a run sealed at a given instant may bind to, and when that round is published. */
|
|
136
|
+
export interface RequiredBeaconRound {
|
|
137
|
+
readonly round: number;
|
|
138
|
+
/** RFC 3339 UTC, from the source's own schedule -- the same arithmetic `beaconRoundInstant` does. */
|
|
139
|
+
readonly publishedAt: string;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The one round a run sealed at `sealedAt` may bind to on a `deterministic-round-time` source: the
|
|
143
|
+
* first round that source publishes STRICTLY after the seal (issue #3322).
|
|
144
|
+
*
|
|
145
|
+
* The point is that the seal already fixes it. `verifyRunBinding` admits any round whose instant
|
|
146
|
+
* postdates the seal, which makes the beacon VALUE unpredictable but leaves the CHOICE among
|
|
147
|
+
* realized values open: between seal and binding an operator sees many published rounds, can derive
|
|
148
|
+
* what each would produce, and can bind the one they prefer. The standard construction is to commit
|
|
149
|
+
* at seal time to a specific future round -- and for a scheduled source no separate commitment
|
|
150
|
+
* record is needed, because `(source, sealedAt)` already determines exactly one such round, and both
|
|
151
|
+
* are fixed at seal time and carried by the binding itself.
|
|
152
|
+
*
|
|
153
|
+
* From `instant(r) = genesis + (r - 1) * period`, the smallest `r` with `instant(r) > sealedAt` is
|
|
154
|
+
* `floor((sealedAt - genesis) / period) + 2`, clamped to round 1 for a seal that predates genesis.
|
|
155
|
+
*
|
|
156
|
+
* `undefined` -- meaning no round is derivable, so the operator's choice remains and the report face
|
|
157
|
+
* says so -- when the source indexes by block height rather than by a schedule, when `sealedAt` is
|
|
158
|
+
* unparseable, or when the required round leaves `MAX_BEACON_ROUND` or the representable range.
|
|
159
|
+
*/
|
|
160
|
+
export declare function requiredBeaconRound(source: BeaconSourceId, sealedAt: string): RequiredBeaconRound | undefined;
|
|
161
|
+
export interface BeaconOrderParams {
|
|
162
|
+
/** `sha256:<64 lowercase hex>` -- the sealed record the beacon postdates. */
|
|
163
|
+
readonly sealDigest: string;
|
|
164
|
+
/** The beacon's published value, 64 lowercase hex characters. */
|
|
165
|
+
readonly beaconValue: string;
|
|
166
|
+
/** The identity set to order. Non-empty, unique, each `sha256:<64 lowercase hex>`. */
|
|
167
|
+
readonly itemSha256s: readonly string[];
|
|
168
|
+
}
|
|
169
|
+
export interface BeaconOrderResult {
|
|
170
|
+
/** `sha256:<64 lowercase hex>` of the sorted, unique identity set. */
|
|
171
|
+
readonly poolDigest: string;
|
|
172
|
+
/** Every item, ascending by HMAC stream (unsigned byte order), ties by code-unit order. */
|
|
173
|
+
readonly order: readonly string[];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The derivation itself. Refuses (throws `RunBindingError`) when `sealDigest` or `beaconValue` is
|
|
177
|
+
* malformed, or when `itemSha256s` is empty, contains a duplicate, or contains a malformed entry.
|
|
178
|
+
*/
|
|
179
|
+
export declare function computeBeaconOrder(params: BeaconOrderParams): BeaconOrderResult;
|
|
180
|
+
/** Whether the beacon's postdating of the seal was proven here, or only asserted by its chain. */
|
|
181
|
+
export type BeaconPostSealBasis = "proven-offline" | "attributive";
|
|
182
|
+
/**
|
|
183
|
+
* Whether the seal fixed WHICH post-seal value applied, or the operator picked it (issue #3322).
|
|
184
|
+
*
|
|
185
|
+
* `seal-derived` -- the named round is `requiredBeaconRound` for this source and seal, so there was
|
|
186
|
+
* exactly one round to bind to and no choosing happened. `operator-chosen` -- the round postdates
|
|
187
|
+
* the seal (that is still checked) but was selected afterwards from among those published since, so
|
|
188
|
+
* the derivation is one of several the operator could have realized. Every `attributive-height`
|
|
189
|
+
* source is `operator-chosen` by construction: a block height carries no schedule, so no round
|
|
190
|
+
* follows from the seal.
|
|
191
|
+
*
|
|
192
|
+
* This is derived and reported, never enforced here. A verifier states what the bytes are; the
|
|
193
|
+
* choosing happens in the producer, which is where the refusal belongs (`bind` refuses a round other
|
|
194
|
+
* than the derivable one). Refusing here would also make every already-sealed record unreadable.
|
|
195
|
+
*/
|
|
196
|
+
export type BeaconRoundBasis = "seal-derived" | "operator-chosen";
|
|
197
|
+
/** What every verified binding carries, whichever mode produced it. */
|
|
198
|
+
export interface VerifiedRunBindingBase {
|
|
199
|
+
readonly procedure: typeof BEACON_BINDING_PROCEDURE;
|
|
200
|
+
readonly beacon: BeaconReference;
|
|
201
|
+
readonly sealDigest: string;
|
|
202
|
+
readonly sealedAt: string;
|
|
203
|
+
/** The recomputed identity-set digest of the pool (sampled) or population (census). */
|
|
204
|
+
readonly poolDigest: string;
|
|
205
|
+
readonly poolSize: number;
|
|
206
|
+
/** The recomputed full order. In census mode this is the execution order. */
|
|
207
|
+
readonly order: readonly string[];
|
|
208
|
+
readonly postSeal: BeaconPostSealBasis;
|
|
209
|
+
/** Whether the seal fixed which post-seal round applied, or the operator chose it. */
|
|
210
|
+
readonly roundBasis: BeaconRoundBasis;
|
|
211
|
+
/** The beacon's own publication instant, when its source's round index determines one. */
|
|
212
|
+
readonly beaconInstant?: string;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Discriminated on `mode` so `sample` is present exactly when there was a draw. A single optional
|
|
216
|
+
* `sample` would make every reader of the stronger binding write a fallback for a case that cannot
|
|
217
|
+
* happen, and the fallback is where a "0 items" sentence gets shipped.
|
|
218
|
+
*/
|
|
219
|
+
export type VerifiedRunBinding = (VerifiedRunBindingBase & {
|
|
220
|
+
readonly mode: "census";
|
|
221
|
+
}) | (VerifiedRunBindingBase & {
|
|
222
|
+
readonly mode: "sampled";
|
|
223
|
+
readonly sample: readonly string[];
|
|
224
|
+
});
|
|
225
|
+
/**
|
|
226
|
+
* Verifies one binding record: the beacon postdates the seal, and the declared draw or order is
|
|
227
|
+
* exactly what `beacon-binding/1` derives. Throws `RunBindingError` on any disagreement -- the
|
|
228
|
+
* recomputation wins, always; a stored field never does.
|
|
229
|
+
*/
|
|
230
|
+
export declare function verifyRunBinding(candidate: unknown): VerifiedRunBinding;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* `beacon-binding/1` -- binding a sealed run to public randomness that did not exist when it was
|
|
4
|
+
* sealed (issue #2976).
|
|
5
|
+
*
|
|
6
|
+
* Sealing proves a method document predates its publication. It cannot prove the *run* happened
|
|
7
|
+
* after the seal: a party could run privately, write a method describing what already happened,
|
|
8
|
+
* seal it, and re-run. `verify/src/profile/anchor-claims.ts` says as much in the sealed claim's own
|
|
9
|
+
* words -- a time anchor proves "the design's existence by that time and nothing else about the run
|
|
10
|
+
* -- in particular, not that results were produced after it".
|
|
11
|
+
*
|
|
12
|
+
* This procedure closes that gap by deriving a run property from a value that postdates the seal.
|
|
13
|
+
* Where a slate is drawn from a larger pool, the draw is a function of (seal digest, beacon value),
|
|
14
|
+
* so no post-hoc selection is possible: the operator would have had to predict the beacon. Where
|
|
15
|
+
* the slate is a whole census, there is no draw to bind, and the beacon binds only execution
|
|
16
|
+
* ORDER -- a strictly weaker property that this module names as such rather than dressing up.
|
|
17
|
+
*
|
|
18
|
+
* It is the deliberate sibling of `../admission/screening-sample.ts` (`screening-sample/1`) and
|
|
19
|
+
* reuses that procedure's encoding decisions verbatim, including its identity-set digest. The one
|
|
20
|
+
* substantive difference is the HMAC key: `screening-sample/1` keys on a SEALED seed, which is
|
|
21
|
+
* exactly the property #2976 says is insufficient, and this one keys on the seal digest together
|
|
22
|
+
* with a post-seal beacon value. Everything else is deliberately identical so a second implementer
|
|
23
|
+
* who has already built one has nothing new to get wrong.
|
|
24
|
+
*
|
|
25
|
+
* Every encoding choice, restated here so this paragraph alone is reimplementable in any language:
|
|
26
|
+
*
|
|
27
|
+
* - **The HMAC key is `utf8(sealDigest || beaconValue)`.** `sealDigest` enters in its
|
|
28
|
+
* `sha256:`-prefixed lowercase-hex string form (a fixed 71 characters) and `beaconValue` as its
|
|
29
|
+
* 64 lowercase hex digits. Both are fixed-length, so -- as in `screening-sample/1` -- no
|
|
30
|
+
* delimiter separates them; a delimiter would only be a second convention to get wrong. Never
|
|
31
|
+
* raw digest bytes: the whole procedure is text.
|
|
32
|
+
* - **The HMAC message is `utf8(itemSha256)`**, the same `sha256:`-prefixed lowercase-hex form.
|
|
33
|
+
* - **The order is ascending over the 32 unsigned HMAC-SHA256 bytes**, ties broken by `itemSha256`
|
|
34
|
+
* in code-unit order. This is `compareScreeningStreamEntries`, shared rather than re-derived.
|
|
35
|
+
* - **`poolDigest` binds the identity SET**: `sha256:` followed by the SHA-256 of the canonical-JSON
|
|
36
|
+
* bytes of the `itemSha256` values, code-unit sorted and unique. This is
|
|
37
|
+
* `computeScreeningPoolDigest`, shared for the same reason.
|
|
38
|
+
* - **The sample is the first `sampleSize` of that order.** In census mode there is no sample and
|
|
39
|
+
* the order itself is the execution order.
|
|
40
|
+
*
|
|
41
|
+
* The beacon's postdating is checked, not assumed, and how strongly it can be checked depends on
|
|
42
|
+
* the source. A drand round number maps to a time by published chain parameters
|
|
43
|
+
* (`genesis + (round - 1) * period`), so "this value did not exist at seal time" is arithmetic any
|
|
44
|
+
* reader does offline. A Bitcoin height does not: block times need headers, so that check is
|
|
45
|
+
* attributive and this module says so instead of claiming an offline proof it cannot make.
|
|
46
|
+
*
|
|
47
|
+
* Postdating alone would still leave the operator a choice, and issue #3322 closes it: admitting any
|
|
48
|
+
* round later than the seal makes the VALUE unpredictable but not WHICH realized value applies, so
|
|
49
|
+
* an operator could watch the rounds published between lock and launch and bind the one whose
|
|
50
|
+
* derivation they preferred. For a scheduled source the seal already names one round --
|
|
51
|
+
* `requiredBeaconRound`, the first published strictly after it -- so the commitment needs no
|
|
52
|
+
* separate record, the producer refuses any other round, and `roundBasis` reports which of the two
|
|
53
|
+
* situations a record is in so the report face can say only what is true of it.
|
|
54
|
+
*
|
|
55
|
+
* This module does no filesystem or network I/O and throws `RunBindingError` on any invalid input.
|
|
56
|
+
*/
|
|
57
|
+
import { createHmac } from "node:crypto";
|
|
58
|
+
import { z } from "zod";
|
|
59
|
+
import { compareScreeningStreamEntries, computeScreeningPoolDigest, } from "../admission/screening-sample.js";
|
|
60
|
+
/** The procedure identifier a binding record must carry. */
|
|
61
|
+
export const BEACON_BINDING_PROCEDURE = "beacon-binding/1";
|
|
62
|
+
/**
|
|
63
|
+
* The beacons this procedure admits. Values are the sources' own published chain parameters and
|
|
64
|
+
* are part of the derivation: a reader recomputing `beaconRoundInstant` needs exactly these
|
|
65
|
+
* numbers, so they live in the code rather than in a comment.
|
|
66
|
+
*/
|
|
67
|
+
export const BEACON_SOURCES = {
|
|
68
|
+
"drand/quicknet": {
|
|
69
|
+
timeBasis: "deterministic-round-time",
|
|
70
|
+
genesisTimeSeconds: 1692803367,
|
|
71
|
+
periodSeconds: 3,
|
|
72
|
+
displayName: "drand quicknet",
|
|
73
|
+
},
|
|
74
|
+
"drand/default": {
|
|
75
|
+
timeBasis: "deterministic-round-time",
|
|
76
|
+
genesisTimeSeconds: 1595431050,
|
|
77
|
+
periodSeconds: 30,
|
|
78
|
+
displayName: "drand default chain",
|
|
79
|
+
},
|
|
80
|
+
"bitcoin/mainnet": {
|
|
81
|
+
timeBasis: "attributive-height",
|
|
82
|
+
displayName: "Bitcoin mainnet",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
export const BEACON_SOURCE_IDS = Object.keys(BEACON_SOURCES).sort();
|
|
86
|
+
/**
|
|
87
|
+
* A schema-level sanity ceiling on a round index. Not a beacon limit, and deliberately not the
|
|
88
|
+
* representability guarantee either: the ceiling that matters is per-source, because it falls out
|
|
89
|
+
* of each source's own period, and one shared number cannot be sound for all of them. Quicknet's
|
|
90
|
+
* 3-second period puts round 1,000,000,000,000 some 95,000 years out and inside what `Date`
|
|
91
|
+
* represents; the default chain's 30-second period puts the same round ten times further out and
|
|
92
|
+
* outside it. So this bound only rejects the absurd, and `beaconRoundInstant` -- not the schema --
|
|
93
|
+
* owns the guarantee that the arithmetic stays representable. Every real Bitcoin height is eight
|
|
94
|
+
* orders of magnitude below this.
|
|
95
|
+
*/
|
|
96
|
+
export const MAX_BEACON_ROUND = 1_000_000_000_000;
|
|
97
|
+
/**
|
|
98
|
+
* The widest instant `Date` represents, in milliseconds either side of the epoch (ECMA-262:
|
|
99
|
+
* 8.64e15). Beyond it `toISOString` throws an untyped `RangeError`, which is what
|
|
100
|
+
* `beaconRoundInstant` refuses on this module's own terms instead.
|
|
101
|
+
*/
|
|
102
|
+
const MAX_REPRESENTABLE_TIME_MS = 8_640_000_000_000_000;
|
|
103
|
+
const HexValueSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be 64 lowercase hex characters");
|
|
104
|
+
const DigestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "must match ^sha256:[0-9a-f]{64}$");
|
|
105
|
+
const InstantSchema = z.string().datetime({ offset: true });
|
|
106
|
+
/**
|
|
107
|
+
* A public beacon reference: which beacon, which round or height, and the value it published
|
|
108
|
+
* there. `round` is the source's own index -- a drand round number, a Bitcoin block height.
|
|
109
|
+
*/
|
|
110
|
+
export const BeaconReferenceSchema = z.strictObject({
|
|
111
|
+
source: z.enum(Object.keys(BEACON_SOURCES)),
|
|
112
|
+
round: z.number().int().positive().max(MAX_BEACON_ROUND),
|
|
113
|
+
value: HexValueSchema,
|
|
114
|
+
});
|
|
115
|
+
const CommonBindingFields = {
|
|
116
|
+
procedure: z.literal(BEACON_BINDING_PROCEDURE),
|
|
117
|
+
/** The digest of the sealed record this binding postdates -- the run's own seal. */
|
|
118
|
+
sealDigest: DigestSchema,
|
|
119
|
+
/** When that seal was taken. The beacon must postdate it. */
|
|
120
|
+
sealedAt: InstantSchema,
|
|
121
|
+
beacon: BeaconReferenceSchema,
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The two shapes, disjoint by construction so no reader can mistake the weaker binding for the
|
|
125
|
+
* stronger one:
|
|
126
|
+
*
|
|
127
|
+
* - `sampled` -- a slate drawn from a larger pool. `sample` is the claim; the verifier recomputes
|
|
128
|
+
* it and fails on mismatch.
|
|
129
|
+
* - `census` -- the whole declared population runs, so there is no draw. `order` is the claim, and
|
|
130
|
+
* it binds execution order only.
|
|
131
|
+
*/
|
|
132
|
+
export const RunBindingSchema = z.discriminatedUnion("mode", [
|
|
133
|
+
z.strictObject({
|
|
134
|
+
...CommonBindingFields,
|
|
135
|
+
mode: z.literal("sampled"),
|
|
136
|
+
/** The pool the slate was drawn from. Order is irrelevant; the identity set is what binds. */
|
|
137
|
+
poolItemSha256s: z.array(DigestSchema).min(1),
|
|
138
|
+
sampleSize: z.number().int().positive(),
|
|
139
|
+
/** The drawn slate, in derived order. */
|
|
140
|
+
sample: z.array(DigestSchema).min(1),
|
|
141
|
+
}),
|
|
142
|
+
z.strictObject({
|
|
143
|
+
...CommonBindingFields,
|
|
144
|
+
mode: z.literal("census"),
|
|
145
|
+
/** The whole population. Order is irrelevant here; `order` below is the derived claim. */
|
|
146
|
+
itemSha256s: z.array(DigestSchema).min(1),
|
|
147
|
+
/** Every item, in beacon-derived execution order. */
|
|
148
|
+
order: z.array(DigestSchema).min(1),
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
export class RunBindingError extends Error {
|
|
152
|
+
name = "RunBindingError";
|
|
153
|
+
path;
|
|
154
|
+
constructor(path, detail) {
|
|
155
|
+
super(`${path}: ${detail}`);
|
|
156
|
+
this.path = path;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function fail(path, detail) {
|
|
160
|
+
throw new RunBindingError(path, detail);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The instant a `deterministic-round-time` beacon published `round`, as an RFC 3339 UTC string, or
|
|
164
|
+
* `undefined` for a source whose round index carries no offline time. The schedule is
|
|
165
|
+
* `genesis + (round - 1) * period`: round 1 is published at genesis.
|
|
166
|
+
*
|
|
167
|
+
* Refuses (throws `RunBindingError`) when that instant falls outside the range `Date` represents.
|
|
168
|
+
* The check lives here rather than on the schema because the largest representable round is a
|
|
169
|
+
* function of the source's own period -- the default chain runs out ten times earlier than
|
|
170
|
+
* quicknet does -- so a single schema ceiling would leave the slower source's tail passing
|
|
171
|
+
* validation and then throwing an untyped `RangeError` from `toISOString` deep inside
|
|
172
|
+
* verification. Guarding the arithmetic where the arithmetic happens makes the typed refusal a
|
|
173
|
+
* property of this function, and therefore true for every source and every caller.
|
|
174
|
+
*/
|
|
175
|
+
export function beaconRoundInstant(beacon) {
|
|
176
|
+
const source = BEACON_SOURCES[beacon.source];
|
|
177
|
+
if (source.timeBasis !== "deterministic-round-time")
|
|
178
|
+
return undefined;
|
|
179
|
+
const { genesisTimeSeconds, periodSeconds } = source;
|
|
180
|
+
const instantMs = (genesisTimeSeconds + (beacon.round - 1) * periodSeconds) * 1000;
|
|
181
|
+
if (!Number.isFinite(instantMs) || Math.abs(instantMs) > MAX_REPRESENTABLE_TIME_MS) {
|
|
182
|
+
fail("beacon.round", `${source.displayName} round ${beacon.round} is scheduled outside the range a timestamp can `
|
|
183
|
+
+ "represent, so its publication instant cannot be computed");
|
|
184
|
+
}
|
|
185
|
+
return new Date(instantMs).toISOString();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The one round a run sealed at `sealedAt` may bind to on a `deterministic-round-time` source: the
|
|
189
|
+
* first round that source publishes STRICTLY after the seal (issue #3322).
|
|
190
|
+
*
|
|
191
|
+
* The point is that the seal already fixes it. `verifyRunBinding` admits any round whose instant
|
|
192
|
+
* postdates the seal, which makes the beacon VALUE unpredictable but leaves the CHOICE among
|
|
193
|
+
* realized values open: between seal and binding an operator sees many published rounds, can derive
|
|
194
|
+
* what each would produce, and can bind the one they prefer. The standard construction is to commit
|
|
195
|
+
* at seal time to a specific future round -- and for a scheduled source no separate commitment
|
|
196
|
+
* record is needed, because `(source, sealedAt)` already determines exactly one such round, and both
|
|
197
|
+
* are fixed at seal time and carried by the binding itself.
|
|
198
|
+
*
|
|
199
|
+
* From `instant(r) = genesis + (r - 1) * period`, the smallest `r` with `instant(r) > sealedAt` is
|
|
200
|
+
* `floor((sealedAt - genesis) / period) + 2`, clamped to round 1 for a seal that predates genesis.
|
|
201
|
+
*
|
|
202
|
+
* `undefined` -- meaning no round is derivable, so the operator's choice remains and the report face
|
|
203
|
+
* says so -- when the source indexes by block height rather than by a schedule, when `sealedAt` is
|
|
204
|
+
* unparseable, or when the required round leaves `MAX_BEACON_ROUND` or the representable range.
|
|
205
|
+
*/
|
|
206
|
+
export function requiredBeaconRound(source, sealedAt) {
|
|
207
|
+
const definition = BEACON_SOURCES[source];
|
|
208
|
+
if (definition.timeBasis !== "deterministic-round-time")
|
|
209
|
+
return undefined;
|
|
210
|
+
const { genesisTimeSeconds, periodSeconds } = definition;
|
|
211
|
+
const sealedAtMs = Date.parse(sealedAt);
|
|
212
|
+
if (!Number.isFinite(sealedAtMs))
|
|
213
|
+
return undefined;
|
|
214
|
+
const round = Math.max(1, Math.floor((sealedAtMs - genesisTimeSeconds * 1000) / (periodSeconds * 1000)) + 2);
|
|
215
|
+
if (!Number.isSafeInteger(round) || round > MAX_BEACON_ROUND)
|
|
216
|
+
return undefined;
|
|
217
|
+
// `beaconRoundInstant` owns the representability refusal; a round derived from a real seal is
|
|
218
|
+
// within one period of it, so this only ever throws on an input `Date` itself cannot represent.
|
|
219
|
+
let publishedAt;
|
|
220
|
+
try {
|
|
221
|
+
publishedAt = beaconRoundInstant({ source, round });
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
return publishedAt === undefined ? undefined : { round, publishedAt };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The derivation itself. Refuses (throws `RunBindingError`) when `sealDigest` or `beaconValue` is
|
|
230
|
+
* malformed, or when `itemSha256s` is empty, contains a duplicate, or contains a malformed entry.
|
|
231
|
+
*/
|
|
232
|
+
export function computeBeaconOrder(params) {
|
|
233
|
+
const { sealDigest, beaconValue, itemSha256s } = params;
|
|
234
|
+
if (!DigestSchema.safeParse(sealDigest).success) {
|
|
235
|
+
fail("sealDigest", `must match ^sha256:[0-9a-f]{64}$, got ${JSON.stringify(sealDigest)}`);
|
|
236
|
+
}
|
|
237
|
+
if (!HexValueSchema.safeParse(beaconValue).success) {
|
|
238
|
+
fail("beaconValue", `must be 64 lowercase hex characters, got ${JSON.stringify(beaconValue)}`);
|
|
239
|
+
}
|
|
240
|
+
itemSha256s.forEach((itemSha256, index) => {
|
|
241
|
+
if (!DigestSchema.safeParse(itemSha256).success) {
|
|
242
|
+
fail(`itemSha256s[${index}]`, `must match ^sha256:[0-9a-f]{64}$, got ${JSON.stringify(itemSha256)}`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
if (itemSha256s.length === 0)
|
|
246
|
+
fail("itemSha256s", "identity set must be non-empty");
|
|
247
|
+
if (new Set(itemSha256s).size !== itemSha256s.length) {
|
|
248
|
+
fail("itemSha256s", "identity set must not contain duplicate itemSha256 values");
|
|
249
|
+
}
|
|
250
|
+
const key = Buffer.from(`${sealDigest}${beaconValue}`, "utf8");
|
|
251
|
+
const entries = itemSha256s.map((itemSha256) => ({
|
|
252
|
+
itemSha256,
|
|
253
|
+
stream: new Uint8Array(createHmac("sha256", key).update(Buffer.from(itemSha256, "utf8")).digest()),
|
|
254
|
+
}));
|
|
255
|
+
return {
|
|
256
|
+
poolDigest: computeScreeningPoolDigest(itemSha256s),
|
|
257
|
+
order: [...entries].sort(compareScreeningStreamEntries).map((entry) => entry.itemSha256),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Verifies one binding record: the beacon postdates the seal, and the declared draw or order is
|
|
262
|
+
* exactly what `beacon-binding/1` derives. Throws `RunBindingError` on any disagreement -- the
|
|
263
|
+
* recomputation wins, always; a stored field never does.
|
|
264
|
+
*/
|
|
265
|
+
export function verifyRunBinding(candidate) {
|
|
266
|
+
const parsed = RunBindingSchema.safeParse(candidate);
|
|
267
|
+
if (!parsed.success) {
|
|
268
|
+
const issue = parsed.error.issues[0];
|
|
269
|
+
fail(issue.path.length === 0 ? "binding" : issue.path.join("."), issue.message);
|
|
270
|
+
}
|
|
271
|
+
const binding = parsed.data;
|
|
272
|
+
const sealedAtMs = Date.parse(binding.sealedAt);
|
|
273
|
+
const beaconInstant = beaconRoundInstant(binding.beacon);
|
|
274
|
+
let postSeal;
|
|
275
|
+
if (beaconInstant === undefined) {
|
|
276
|
+
postSeal = "attributive";
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (Date.parse(beaconInstant) <= sealedAtMs) {
|
|
280
|
+
fail("beacon.round", `${BEACON_SOURCES[binding.beacon.source].displayName} round ${binding.beacon.round} was published at `
|
|
281
|
+
+ `${beaconInstant}, which does not postdate the seal at ${binding.sealedAt} — a beacon that existed at `
|
|
282
|
+
+ "seal time binds nothing");
|
|
283
|
+
}
|
|
284
|
+
postSeal = "proven-offline";
|
|
285
|
+
}
|
|
286
|
+
const pool = binding.mode === "sampled" ? binding.poolItemSha256s : binding.itemSha256s;
|
|
287
|
+
const derived = computeBeaconOrder({
|
|
288
|
+
sealDigest: binding.sealDigest,
|
|
289
|
+
beaconValue: binding.beacon.value,
|
|
290
|
+
itemSha256s: pool,
|
|
291
|
+
});
|
|
292
|
+
const required = requiredBeaconRound(binding.beacon.source, binding.sealedAt);
|
|
293
|
+
const roundBasis = required !== undefined && required.round === binding.beacon.round
|
|
294
|
+
? "seal-derived"
|
|
295
|
+
: "operator-chosen";
|
|
296
|
+
const common = {
|
|
297
|
+
procedure: BEACON_BINDING_PROCEDURE,
|
|
298
|
+
beacon: binding.beacon,
|
|
299
|
+
sealDigest: binding.sealDigest,
|
|
300
|
+
sealedAt: binding.sealedAt,
|
|
301
|
+
poolDigest: derived.poolDigest,
|
|
302
|
+
poolSize: pool.length,
|
|
303
|
+
order: derived.order,
|
|
304
|
+
postSeal,
|
|
305
|
+
roundBasis,
|
|
306
|
+
...(beaconInstant === undefined ? {} : { beaconInstant }),
|
|
307
|
+
};
|
|
308
|
+
if (binding.mode === "census") {
|
|
309
|
+
if (!sameSequence(binding.order, derived.order)) {
|
|
310
|
+
fail("order", "declared execution order differs from the beacon-binding/1 recomputation");
|
|
311
|
+
}
|
|
312
|
+
return { ...common, mode: "census" };
|
|
313
|
+
}
|
|
314
|
+
if (binding.sampleSize > pool.length) {
|
|
315
|
+
fail("sampleSize", `must not exceed the pool size (${pool.length}), got ${binding.sampleSize}`);
|
|
316
|
+
}
|
|
317
|
+
const sample = derived.order.slice(0, binding.sampleSize);
|
|
318
|
+
if (!sameSequence(binding.sample, sample)) {
|
|
319
|
+
fail("sample", "declared sample differs from the beacon-binding/1 recomputation");
|
|
320
|
+
}
|
|
321
|
+
return { ...common, mode: "sampled", sample };
|
|
322
|
+
}
|
|
323
|
+
function sameSequence(left, right) {
|
|
324
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
325
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report face for `beacon-binding/1` (issue #2976, acceptance criteria 3 and 4): which binding
|
|
3
|
+
* applied, in plain words, and what it does and does not establish.
|
|
4
|
+
*
|
|
5
|
+
* Two rules carried over from `../profile/anchor-claims.ts`, whose conditional honesty copy this
|
|
6
|
+
* mirrors:
|
|
7
|
+
*
|
|
8
|
+
* - **Single-sourced, never mirrored.** `@colophon-claims/core` already depends on this package, so
|
|
9
|
+
* producer and reader render the same function rather than two copies that can drift.
|
|
10
|
+
* - **The words key on facts, not on configuration.** Every value in a sentence below comes from
|
|
11
|
+
* the verified binding itself, so the text is identical for every reader.
|
|
12
|
+
*
|
|
13
|
+
* A third rule follows from issue #3322: **a sentence claims unchosen-ness only where the seal
|
|
14
|
+
* established it.** A beacon that merely postdates the seal makes the value unpredictable and
|
|
15
|
+
* leaves the operator choosing among the values that postdate it. So both sentences carry
|
|
16
|
+
* `roundChoiceClause`, which asserts the second property under `seal-derived` and retracts it under
|
|
17
|
+
* `operator-chosen` rather than letting either branch imply it.
|
|
18
|
+
*
|
|
19
|
+
* A fourth rule is this module's own, and is the whole point of the originating issue: **the census
|
|
20
|
+
* sentence says it is the weaker binding.** Ordering-only binding shows the run's order was fixed by
|
|
21
|
+
* randomness that postdates the seal; it does not show the population was, because with a census
|
|
22
|
+
* there was no population choice to make. Letting the two modes share one confident sentence would
|
|
23
|
+
* be the failure this feature exists to prevent. Issue #3425 subjects that sentence to the third
|
|
24
|
+
* rule as well: `censusOrderClause` asserts the postdating only under `proven-offline`, and concedes
|
|
25
|
+
* it under `attributive` in the register the sampled opening already uses, because the clauses on
|
|
26
|
+
* either side of it concede exactly that.
|
|
27
|
+
*/
|
|
28
|
+
import { type VerifiedRunBinding } from "./beacon-binding.js";
|
|
29
|
+
/**
|
|
30
|
+
* Which binding a run carries. `none` is the historical state every unbound run keeps -- the
|
|
31
|
+
* absence of a binding is a fact about the run, and is reported as one.
|
|
32
|
+
*/
|
|
33
|
+
export type RunBindingClass = "none" | "beacon-drawn-slate" | "beacon-ordering-only";
|
|
34
|
+
export declare function runBindingClass(binding: VerifiedRunBinding | undefined): RunBindingClass;
|
|
35
|
+
/**
|
|
36
|
+
* The one sentence that states which binding applied. `undefined` yields the unbound statement,
|
|
37
|
+
* which is a claim about the run too: nothing about it was drawn from post-seal randomness.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runBindingSentence(binding: VerifiedRunBinding | undefined): string;
|
|
40
|
+
/**
|
|
41
|
+
* The venue-limits list with the binding statement appended. Returns the list unchanged when the
|
|
42
|
+
* run carries no binding, so every run that predates this feature keeps its exact limits bytes --
|
|
43
|
+
* the same additive posture `anchoredVenueLimits` takes for an unanchored run.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runBoundVenueLimits(limits: readonly string[], binding: VerifiedRunBinding | undefined): readonly string[];
|