@calimero-network/mero-js 21.3.0 → 21.4.0
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/dist/admin-api/admin-types.d.ts +3 -24
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +529 -2
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +529 -2
- package/dist/index.mjs.map +4 -4
- package/dist/namespace-op/namespace-op.d.ts +1 -1
- package/dist/namespace-op/namespace-op.js +2 -2
- package/dist/sealed/index.d.ts +3 -0
- package/dist/sealed/index.js +1 -0
- package/dist/sealed/release.d.ts +213 -0
- package/dist/sealed/release.js +291 -0
- package/dist/sealed/sigstore-trust-root.d.ts +52 -0
- package/dist/sealed/sigstore-trust-root.js +76 -0
- package/dist/sealed/sigstore.d.ts +41 -0
- package/dist/sealed/sigstore.js +207 -0
- package/dist/sealed/x509.d.ts +67 -0
- package/dist/sealed/x509.js +240 -0
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
|
|
17
17
|
/** Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value. */
|
|
18
|
-
export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION =
|
|
18
|
+
export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 10;
|
|
19
19
|
/**
|
|
20
20
|
* Borsh-encode a `SignedGroupOpenInvitation`.
|
|
21
21
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { concat, derivePublicKey, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
|
|
2
2
|
/** Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value. */
|
|
3
|
-
export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION =
|
|
3
|
+
export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 10;
|
|
4
4
|
/** Domain prefixed to the signable bytes; core's `NAMESPACE_GOVERNANCE_SIGN_DOMAIN`. */
|
|
5
5
|
const NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode('calimero.namespace.v1');
|
|
6
6
|
/** `NamespaceOp::Root` is the first variant. */
|
|
@@ -20,7 +20,7 @@ const ROOT_OP = {
|
|
|
20
20
|
KeyDelivery: 6,
|
|
21
21
|
MemberJoinedOpen: 7,
|
|
22
22
|
MemberJoinedAt: 8,
|
|
23
|
-
|
|
23
|
+
NamespaceCreatedV2: 9,
|
|
24
24
|
MemberJoinedViaTeeAttestation: 10,
|
|
25
25
|
};
|
|
26
26
|
/** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
|
package/dist/sealed/index.d.ts
CHANGED
|
@@ -2,3 +2,6 @@ export { HANDSHAKE_PATH, SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError,
|
|
|
2
2
|
export type { AttestedSealedFetchOptions, SealedFetchOptions, VerifyTransportQuote } from './sealed.js';
|
|
3
3
|
export { createQuoteVerifier, trustedMeasurementsFromReleases } from './verify.js';
|
|
4
4
|
export type { DcapCollateral, DcapVerifiedReport, DcapVerify, PublishedMrtds, QuoteVerifier, QuoteVerifierOptions, TrustedMeasurements, } from './verify.js';
|
|
5
|
+
export { DEFAULT_RELEASE_MIRROR, NODE_RELEASE_SIGNER, cloudNodeReleaseUrl, createSignedReleaseSealedFetch, createSignedReleaseVerifier, fetchNodeRelease, fetchNodeReleaseVersion, nodeReleaseVersionFromImage, trustSignedRelease, verifySignedNodeRelease, } from './release.js';
|
|
6
|
+
export type { SignedNodeRelease, SignedReleaseSealedFetchOptions, SignedReleaseVerifierOptions, TrustSignedReleaseOptions, VerifySignedNodeReleaseOptions, } from './release.js';
|
|
7
|
+
export type { SignerIdentity } from './sigstore.js';
|
package/dist/sealed/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { HANDSHAKE_PATH, SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createAttestedSealedFetch, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
|
|
2
2
|
export { createQuoteVerifier, trustedMeasurementsFromReleases } from './verify.js';
|
|
3
|
+
export { DEFAULT_RELEASE_MIRROR, NODE_RELEASE_SIGNER, cloudNodeReleaseUrl, createSignedReleaseSealedFetch, createSignedReleaseVerifier, fetchNodeRelease, fetchNodeReleaseVersion, nodeReleaseVersionFromImage, trustSignedRelease, verifySignedNodeRelease, } from './release.js';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusting a TEE node's image by its signed release, fetched at run time from
|
|
3
|
+
* anywhere, instead of shipping the release's `published-mrtds.json` with the
|
|
4
|
+
* app.
|
|
5
|
+
*
|
|
6
|
+
* Each mero-tee node release publishes `published-mrtds.json` (the
|
|
7
|
+
* measurements of its image) with a cosign keyless bundle, signed by the
|
|
8
|
+
* mero-tee release workflow on GitHub Actions. {@link verifySignedNodeRelease}
|
|
9
|
+
* checks that signature here, against the Sigstore trust root embedded in this
|
|
10
|
+
* package, so the file can come from any source: the cloud's mirror
|
|
11
|
+
* (`GET https://cloud.calimero.network/api/tee/node-releases/{version}`), a
|
|
12
|
+
* mirror of your own, or a file bundled with the app. None of them is
|
|
13
|
+
* trusted; a file whose signature does not verify is refused.
|
|
14
|
+
*
|
|
15
|
+
* What is trusted:
|
|
16
|
+
*
|
|
17
|
+
* - the signer: the node release workflow of `calimero-network/mero-tee` on
|
|
18
|
+
* `master` ({@link NODE_RELEASE_SIGNER}), the identity core pins as
|
|
19
|
+
* `NODE_RELEASE_IDENTITY`;
|
|
20
|
+
* - the Sigstore public-good trust root: Fulcio's CAs and Rekor's key;
|
|
21
|
+
* - the minimum release you pass, below which no release is accepted.
|
|
22
|
+
*
|
|
23
|
+
* What is not: the transport, the server that serves the release, and the
|
|
24
|
+
* relay's choice of release. A relay can present any genuinely signed release
|
|
25
|
+
* at or above the minimum, and no other; its quote must then match that
|
|
26
|
+
* release's measurements exactly, so it cannot claim one release and run
|
|
27
|
+
* another.
|
|
28
|
+
*/
|
|
29
|
+
import { type SignerIdentity } from './sigstore.js';
|
|
30
|
+
import { type DcapVerify, type PublishedMrtds, type QuoteVerifier, type QuoteVerifierOptions } from './verify.js';
|
|
31
|
+
/** The workflow that signs every mero-tee node release, and the issuer of its identity. */
|
|
32
|
+
export declare const NODE_RELEASE_SIGNER: Readonly<SignerIdentity>;
|
|
33
|
+
/**
|
|
34
|
+
* The public mirror of signed node releases, used when no other source is
|
|
35
|
+
* given. Only a transport: what it serves is verified, so it can fail to
|
|
36
|
+
* answer but cannot make a release trusted.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_RELEASE_MIRROR = "https://cloud.calimero.network";
|
|
39
|
+
/**
|
|
40
|
+
* A node release as the servers hand it out, wrapped as `{"data": ...}`: the
|
|
41
|
+
* release's `published-mrtds.json`, byte for byte, and its cosign bundle.
|
|
42
|
+
* Unverified until {@link verifySignedNodeRelease} says otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export interface SignedNodeRelease {
|
|
45
|
+
/** The release, e.g. `2.3.87`. It must be the signed file's `tag`. */
|
|
46
|
+
version: string;
|
|
47
|
+
/** `published-mrtds.json`, exactly as signed. */
|
|
48
|
+
publishedMrtds: string;
|
|
49
|
+
/** `published-mrtds.json.bundle.json`, as JSON text. */
|
|
50
|
+
bundle: string;
|
|
51
|
+
}
|
|
52
|
+
export interface VerifySignedNodeReleaseOptions {
|
|
53
|
+
/** Require the file's `tag` to be this release. */
|
|
54
|
+
expectedVersion?: string;
|
|
55
|
+
/** The certificate identity required. Defaults to {@link NODE_RELEASE_SIGNER}'s. */
|
|
56
|
+
identity?: string;
|
|
57
|
+
/** The OIDC issuer required. Defaults to {@link NODE_RELEASE_SIGNER}'s. */
|
|
58
|
+
issuer?: string;
|
|
59
|
+
/** The release's detached `published-mrtds.json.sig`, when you have it; it must be the bundle's. */
|
|
60
|
+
signature?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Verify a node release's `published-mrtds.json` against its cosign bundle,
|
|
64
|
+
* as `cosign verify-blob` would, and return the file parsed. Rejects, with the
|
|
65
|
+
* reason, unless:
|
|
66
|
+
*
|
|
67
|
+
* - the signature over the exact bytes verifies with the bundle's certificate;
|
|
68
|
+
* - that certificate chains to a Fulcio CA in the Sigstore trust root, and it
|
|
69
|
+
* and the CA were valid when Rekor logged the signature;
|
|
70
|
+
* - it names the node release workflow and GitHub Actions as its issuer;
|
|
71
|
+
* - Rekor's signed entry timestamp verifies, for an entry that is this
|
|
72
|
+
* signature over this file by this certificate;
|
|
73
|
+
* - the file is a node release (`role: "node"`), and `expectedVersion`, when
|
|
74
|
+
* given, is its `tag`.
|
|
75
|
+
*
|
|
76
|
+
* Nothing is read from the clock: the certificate is checked at the time Rekor
|
|
77
|
+
* logged the signature, so a release that verifies once always does.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verifySignedNodeRelease(publishedMrtds: string | Uint8Array, bundle: string | object, options?: VerifySignedNodeReleaseOptions): Promise<PublishedMrtds>;
|
|
80
|
+
/**
|
|
81
|
+
* Fetch a {@link SignedNodeRelease} from a server that wraps it as
|
|
82
|
+
* `{"data": ...}`, such as the cloud's {@link cloudNodeReleaseUrl}. It is
|
|
83
|
+
* only fetched: pass it to
|
|
84
|
+
* {@link trustSignedRelease}, or {@link verifySignedNodeRelease}, before using it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function fetchNodeRelease(url: string, options?: {
|
|
87
|
+
fetch?: typeof fetch;
|
|
88
|
+
}): Promise<SignedNodeRelease>;
|
|
89
|
+
/**
|
|
90
|
+
* The release a mero-tee node image was built from, read from its name, e.g.
|
|
91
|
+
* `merotee-ubuntu-questing-25-10-locked-read-only-2-3-87` is `2.3.87`. Throws
|
|
92
|
+
* for any other name, including the suffixed images of unreleased builds.
|
|
93
|
+
*/
|
|
94
|
+
export declare function nodeReleaseVersionFromImage(osImage: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* The release the TEE node at `baseUrl` says it runs, from the image name its
|
|
97
|
+
* `GET /admin-api/tee/info` reports. The node's own claim, and not trusted:
|
|
98
|
+
* it only picks which signed release to fetch, and the quote must then match
|
|
99
|
+
* that release's measurements.
|
|
100
|
+
*/
|
|
101
|
+
export declare function fetchNodeReleaseVersion(baseUrl: string, options?: {
|
|
102
|
+
fetch?: typeof fetch;
|
|
103
|
+
}): Promise<string>;
|
|
104
|
+
/** Where the cloud at `cloudUrl` (e.g. `https://cloud.calimero.network`) mirrors node release `version`. */
|
|
105
|
+
export declare function cloudNodeReleaseUrl(cloudUrl: string, version: string): string;
|
|
106
|
+
export interface TrustSignedReleaseOptions {
|
|
107
|
+
/** The release, from any source; it is verified here. */
|
|
108
|
+
release: SignedNodeRelease;
|
|
109
|
+
/** The image profile to trust, e.g. `locked-read-only`. */
|
|
110
|
+
profile: string;
|
|
111
|
+
/**
|
|
112
|
+
* The oldest release accepted, e.g. `2.3.87`. Required: every release ever
|
|
113
|
+
* signed stays validly signed, so without a floor a relay could present an
|
|
114
|
+
* old release, with a flaw since fixed, and a quote from that old image
|
|
115
|
+
* would pass. Raise it when a release must no longer be trusted.
|
|
116
|
+
*/
|
|
117
|
+
minReleaseVersion: string;
|
|
118
|
+
/** Override the signer; defaults to {@link NODE_RELEASE_SIGNER}. */
|
|
119
|
+
identity?: string;
|
|
120
|
+
issuer?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The {@link QuoteVerifierOptions} that trust one profile of a signed node
|
|
124
|
+
* release, from an untrusted source: its signature is verified, its signed
|
|
125
|
+
* `tag` must be `release.version` and no older than `minReleaseVersion`, and
|
|
126
|
+
* then it is read as {@link trustedMeasurementsFromReleases} reads a bundled
|
|
127
|
+
* file.
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* const version = await fetchNodeReleaseVersion(relayUrl);
|
|
131
|
+
* const release = await fetchNodeRelease(cloudNodeReleaseUrl(DEFAULT_RELEASE_MIRROR, version));
|
|
132
|
+
* const verifier = createQuoteVerifier({
|
|
133
|
+
* dcapVerify,
|
|
134
|
+
* ...(await trustSignedRelease({ release, profile: 'locked-read-only', minReleaseVersion: '2.3.87' })),
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export declare function trustSignedRelease(options: TrustSignedReleaseOptions): Promise<Required<Pick<QuoteVerifierOptions, 'allowedMeasurements' | 'allowedTcbStatuses'>>>;
|
|
139
|
+
export interface SignedReleaseVerifierOptions extends Omit<TrustSignedReleaseOptions, 'release'> {
|
|
140
|
+
/** DCAP verification: `verify` from `@phala/dcap-qvl`. */
|
|
141
|
+
dcapVerify: DcapVerify;
|
|
142
|
+
/**
|
|
143
|
+
* Where to get the node's release, asked again each time the node is
|
|
144
|
+
* attested (a restarted node may run a newer one). Untrusted: it is
|
|
145
|
+
* verified, and the quote must match it.
|
|
146
|
+
*/
|
|
147
|
+
release: () => Promise<SignedNodeRelease>;
|
|
148
|
+
/** As {@link QuoteVerifierOptions.fetchCollateral}. */
|
|
149
|
+
fetchCollateral?: QuoteVerifierOptions['fetchCollateral'];
|
|
150
|
+
/** The current time in milliseconds, for the quote's collateral; defaults to `Date.now`. For tests. */
|
|
151
|
+
now?: () => number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A {@link createQuoteVerifier} whose trusted image is the signed release the
|
|
155
|
+
* node says it runs. On each attestation it gets the release, checks it with
|
|
156
|
+
* {@link trustSignedRelease}, and verifies the quote against that release's
|
|
157
|
+
* measurements only.
|
|
158
|
+
*
|
|
159
|
+
* The node's choice of release is the one thing taken from it, and it is
|
|
160
|
+
* bounded: only a genuinely signed release at or above `minReleaseVersion`
|
|
161
|
+
* passes, and the quote must then be of exactly that release's image. Use it
|
|
162
|
+
* as `connectCloud`'s `seal`, or with `createAttestedSealedFetch`;
|
|
163
|
+
* {@link createSignedReleaseSealedFetch} does the latter for you.
|
|
164
|
+
*/
|
|
165
|
+
export declare function createSignedReleaseVerifier(options: SignedReleaseVerifierOptions): QuoteVerifier;
|
|
166
|
+
export interface SignedReleaseSealedFetchOptions extends Omit<SignedReleaseVerifierOptions, 'release'> {
|
|
167
|
+
/** The TEE node's (the relay's) base URL. */
|
|
168
|
+
baseUrl: string;
|
|
169
|
+
/**
|
|
170
|
+
* Where to get the release the node says it runs, the version read from its
|
|
171
|
+
* `GET /admin-api/tee/info` each time it is attested. A string is the base
|
|
172
|
+
* URL of a mirror serving {@link cloudNodeReleaseUrl}'s path, defaulting to
|
|
173
|
+
* {@link DEFAULT_RELEASE_MIRROR}; a function gets the version and returns
|
|
174
|
+
* the release, e.g. from a file bundled with the app. Whichever it is, it is
|
|
175
|
+
* not trusted: the release is verified. `/tee/info` is read in the clear,
|
|
176
|
+
* which a node with `[server.sealed] required` refuses: for one, pass a
|
|
177
|
+
* function that ignores `version` and returns the release you expect.
|
|
178
|
+
*/
|
|
179
|
+
releaseSource?: string | ((context: {
|
|
180
|
+
baseUrl: string;
|
|
181
|
+
version: string;
|
|
182
|
+
fetch: typeof fetch;
|
|
183
|
+
}) => Promise<SignedNodeRelease>);
|
|
184
|
+
/** Also require the node to run this application with this bytecode hash. */
|
|
185
|
+
applicationId?: string;
|
|
186
|
+
applicationHash?: string;
|
|
187
|
+
/** Fetch for the release, the attestation and the envelopes. Defaults to global `fetch`. */
|
|
188
|
+
fetch?: typeof fetch;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* {@link createAttestedSealedFetch} for a TEE node known only by URL, trusting
|
|
192
|
+
* whatever signed release at or above `minReleaseVersion` it runs: it reads
|
|
193
|
+
* the release the node names from its `/tee/info`, gets that release from
|
|
194
|
+
* `releaseSource` (the public mirror unless you say otherwise), verifies its
|
|
195
|
+
* signature, builds the quote verifier from its measurements, attests the node
|
|
196
|
+
* and seals everything to it.
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* import { verify as dcapVerify } from '@phala/dcap-qvl';
|
|
200
|
+
*
|
|
201
|
+
* const relay = new RelayClient({
|
|
202
|
+
* relayUrl,
|
|
203
|
+
* fetch: createSignedReleaseSealedFetch({
|
|
204
|
+
* baseUrl: relayUrl,
|
|
205
|
+
* dcapVerify,
|
|
206
|
+
* profile: 'locked-read-only',
|
|
207
|
+
* minReleaseVersion: '2.3.87',
|
|
208
|
+
* }),
|
|
209
|
+
* ...
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export declare function createSignedReleaseSealedFetch(options: SignedReleaseSealedFetchOptions): typeof fetch;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusting a TEE node's image by its signed release, fetched at run time from
|
|
3
|
+
* anywhere, instead of shipping the release's `published-mrtds.json` with the
|
|
4
|
+
* app.
|
|
5
|
+
*
|
|
6
|
+
* Each mero-tee node release publishes `published-mrtds.json` (the
|
|
7
|
+
* measurements of its image) with a cosign keyless bundle, signed by the
|
|
8
|
+
* mero-tee release workflow on GitHub Actions. {@link verifySignedNodeRelease}
|
|
9
|
+
* checks that signature here, against the Sigstore trust root embedded in this
|
|
10
|
+
* package, so the file can come from any source: the cloud's mirror
|
|
11
|
+
* (`GET https://cloud.calimero.network/api/tee/node-releases/{version}`), a
|
|
12
|
+
* mirror of your own, or a file bundled with the app. None of them is
|
|
13
|
+
* trusted; a file whose signature does not verify is refused.
|
|
14
|
+
*
|
|
15
|
+
* What is trusted:
|
|
16
|
+
*
|
|
17
|
+
* - the signer: the node release workflow of `calimero-network/mero-tee` on
|
|
18
|
+
* `master` ({@link NODE_RELEASE_SIGNER}), the identity core pins as
|
|
19
|
+
* `NODE_RELEASE_IDENTITY`;
|
|
20
|
+
* - the Sigstore public-good trust root: Fulcio's CAs and Rekor's key;
|
|
21
|
+
* - the minimum release you pass, below which no release is accepted.
|
|
22
|
+
*
|
|
23
|
+
* What is not: the transport, the server that serves the release, and the
|
|
24
|
+
* relay's choice of release. A relay can present any genuinely signed release
|
|
25
|
+
* at or above the minimum, and no other; its quote must then match that
|
|
26
|
+
* release's measurements exactly, so it cannot claim one release and run
|
|
27
|
+
* another.
|
|
28
|
+
*/
|
|
29
|
+
import { createAttestedSealedFetch } from './sealed.js';
|
|
30
|
+
import { verifyCosignBundle } from './sigstore.js';
|
|
31
|
+
import { createQuoteVerifier, trustedMeasurementsFromReleases, } from './verify.js';
|
|
32
|
+
/** The workflow that signs every mero-tee node release, and the issuer of its identity. */
|
|
33
|
+
export const NODE_RELEASE_SIGNER = Object.freeze({
|
|
34
|
+
identity: 'https://github.com/calimero-network/mero-tee/.github/workflows/release-node-image-gcp.yaml@refs/heads/master',
|
|
35
|
+
issuer: 'https://token.actions.githubusercontent.com',
|
|
36
|
+
});
|
|
37
|
+
const MEASUREMENTS = ['mrtd', 'rtmr0', 'rtmr1', 'rtmr2', 'rtmr3'];
|
|
38
|
+
/**
|
|
39
|
+
* The public mirror of signed node releases, used when no other source is
|
|
40
|
+
* given. Only a transport: what it serves is verified, so it can fail to
|
|
41
|
+
* answer but cannot make a release trusted.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_RELEASE_MIRROR = 'https://cloud.calimero.network';
|
|
44
|
+
/**
|
|
45
|
+
* Verify a node release's `published-mrtds.json` against its cosign bundle,
|
|
46
|
+
* as `cosign verify-blob` would, and return the file parsed. Rejects, with the
|
|
47
|
+
* reason, unless:
|
|
48
|
+
*
|
|
49
|
+
* - the signature over the exact bytes verifies with the bundle's certificate;
|
|
50
|
+
* - that certificate chains to a Fulcio CA in the Sigstore trust root, and it
|
|
51
|
+
* and the CA were valid when Rekor logged the signature;
|
|
52
|
+
* - it names the node release workflow and GitHub Actions as its issuer;
|
|
53
|
+
* - Rekor's signed entry timestamp verifies, for an entry that is this
|
|
54
|
+
* signature over this file by this certificate;
|
|
55
|
+
* - the file is a node release (`role: "node"`), and `expectedVersion`, when
|
|
56
|
+
* given, is its `tag`.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is read from the clock: the certificate is checked at the time Rekor
|
|
59
|
+
* logged the signature, so a release that verifies once always does.
|
|
60
|
+
*/
|
|
61
|
+
export async function verifySignedNodeRelease(publishedMrtds, bundle, options = {}) {
|
|
62
|
+
const body = typeof publishedMrtds === 'string' ? new TextEncoder().encode(publishedMrtds) : publishedMrtds;
|
|
63
|
+
await verifyCosignBundle(body, bundle, {
|
|
64
|
+
identity: options.identity ?? NODE_RELEASE_SIGNER.identity,
|
|
65
|
+
issuer: options.issuer ?? NODE_RELEASE_SIGNER.issuer,
|
|
66
|
+
}, options.signature);
|
|
67
|
+
let release;
|
|
68
|
+
try {
|
|
69
|
+
release = JSON.parse(new TextDecoder().decode(body));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error('The signed release is not JSON');
|
|
73
|
+
}
|
|
74
|
+
if (release?.role !== 'node') {
|
|
75
|
+
throw new Error(`The signed release is a ${JSON.stringify(release?.role)} release, not a node one`);
|
|
76
|
+
}
|
|
77
|
+
// The workflow signs other files too, some with a `role` and `profiles` of
|
|
78
|
+
// their own (`release-provenance.json`): the signature covers bytes, not a
|
|
79
|
+
// file name, so check this is the measurements file.
|
|
80
|
+
const profiles = Object.values(release.profiles ?? {});
|
|
81
|
+
const measured = (image) => MEASUREMENTS.every((register) => /^[0-9a-f]{96}$/i.test(String(image?.[register])));
|
|
82
|
+
if (typeof release.tag !== 'string' || profiles.length === 0 || !profiles.every(measured)) {
|
|
83
|
+
throw new Error("The signed file is not a release's published-mrtds.json: it lists no measured profiles");
|
|
84
|
+
}
|
|
85
|
+
if (options.expectedVersion !== undefined && release.tag !== options.expectedVersion) {
|
|
86
|
+
throw new Error(`The signed release is ${release.tag}, not ${options.expectedVersion}`);
|
|
87
|
+
}
|
|
88
|
+
return release;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fetch a {@link SignedNodeRelease} from a server that wraps it as
|
|
92
|
+
* `{"data": ...}`, such as the cloud's {@link cloudNodeReleaseUrl}. It is
|
|
93
|
+
* only fetched: pass it to
|
|
94
|
+
* {@link trustSignedRelease}, or {@link verifySignedNodeRelease}, before using it.
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchNodeRelease(url, options = {}) {
|
|
97
|
+
const response = await (options.fetch ?? fetch)(url, { headers: { accept: 'application/json' } });
|
|
98
|
+
if (!response.ok)
|
|
99
|
+
throw new Error(`Fetching the node release from ${url} failed: HTTP ${response.status}`);
|
|
100
|
+
let release;
|
|
101
|
+
try {
|
|
102
|
+
release = (await response.json())?.data;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error(`${url} did not answer with JSON`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof release?.version !== 'string' ||
|
|
108
|
+
typeof release.publishedMrtds !== 'string' ||
|
|
109
|
+
typeof release.bundle !== 'string') {
|
|
110
|
+
throw new Error(`${url} did not answer with a signed node release`);
|
|
111
|
+
}
|
|
112
|
+
return { version: release.version, publishedMrtds: release.publishedMrtds, bundle: release.bundle };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The release a mero-tee node image was built from, read from its name, e.g.
|
|
116
|
+
* `merotee-ubuntu-questing-25-10-locked-read-only-2-3-87` is `2.3.87`. Throws
|
|
117
|
+
* for any other name, including the suffixed images of unreleased builds.
|
|
118
|
+
*/
|
|
119
|
+
export function nodeReleaseVersionFromImage(osImage) {
|
|
120
|
+
const match = /^merotee-.+-(\d+)-(\d+)-(\d+)$/.exec(osImage);
|
|
121
|
+
if (!match)
|
|
122
|
+
throw new Error(`${JSON.stringify(osImage)} is not the image of a mero-tee node release`);
|
|
123
|
+
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The release the TEE node at `baseUrl` says it runs, from the image name its
|
|
127
|
+
* `GET /admin-api/tee/info` reports. The node's own claim, and not trusted:
|
|
128
|
+
* it only picks which signed release to fetch, and the quote must then match
|
|
129
|
+
* that release's measurements.
|
|
130
|
+
*/
|
|
131
|
+
export async function fetchNodeReleaseVersion(baseUrl, options = {}) {
|
|
132
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/admin-api/tee/info`;
|
|
133
|
+
const response = await (options.fetch ?? fetch)(url, { headers: { accept: 'application/json' } });
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
throw new Error(`Fetching ${url} failed: HTTP ${response.status}`);
|
|
136
|
+
let osImage;
|
|
137
|
+
try {
|
|
138
|
+
osImage = (await response.json())?.data?.osImage;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error(`${url} did not answer with JSON`);
|
|
142
|
+
}
|
|
143
|
+
if (typeof osImage !== 'string')
|
|
144
|
+
throw new Error(`${url} did not name the node's image`);
|
|
145
|
+
return nodeReleaseVersionFromImage(osImage);
|
|
146
|
+
}
|
|
147
|
+
/** Where the cloud at `cloudUrl` (e.g. `https://cloud.calimero.network`) mirrors node release `version`. */
|
|
148
|
+
export function cloudNodeReleaseUrl(cloudUrl, version) {
|
|
149
|
+
return `${cloudUrl.replace(/\/+$/, '')}/api/tee/node-releases/${releaseVersion(version, 'version')}`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The {@link QuoteVerifierOptions} that trust one profile of a signed node
|
|
153
|
+
* release, from an untrusted source: its signature is verified, its signed
|
|
154
|
+
* `tag` must be `release.version` and no older than `minReleaseVersion`, and
|
|
155
|
+
* then it is read as {@link trustedMeasurementsFromReleases} reads a bundled
|
|
156
|
+
* file.
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const version = await fetchNodeReleaseVersion(relayUrl);
|
|
160
|
+
* const release = await fetchNodeRelease(cloudNodeReleaseUrl(DEFAULT_RELEASE_MIRROR, version));
|
|
161
|
+
* const verifier = createQuoteVerifier({
|
|
162
|
+
* dcapVerify,
|
|
163
|
+
* ...(await trustSignedRelease({ release, profile: 'locked-read-only', minReleaseVersion: '2.3.87' })),
|
|
164
|
+
* });
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export async function trustSignedRelease(options) {
|
|
168
|
+
const minimum = releaseVersion(options.minReleaseVersion, 'minReleaseVersion');
|
|
169
|
+
const version = releaseVersion(options.release?.version, 'The release version');
|
|
170
|
+
const published = await verifySignedNodeRelease(options.release.publishedMrtds, options.release.bundle, {
|
|
171
|
+
expectedVersion: version,
|
|
172
|
+
identity: options.identity,
|
|
173
|
+
issuer: options.issuer,
|
|
174
|
+
});
|
|
175
|
+
if (compareReleaseVersions(version, minimum) < 0) {
|
|
176
|
+
throw new Error(`Release ${version} is older than the minimum trusted, ${minimum}`);
|
|
177
|
+
}
|
|
178
|
+
return trustedMeasurementsFromReleases([published], { profile: options.profile });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A {@link createQuoteVerifier} whose trusted image is the signed release the
|
|
182
|
+
* node says it runs. On each attestation it gets the release, checks it with
|
|
183
|
+
* {@link trustSignedRelease}, and verifies the quote against that release's
|
|
184
|
+
* measurements only.
|
|
185
|
+
*
|
|
186
|
+
* The node's choice of release is the one thing taken from it, and it is
|
|
187
|
+
* bounded: only a genuinely signed release at or above `minReleaseVersion`
|
|
188
|
+
* passes, and the quote must then be of exactly that release's image. Use it
|
|
189
|
+
* as `connectCloud`'s `seal`, or with `createAttestedSealedFetch`;
|
|
190
|
+
* {@link createSignedReleaseSealedFetch} does the latter for you.
|
|
191
|
+
*/
|
|
192
|
+
export function createSignedReleaseVerifier(options) {
|
|
193
|
+
releaseVersion(options.minReleaseVersion, 'minReleaseVersion');
|
|
194
|
+
const verifier = async (args) => {
|
|
195
|
+
const trusted = await trustSignedRelease({
|
|
196
|
+
release: await options.release(),
|
|
197
|
+
profile: options.profile,
|
|
198
|
+
minReleaseVersion: options.minReleaseVersion,
|
|
199
|
+
identity: options.identity,
|
|
200
|
+
issuer: options.issuer,
|
|
201
|
+
});
|
|
202
|
+
return createQuoteVerifier({
|
|
203
|
+
dcapVerify: options.dcapVerify,
|
|
204
|
+
fetchCollateral: options.fetchCollateral,
|
|
205
|
+
now: options.now,
|
|
206
|
+
...trusted,
|
|
207
|
+
})(args);
|
|
208
|
+
};
|
|
209
|
+
return Object.assign(verifier, { includeCollateral: true });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* {@link createAttestedSealedFetch} for a TEE node known only by URL, trusting
|
|
213
|
+
* whatever signed release at or above `minReleaseVersion` it runs: it reads
|
|
214
|
+
* the release the node names from its `/tee/info`, gets that release from
|
|
215
|
+
* `releaseSource` (the public mirror unless you say otherwise), verifies its
|
|
216
|
+
* signature, builds the quote verifier from its measurements, attests the node
|
|
217
|
+
* and seals everything to it.
|
|
218
|
+
*
|
|
219
|
+
* ```ts
|
|
220
|
+
* import { verify as dcapVerify } from '@phala/dcap-qvl';
|
|
221
|
+
*
|
|
222
|
+
* const relay = new RelayClient({
|
|
223
|
+
* relayUrl,
|
|
224
|
+
* fetch: createSignedReleaseSealedFetch({
|
|
225
|
+
* baseUrl: relayUrl,
|
|
226
|
+
* dcapVerify,
|
|
227
|
+
* profile: 'locked-read-only',
|
|
228
|
+
* minReleaseVersion: '2.3.87',
|
|
229
|
+
* }),
|
|
230
|
+
* ...
|
|
231
|
+
* });
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
export function createSignedReleaseSealedFetch(options) {
|
|
235
|
+
const { baseUrl, releaseSource, applicationId, applicationHash, fetch: givenFetch, ...verifierOptions } = options;
|
|
236
|
+
const baseFetch = givenFetch ?? ((input, init) => fetch(input, init));
|
|
237
|
+
const release = async () => {
|
|
238
|
+
const version = await fetchNodeReleaseVersion(baseUrl, { fetch: baseFetch });
|
|
239
|
+
return typeof releaseSource === 'function'
|
|
240
|
+
? releaseSource({ baseUrl, version, fetch: baseFetch })
|
|
241
|
+
: fetchNodeRelease(cloudNodeReleaseUrl(releaseSource ?? DEFAULT_RELEASE_MIRROR, version), { fetch: baseFetch });
|
|
242
|
+
};
|
|
243
|
+
return createAttestedSealedFetch({
|
|
244
|
+
baseUrl,
|
|
245
|
+
verify: createSignedReleaseVerifier({ ...verifierOptions, release }),
|
|
246
|
+
applicationId,
|
|
247
|
+
applicationHash,
|
|
248
|
+
fetch: baseFetch,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
252
|
+
/** `value` if it is a release version (`2.3.87`, `2.3.88-rc.1`); throws otherwise. */
|
|
253
|
+
function releaseVersion(value, what) {
|
|
254
|
+
const match = typeof value === 'string' ? RELEASE_VERSION.exec(value) : null;
|
|
255
|
+
const pre = match?.[4];
|
|
256
|
+
if (!match || (pre !== undefined && pre.split('.').some((id) => id === ''))) {
|
|
257
|
+
throw new Error(`${what} is ${JSON.stringify(value)}, not a release version like 2.3.87`);
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Order two release versions as semver does, and as core's
|
|
263
|
+
* `compare_release_versions`: numerically by `X.Y.Z`, a pre-release before its
|
|
264
|
+
* release, build metadata ignored.
|
|
265
|
+
*/
|
|
266
|
+
function compareReleaseVersions(a, b) {
|
|
267
|
+
const [aCore, aPre] = splitVersion(a);
|
|
268
|
+
const [bCore, bPre] = splitVersion(b);
|
|
269
|
+
for (let i = 0; i < 3; i += 1) {
|
|
270
|
+
if (aCore[i] !== bCore[i])
|
|
271
|
+
return aCore[i] < bCore[i] ? -1 : 1;
|
|
272
|
+
}
|
|
273
|
+
if (aPre === undefined || bPre === undefined)
|
|
274
|
+
return aPre === bPre ? 0 : aPre === undefined ? 1 : -1;
|
|
275
|
+
const aIds = aPre.split('.');
|
|
276
|
+
const bIds = bPre.split('.');
|
|
277
|
+
for (let i = 0; i < Math.max(aIds.length, bIds.length); i += 1) {
|
|
278
|
+
const [x, y] = [aIds[i], bIds[i]];
|
|
279
|
+
if (x === undefined || y === undefined)
|
|
280
|
+
return x === undefined ? -1 : 1;
|
|
281
|
+
const [xNum, yNum] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
282
|
+
const order = xNum && yNum ? Math.sign(Number(x) - Number(y)) : xNum !== yNum ? (xNum ? -1 : 1) : x < y ? -1 : x > y ? 1 : 0;
|
|
283
|
+
if (order !== 0)
|
|
284
|
+
return order;
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
function splitVersion(version) {
|
|
289
|
+
const match = RELEASE_VERSION.exec(version);
|
|
290
|
+
return [[Number(match[1]), Number(match[2]), Number(match[3])], match[4]];
|
|
291
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the Sigstore public-good trust root that verifying a mero-tee
|
|
3
|
+
* release's cosign bundle needs: the Fulcio certificate authorities that issue
|
|
4
|
+
* the release workflow's short-lived signing certificates, and the Rekor log
|
|
5
|
+
* key that signs the bundle's signed entry timestamp. Each with the window in
|
|
6
|
+
* which the trust root says it was in use.
|
|
7
|
+
*
|
|
8
|
+
* Copied from the `trusted_root.json` that Sigstore publishes through its TUF
|
|
9
|
+
* repository (https://tuf-repo-cdn.sigstore.dev, target `trusted_root.json`),
|
|
10
|
+
* as shipped by the `sigstore` Python client 4.5.0 in
|
|
11
|
+
* `sigstore/_store/https%3A%2F%2Ftuf-repo-cdn.sigstore.dev/trusted_root.json`.
|
|
12
|
+
* Embedded rather than fetched: fetching it at run time would trust whoever
|
|
13
|
+
* serves it, the very thing this check exists to avoid.
|
|
14
|
+
*
|
|
15
|
+
* Left out, because a legacy cosign bundle never uses them: the CT logs (the
|
|
16
|
+
* certificate's SCT is not checked here, as core does not either), the
|
|
17
|
+
* timestamp authority, and the Rekor v2 log `log2025-1.rekor.sigstore.dev`,
|
|
18
|
+
* whose Ed25519 key signs checkpoints rather than signed entry timestamps. A
|
|
19
|
+
* bundle that names any log but those below is refused.
|
|
20
|
+
*
|
|
21
|
+
* To refresh it when Sigstore rotates a key or adds a CA: take the current
|
|
22
|
+
* `trusted_root.json` from a TUF client (`python -m sigstore` or `cosign
|
|
23
|
+
* initialize` both keep one, verified against the TUF root), and copy each
|
|
24
|
+
* `certificateAuthorities[].certChain.certificates[].rawBytes` with its
|
|
25
|
+
* `validFor`, and each ECDSA `tlogs[]` entry's `publicKey.rawBytes`,
|
|
26
|
+
* `publicKey.validFor` and `logId.keyId` (as hex). The tests check each log id
|
|
27
|
+
* is the SHA-256 of its key and each chain verifies.
|
|
28
|
+
*/
|
|
29
|
+
/** A Fulcio CA: its chain from the certificate that issues leaves up to the root, DER as base64. */
|
|
30
|
+
export interface FulcioAuthority {
|
|
31
|
+
uri: string;
|
|
32
|
+
/** When the trust root says this CA issued certificates. No `end`: it still does. */
|
|
33
|
+
validFor: {
|
|
34
|
+
start: string;
|
|
35
|
+
end?: string;
|
|
36
|
+
};
|
|
37
|
+
certChain: string[];
|
|
38
|
+
}
|
|
39
|
+
/** A Rekor log whose signed entry timestamps are accepted. */
|
|
40
|
+
export interface RekorLog {
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
/** The log id a bundle names it by: the SHA-256 of its public key, hex. */
|
|
43
|
+
logId: string;
|
|
44
|
+
/** Its public key: DER SubjectPublicKeyInfo, base64. ECDSA P-256 with SHA-256. */
|
|
45
|
+
publicKey: string;
|
|
46
|
+
validFor: {
|
|
47
|
+
start: string;
|
|
48
|
+
end?: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export declare const FULCIO_AUTHORITIES: readonly FulcioAuthority[];
|
|
52
|
+
export declare const REKOR_LOGS: readonly RekorLog[];
|