terminalhire 0.42.20 → 0.42.21
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/bin/claim-push-bg.js +1 -1
- package/dist/bin/jpi-chat-read.js +1 -1
- package/dist/bin/jpi-chat.js +1 -1
- package/dist/bin/jpi-claim.js +854 -123
- package/dist/bin/jpi-contribute.js +6 -1
- package/dist/bin/jpi-dispatch.js +891 -160
- package/dist/bin/jpi-hub.js +1 -1
- package/dist/bin/jpi-inbox.js +1 -1
- package/dist/bin/jpi-login.js +10 -5
- package/dist/bin/jpi-mcp-chat.js +1 -1
- package/dist/bin/jpi-mcp.js +863 -132
- package/dist/bin/jpi-refresh.js +1 -1
- package/dist/bin/jpi-run.js +826 -100
- package/dist/src/chat-client.js +1 -1
- package/dist/src/chat-keystore.js +1 -1
- package/dist/src/github-auth.js +6 -1
- package/package.json +1 -1
package/dist/bin/jpi-run.js
CHANGED
|
@@ -551,12 +551,33 @@ function scrubEndpointEnv(env) {
|
|
|
551
551
|
}
|
|
552
552
|
return out;
|
|
553
553
|
}
|
|
554
|
-
function
|
|
554
|
+
function containerFlagsAt(args) {
|
|
555
|
+
if (args[0] === "run" || args[0] === "create")
|
|
556
|
+
return 1;
|
|
557
|
+
if (args[0] === "container" && (args[1] === "run" || args[1] === "create"))
|
|
558
|
+
return 2;
|
|
559
|
+
return -1;
|
|
560
|
+
}
|
|
561
|
+
function remoteDockerClient(endpoint, opts = {}) {
|
|
555
562
|
if (endpoint.trim().length === 0) {
|
|
556
563
|
throw new Error("remoteDockerClient: endpoint must be a non-empty docker host");
|
|
557
564
|
}
|
|
558
565
|
const target = endpoint.trim();
|
|
559
|
-
const
|
|
566
|
+
const tlsFlags = [];
|
|
567
|
+
if (opts.tls) {
|
|
568
|
+
for (const key of ["caPath", "certPath", "keyPath"]) {
|
|
569
|
+
if (opts.tls[key].trim().length === 0) {
|
|
570
|
+
throw new Error(`remoteDockerClient: tls.${key} must name a file`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
tlsFlags.push("--tlsverify", "--tlscacert", opts.tls.caPath, "--tlscert", opts.tls.certPath, "--tlskey", opts.tls.keyPath);
|
|
574
|
+
}
|
|
575
|
+
const runArgs = opts.runArgs ?? [];
|
|
576
|
+
const argv = (args) => {
|
|
577
|
+
const at = runArgs.length > 0 ? containerFlagsAt(args) : -1;
|
|
578
|
+
const sub = at < 0 ? [...args] : [...args.slice(0, at), ...runArgs, ...args.slice(at)];
|
|
579
|
+
return ["--host", target, ...tlsFlags, ...sub];
|
|
580
|
+
};
|
|
560
581
|
return {
|
|
561
582
|
endpoint: target,
|
|
562
583
|
// Always empty, and it is a fact about this client rather than about the
|
|
@@ -573,10 +594,10 @@ function remoteDockerClient(endpoint) {
|
|
|
573
594
|
env: scrubEndpointEnv(process.env)
|
|
574
595
|
});
|
|
575
596
|
},
|
|
576
|
-
sync(args,
|
|
597
|
+
sync(args, opts2) {
|
|
577
598
|
return syncResult(spawnSync3(DOCKER_BIN, argv(args), {
|
|
578
599
|
encoding: "utf8",
|
|
579
|
-
timeout:
|
|
600
|
+
timeout: opts2?.timeoutMs,
|
|
580
601
|
env: scrubEndpointEnv(process.env)
|
|
581
602
|
}));
|
|
582
603
|
},
|
|
@@ -2559,13 +2580,192 @@ var init_gceIdentity = __esm({
|
|
|
2559
2580
|
|
|
2560
2581
|
// ../../packages/attest/dist/confidentialSpace.js
|
|
2561
2582
|
import { createHash as createHash2, createPublicKey as createPublicKey3, verify as cryptoVerify3 } from "crypto";
|
|
2562
|
-
|
|
2583
|
+
function refuse2(reason, detail) {
|
|
2584
|
+
return { ok: false, reason, detail };
|
|
2585
|
+
}
|
|
2586
|
+
function isPlainObject(v) {
|
|
2587
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2588
|
+
}
|
|
2589
|
+
function assertExpectationsComplete2(expectations) {
|
|
2590
|
+
const required = [
|
|
2591
|
+
["audience", expectations.audience],
|
|
2592
|
+
["nonce", expectations.nonce],
|
|
2593
|
+
["instanceName", expectations.instanceName],
|
|
2594
|
+
["projectId", expectations.projectId],
|
|
2595
|
+
...(expectations.additionalNonces ?? []).map((n) => [
|
|
2596
|
+
"additionalNonces[]",
|
|
2597
|
+
n
|
|
2598
|
+
]),
|
|
2599
|
+
...Object.entries(expectations.containerEnv ?? {}).flatMap(([name, value]) => [
|
|
2600
|
+
["containerEnv name", name],
|
|
2601
|
+
[`containerEnv.${name}`, value]
|
|
2602
|
+
])
|
|
2603
|
+
];
|
|
2604
|
+
for (const [name, value] of required) {
|
|
2605
|
+
if (value === "") {
|
|
2606
|
+
throw new TypeError(`verifyConfidentialSpaceToken: expectations.${name} is empty`);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
function nonceList(v) {
|
|
2611
|
+
if (typeof v === "string")
|
|
2612
|
+
return [v];
|
|
2613
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
2614
|
+
return null;
|
|
2615
|
+
if (!v.every((n) => typeof n === "string"))
|
|
2616
|
+
return null;
|
|
2617
|
+
return v;
|
|
2618
|
+
}
|
|
2619
|
+
async function verifyConfidentialSpaceToken(token, expectations, deps) {
|
|
2620
|
+
assertExpectationsComplete2(expectations);
|
|
2621
|
+
if (!Number.isFinite(deps.now)) {
|
|
2622
|
+
throw new TypeError("verifyConfidentialSpaceToken: deps.now is not a finite number");
|
|
2623
|
+
}
|
|
2624
|
+
if (expectations.allowedImageDigests.length === 0) {
|
|
2625
|
+
return refuse2("image-digest-allowlist-unset", "no workload image digest is allowlisted, so no attestation can be accepted");
|
|
2626
|
+
}
|
|
2627
|
+
if (token.length > MAX_TOKEN_LENGTH) {
|
|
2628
|
+
return refuse2("token-malformed", "longer than any attestation token");
|
|
2629
|
+
}
|
|
2630
|
+
const parts = token.split(".");
|
|
2631
|
+
if (parts.length !== 3) {
|
|
2632
|
+
return refuse2("token-malformed", "not a three-segment compact JWT");
|
|
2633
|
+
}
|
|
2634
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
2635
|
+
const header = decodeJsonSegment(headerB64);
|
|
2636
|
+
if (header === null) {
|
|
2637
|
+
return refuse2("token-malformed", "header segment is not base64url-encoded JSON");
|
|
2638
|
+
}
|
|
2639
|
+
if (header.alg !== "RS256") {
|
|
2640
|
+
return refuse2("algorithm-not-rs256", "token names an unsupported algorithm");
|
|
2641
|
+
}
|
|
2642
|
+
if (typeof header.kid !== "string" || header.kid === "") {
|
|
2643
|
+
return refuse2("unknown-key", "token header names no key id");
|
|
2644
|
+
}
|
|
2645
|
+
const jwks = await deps.fetchJwks();
|
|
2646
|
+
const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === "RSA");
|
|
2647
|
+
if (jwk === void 0) {
|
|
2648
|
+
return refuse2("unknown-key", "token key id is not in the published key set");
|
|
2649
|
+
}
|
|
2650
|
+
const signature = strictBase64UrlDecode(signatureB64);
|
|
2651
|
+
if (signature === null) {
|
|
2652
|
+
return refuse2("token-malformed", "signature segment is not base64url");
|
|
2653
|
+
}
|
|
2654
|
+
let signatureValid = false;
|
|
2655
|
+
try {
|
|
2656
|
+
const publicKey = createPublicKey3({ key: jwk, format: "jwk" });
|
|
2657
|
+
signatureValid = cryptoVerify3("sha256", Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), publicKey, signature);
|
|
2658
|
+
} catch {
|
|
2659
|
+
signatureValid = false;
|
|
2660
|
+
}
|
|
2661
|
+
if (!signatureValid) {
|
|
2662
|
+
return refuse2("signature-invalid", "signature does not verify under the named key");
|
|
2663
|
+
}
|
|
2664
|
+
const payload = decodeJsonSegment(payloadB64);
|
|
2665
|
+
if (payload === null) {
|
|
2666
|
+
return refuse2("token-malformed", "payload segment is not base64url-encoded JSON");
|
|
2667
|
+
}
|
|
2668
|
+
if (payload.iss !== CONFIDENTIAL_SPACE_ISSUER) {
|
|
2669
|
+
return refuse2("wrong-issuer", "issuer is not the Confidential Computing attestation service");
|
|
2670
|
+
}
|
|
2671
|
+
if (payload.aud !== expectations.audience) {
|
|
2672
|
+
return refuse2("wrong-audience", "audience is not the one this run expects");
|
|
2673
|
+
}
|
|
2674
|
+
const nowSeconds = deps.now / 1e3;
|
|
2675
|
+
const exp = payload.exp;
|
|
2676
|
+
if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= nowSeconds) {
|
|
2677
|
+
return refuse2("expired", "exp is missing, malformed, or in the past");
|
|
2678
|
+
}
|
|
2679
|
+
const iat = payload.iat;
|
|
2680
|
+
if (typeof iat !== "number" || !Number.isFinite(iat) || iat > nowSeconds + IAT_FUTURE_SKEW_MS / 1e3) {
|
|
2681
|
+
return refuse2("iat-in-future", "iat is missing, malformed, or too far in the future");
|
|
2682
|
+
}
|
|
2683
|
+
const nonces = nonceList(payload.eat_nonce);
|
|
2684
|
+
if (nonces === null) {
|
|
2685
|
+
return refuse2("nonce-missing", "eat_nonce is absent or not a string or array of strings");
|
|
2686
|
+
}
|
|
2687
|
+
if (!nonces.includes(expectations.nonce)) {
|
|
2688
|
+
return refuse2("nonce-mismatch", "eat_nonce does not carry the nonce this run was dispatched with");
|
|
2689
|
+
}
|
|
2690
|
+
for (const extra of expectations.additionalNonces ?? []) {
|
|
2691
|
+
if (!nonces.includes(extra)) {
|
|
2692
|
+
return refuse2("nonce-mismatch", "eat_nonce does not carry every nonce this caller requires");
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
if (payload.swname !== CONFIDENTIAL_SPACE_SWNAME) {
|
|
2696
|
+
return refuse2("swname-not-confidential-space", "the VM is not running a Confidential Space image");
|
|
2697
|
+
}
|
|
2698
|
+
if (payload.dbgstat !== CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION) {
|
|
2699
|
+
return refuse2("debug-enabled", "the image is not a production image with debug disabled");
|
|
2700
|
+
}
|
|
2701
|
+
if (payload.secboot !== true) {
|
|
2702
|
+
return refuse2("secure-boot-off", "Secure Boot is not reported as enabled");
|
|
2703
|
+
}
|
|
2704
|
+
if (typeof payload.hwmodel !== "string" || !payload.hwmodel.startsWith("GCP_")) {
|
|
2705
|
+
return refuse2("hwmodel-not-gcp", "hardware model is not a Google Cloud confidential model");
|
|
2706
|
+
}
|
|
2707
|
+
const hwmodel = payload.hwmodel;
|
|
2708
|
+
const submods = payload.submods;
|
|
2709
|
+
const gce = isPlainObject(submods) ? submods.gce : void 0;
|
|
2710
|
+
if (!isPlainObject(gce)) {
|
|
2711
|
+
return refuse2("submods-gce-missing", "no submods.gce block names the instance");
|
|
2712
|
+
}
|
|
2713
|
+
if (gce.project_id !== expectations.projectId) {
|
|
2714
|
+
return refuse2("project-mismatch", "token names an instance in a different project");
|
|
2715
|
+
}
|
|
2716
|
+
if (gce.instance_name !== expectations.instanceName) {
|
|
2717
|
+
return refuse2("instance-name-mismatch", "token names a different instance than this run booted");
|
|
2718
|
+
}
|
|
2719
|
+
if (typeof gce.instance_id !== "string" || gce.instance_id === "") {
|
|
2720
|
+
return refuse2("instance-id-missing", "instance id is absent or not a string");
|
|
2721
|
+
}
|
|
2722
|
+
const instanceId = gce.instance_id;
|
|
2723
|
+
if (typeof gce.zone !== "string" || !expectations.allowedZones.includes(gce.zone)) {
|
|
2724
|
+
return refuse2("zone-not-allowed", "zone is not one the pool is configured to use");
|
|
2725
|
+
}
|
|
2726
|
+
const zone = gce.zone;
|
|
2727
|
+
const container = isPlainObject(submods) ? submods.container : void 0;
|
|
2728
|
+
if (!isPlainObject(container)) {
|
|
2729
|
+
return refuse2("submods-container-missing", "no submods.container block names the workload");
|
|
2730
|
+
}
|
|
2731
|
+
if (typeof container.image_reference !== "string" || container.image_reference === "") {
|
|
2732
|
+
return refuse2("image-reference-missing", "the workload image reference is absent");
|
|
2733
|
+
}
|
|
2734
|
+
const imageReference = container.image_reference;
|
|
2735
|
+
if (typeof container.image_digest !== "string" || !expectations.allowedImageDigests.includes(container.image_digest)) {
|
|
2736
|
+
return refuse2("image-digest-not-allowed", "the workload image digest is not on the allowlist");
|
|
2737
|
+
}
|
|
2738
|
+
const env = container.env;
|
|
2739
|
+
for (const [name, value] of Object.entries(expectations.containerEnv ?? {})) {
|
|
2740
|
+
if (!isPlainObject(env) || !Object.prototype.hasOwnProperty.call(env, name) || env[name] !== value) {
|
|
2741
|
+
return refuse2("container-env-mismatch", `the signed container environment does not carry the ${name} this caller booted with`);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
return {
|
|
2745
|
+
ok: true,
|
|
2746
|
+
value: {
|
|
2747
|
+
instanceId,
|
|
2748
|
+
instanceName: expectations.instanceName,
|
|
2749
|
+
zone,
|
|
2750
|
+
projectId: expectations.projectId,
|
|
2751
|
+
imageDigest: container.image_digest,
|
|
2752
|
+
imageReference,
|
|
2753
|
+
hwmodel,
|
|
2754
|
+
iat,
|
|
2755
|
+
exp,
|
|
2756
|
+
tokenSha256: createHash2("sha256").update(token, "utf8").digest("hex")
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
var CONFIDENTIAL_SPACE_ISSUER, CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL, CONFIDENTIAL_SPACE_SWNAME, CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION;
|
|
2563
2761
|
var init_confidentialSpace = __esm({
|
|
2564
2762
|
"../../packages/attest/dist/confidentialSpace.js"() {
|
|
2565
2763
|
"use strict";
|
|
2566
2764
|
init_gceIdentity();
|
|
2567
2765
|
CONFIDENTIAL_SPACE_ISSUER = "https://confidentialcomputing.googleapis.com";
|
|
2568
2766
|
CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL = `${CONFIDENTIAL_SPACE_ISSUER}/.well-known/openid-configuration`;
|
|
2767
|
+
CONFIDENTIAL_SPACE_SWNAME = "CONFIDENTIAL_SPACE";
|
|
2768
|
+
CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION = "disabled-since-boot";
|
|
2569
2769
|
}
|
|
2570
2770
|
});
|
|
2571
2771
|
|
|
@@ -2908,50 +3108,50 @@ function toTestRunResult(result, outputSha256) {
|
|
|
2908
3108
|
function toAcceptancePredicate(pair, opts = {}) {
|
|
2909
3109
|
const { baseline, patched } = pair;
|
|
2910
3110
|
if (baseline.runId === patched.runId) {
|
|
2911
|
-
return
|
|
3111
|
+
return refuse3("pair-is-the-same-run", `both sides are run ${patched.runId}. A baseline and a patched result that are the same execution prove nothing about the patch, and every field of the pair would be equal by construction.`);
|
|
2912
3112
|
}
|
|
2913
3113
|
if (patched.status === "refused") {
|
|
2914
|
-
return
|
|
3114
|
+
return refuse3("run-was-refused", `the patched run was refused before executing (${patched.boundaryRefusals.length} boundary refusal(s)), so there is no result to attest`);
|
|
2915
3115
|
}
|
|
2916
3116
|
if (patched.outcome === null) {
|
|
2917
|
-
return
|
|
3117
|
+
return refuse3("no-outcome-recorded", "the patched run recorded no outcome");
|
|
2918
3118
|
}
|
|
2919
3119
|
if (baseline.status === "refused") {
|
|
2920
|
-
return
|
|
3120
|
+
return refuse3("baseline-was-refused", `the baseline run was refused before executing (${baseline.boundaryRefusals.length} boundary refusal(s)), so there is no "before" to compare against`);
|
|
2921
3121
|
}
|
|
2922
3122
|
if (baseline.outcome === null) {
|
|
2923
|
-
return
|
|
3123
|
+
return refuse3("baseline-no-outcome", "the baseline run recorded no outcome");
|
|
2924
3124
|
}
|
|
2925
3125
|
if (!BASELINE_IS_A_VERDICT[baseline.outcome]) {
|
|
2926
|
-
return
|
|
3126
|
+
return refuse3("baseline-environment-failed", `the baseline ended ${baseline.outcome}, which is not an observed verdict about the unpatched tree. Signing it would let a baseline we could not read stand in for one that failed, and a broken baseline flatters every patch.`);
|
|
2927
3127
|
}
|
|
2928
3128
|
const budget = OUTCOME_TO_BUDGET[patched.outcome];
|
|
2929
3129
|
if (budget === null) {
|
|
2930
|
-
return
|
|
3130
|
+
return refuse3("our-environment-failed", `the patched run ended ${patched.outcome}: the test command could not be invoked, which is our environment failing and not a verdict about the work. No statement is emitted, because every possible value would be a claim we cannot support.`);
|
|
2931
3131
|
}
|
|
2932
3132
|
if (patched.testCommand === null) {
|
|
2933
|
-
return
|
|
3133
|
+
return refuse3("no-test-command", "no test command was recorded, so none can be attested");
|
|
2934
3134
|
}
|
|
2935
3135
|
if (baseline.testCommand !== patched.testCommand) {
|
|
2936
|
-
return
|
|
3136
|
+
return refuse3("pair-disagrees-on-harness", `baseline ran ${JSON.stringify(baseline.testCommand)} and patched ran ${JSON.stringify(patched.testCommand)}. Opposite verdicts from different harnesses say nothing about the patch.`);
|
|
2937
3137
|
}
|
|
2938
3138
|
if (baseline.targetSha !== patched.targetSha) {
|
|
2939
|
-
return
|
|
3139
|
+
return refuse3("pair-disagrees-on-base", `baseline is at ${baseline.targetSha} and patched at ${patched.targetSha}`);
|
|
2940
3140
|
}
|
|
2941
3141
|
if (patched.treeDigest === null || patched.treeDigest === "") {
|
|
2942
|
-
return
|
|
3142
|
+
return refuse3("missing-tree-digest", "the patched run recorded no tree digest to bind to");
|
|
2943
3143
|
}
|
|
2944
3144
|
if (patched.patchSha256 === null || patched.patchSha256 === "") {
|
|
2945
|
-
return
|
|
3145
|
+
return refuse3("missing-patch-digest", "the patched run recorded no patch digest");
|
|
2946
3146
|
}
|
|
2947
3147
|
if (baseline.treeDigest === null || baseline.treeDigest === "") {
|
|
2948
|
-
return
|
|
3148
|
+
return refuse3("missing-tree-digest", "the baseline recorded no tree digest, so the two halves cannot be shown to differ");
|
|
2949
3149
|
}
|
|
2950
3150
|
if (baseline.treeDigest === patched.treeDigest) {
|
|
2951
|
-
return
|
|
3151
|
+
return refuse3("pair-has-identical-tree", `both sides are tree ${String(patched.treeDigest).slice(0, 12)}\u2026, so whatever the two runs disagree about, it is not the patch`);
|
|
2952
3152
|
}
|
|
2953
3153
|
if (patched.baselinePatchSha256 !== null || baseline.baselinePatchSha256 !== null) {
|
|
2954
|
-
return
|
|
3154
|
+
return refuse3("patch-binding-incomplete", `a baseline patch shaped the ${patched.baselinePatchSha256 !== null ? "patched" : "baseline"} tree, so it cannot be reproduced from (base_commit_oid, patch_sha256) alone. Fold it into the binding before signing; do not sign a triple a verifier cannot follow.`);
|
|
2955
3155
|
}
|
|
2956
3156
|
for (const [side, outcome, counts, exitCode] of [
|
|
2957
3157
|
["patched", patched.outcome, patched.counts, patched.exitCode],
|
|
@@ -2959,7 +3159,7 @@ function toAcceptancePredicate(pair, opts = {}) {
|
|
|
2959
3159
|
]) {
|
|
2960
3160
|
const why = contradicts(outcome, counts, exitCode);
|
|
2961
3161
|
if (why !== null) {
|
|
2962
|
-
return
|
|
3162
|
+
return refuse3("outcome-contradicts-counts", `the ${side} run is ${why}`);
|
|
2963
3163
|
}
|
|
2964
3164
|
}
|
|
2965
3165
|
for (const [side, source] of [
|
|
@@ -2968,44 +3168,44 @@ function toAcceptancePredicate(pair, opts = {}) {
|
|
|
2968
3168
|
]) {
|
|
2969
3169
|
const why = SOURCE_IS_SIGNABLE[source](patched.testCommand);
|
|
2970
3170
|
if (why !== null)
|
|
2971
|
-
return
|
|
3171
|
+
return refuse3("test-command-origin-unattested", `the ${side} run ${why}`);
|
|
2972
3172
|
}
|
|
2973
3173
|
if (patched.testCommandSource === "developer-declared") {
|
|
2974
|
-
return
|
|
3174
|
+
return refuse3("test-command-origin-unattested", "the developer supplied the test command (unreachable \u2014 the source rules above cover it)");
|
|
2975
3175
|
}
|
|
2976
3176
|
if (baseline.testCommandSource !== patched.testCommandSource) {
|
|
2977
|
-
return
|
|
3177
|
+
return refuse3("test-command-origin-unattested", `the pair disagrees on where the command came from: baseline ${baseline.testCommandSource}, patched ${patched.testCommandSource}. One signed value cannot describe both.`);
|
|
2978
3178
|
}
|
|
2979
3179
|
if (baseline.containerImage !== patched.containerImage) {
|
|
2980
|
-
return
|
|
3180
|
+
return refuse3("pair-disagrees-on-image", `baseline ran in ${String(baseline.containerImage)} and patched in ${String(patched.containerImage)}. The signed measurement names one image, so a pair from two environments would attribute to the patch whatever the image changed.`);
|
|
2981
3181
|
}
|
|
2982
3182
|
if (patched.containerImage === null || patched.containerImage === "") {
|
|
2983
|
-
return
|
|
3183
|
+
return refuse3("missing-container-image", "no container image was recorded, so the measurement would name a stand-in rather than the environment the suite actually ran in");
|
|
2984
3184
|
}
|
|
2985
3185
|
if (patched.containerImageDigest == null || patched.containerImageDigest === "") {
|
|
2986
|
-
return
|
|
3186
|
+
return refuse3("missing-image-digest", "no image digest was recorded for the patched half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
|
|
2987
3187
|
}
|
|
2988
3188
|
if (typeof patched.containerImageDigest !== "string" || !isCanonicalRepoDigest(patched.containerImageDigest)) {
|
|
2989
|
-
return
|
|
3189
|
+
return refuse3("malformed-image-digest", `the patched half carries ${JSON.stringify(patched.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
|
|
2990
3190
|
}
|
|
2991
3191
|
if (imageRepo(patched.containerImageDigest) !== imageRepo(patched.containerImage)) {
|
|
2992
|
-
return
|
|
3192
|
+
return refuse3("image-digest-repo-mismatch", `the patched half's digest names repository ${imageRepo(patched.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
|
|
2993
3193
|
}
|
|
2994
3194
|
if (baseline.containerImageDigest == null || baseline.containerImageDigest === "") {
|
|
2995
|
-
return
|
|
3195
|
+
return refuse3("missing-image-digest", "no image digest was recorded for the baseline half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
|
|
2996
3196
|
}
|
|
2997
3197
|
if (typeof baseline.containerImageDigest !== "string" || !isCanonicalRepoDigest(baseline.containerImageDigest)) {
|
|
2998
|
-
return
|
|
3198
|
+
return refuse3("malformed-image-digest", `the baseline half carries ${JSON.stringify(baseline.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
|
|
2999
3199
|
}
|
|
3000
3200
|
if (imageRepo(baseline.containerImageDigest) !== imageRepo(patched.containerImage)) {
|
|
3001
|
-
return
|
|
3201
|
+
return refuse3("image-digest-repo-mismatch", `the baseline half's digest names repository ${imageRepo(baseline.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
|
|
3002
3202
|
}
|
|
3003
3203
|
if (baseline.containerImageDigest !== patched.containerImageDigest) {
|
|
3004
|
-
return
|
|
3204
|
+
return refuse3("pair-disagrees-on-image-digest", `baseline ran image digest ${String(baseline.containerImageDigest)} and patched ${String(patched.containerImageDigest)} under one tag. The tag agreeing is the trap: a re-pointed tag is two environments wearing one name.`);
|
|
3005
3205
|
}
|
|
3006
3206
|
const claimedDigest = imageDigestOf(patched.containerImage);
|
|
3007
3207
|
if (claimedDigest !== null && claimedDigest !== imageDigestOf(patched.containerImageDigest)) {
|
|
3008
|
-
return
|
|
3208
|
+
return refuse3("image-digest-contradicts-image", `the pair ran ${patched.containerImage}, whose reference pins digest ${claimedDigest}, but the digest field says ${String(imageDigestOf(patched.containerImageDigest))}. A measurement must not sign one digest while the record names another.`);
|
|
3009
3209
|
}
|
|
3010
3210
|
return {
|
|
3011
3211
|
ok: true,
|
|
@@ -3032,7 +3232,7 @@ function signRunStatement(predicate, privateKey, keyid) {
|
|
|
3032
3232
|
const statement = createAcceptanceStatement(predicate);
|
|
3033
3233
|
return { statement, envelope: signStatement(statement, privateKey, keyid) };
|
|
3034
3234
|
}
|
|
3035
|
-
var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS,
|
|
3235
|
+
var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse3, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
|
|
3036
3236
|
var init_attestation2 = __esm({
|
|
3037
3237
|
"../../packages/envrun/dist/attestation.js"() {
|
|
3038
3238
|
"use strict";
|
|
@@ -3136,7 +3336,7 @@ var init_attestation2 = __esm({
|
|
|
3136
3336
|
"outcome-contradicts-counts",
|
|
3137
3337
|
"test-command-origin-unattested"
|
|
3138
3338
|
];
|
|
3139
|
-
|
|
3339
|
+
refuse3 = (reason, detail) => ({
|
|
3140
3340
|
ok: false,
|
|
3141
3341
|
reason,
|
|
3142
3342
|
detail
|
|
@@ -3547,6 +3747,22 @@ function bootImageFlags(p) {
|
|
|
3547
3747
|
}
|
|
3548
3748
|
const workloadImage = p.workloadImage;
|
|
3549
3749
|
assertWorkloadImage("gcpBootArgv", workloadImage);
|
|
3750
|
+
const clientCert = p.clientCert;
|
|
3751
|
+
if (typeof clientCert !== "string" || !CLIENT_CERT_BASE64URL.test(clientCert)) {
|
|
3752
|
+
throw new GcpPlacementError(`gcpBootArgv: the client certificate is missing or is not one base64url value (${String(CLIENT_CERT_BASE64URL)}). It lands inside the --metadata value, so anything that could start a second key is refused.`);
|
|
3753
|
+
}
|
|
3754
|
+
if (p.runId.length < MIN_NONCE_BYTES) {
|
|
3755
|
+
throw new GcpPlacementError(`gcpBootArgv: a Confidential Space run id must be at least ${String(MIN_NONCE_BYTES)} characters, because it is the attestation token's nonce and the launcher refuses a shorter one. Refused here, before a VM is billed for a token it cannot mint.`);
|
|
3756
|
+
}
|
|
3757
|
+
const metadata = [
|
|
3758
|
+
`tee-image-reference=${workloadImage}`,
|
|
3759
|
+
"tee-container-log-redirect=true",
|
|
3760
|
+
'tee-added-capabilities=["CAP_SYS_ADMIN","CAP_NET_ADMIN"]',
|
|
3761
|
+
"tee-cgroup-ns=true",
|
|
3762
|
+
`tee-env-TH_AUDIENCE=${expectedAudience(p.runId)}`,
|
|
3763
|
+
`tee-env-TH_NONCE=${p.runId}`,
|
|
3764
|
+
`tee-env-TH_CLIENT_CERT=${clientCert}`
|
|
3765
|
+
];
|
|
3550
3766
|
return [
|
|
3551
3767
|
// The production family. The debug family reports `dbgstat: enabled`, which the
|
|
3552
3768
|
// intake's verifier refuses, so a debug boot could never produce a run we record.
|
|
@@ -3559,7 +3775,15 @@ function bootImageFlags(p) {
|
|
|
3559
3775
|
// most GCP_MAX_RUN_DURATION_SECONDS costs one run. Whether GCE boots this exact
|
|
3560
3776
|
// combination is phase 5's measurement.
|
|
3561
3777
|
"--maintenance-policy=TERMINATE",
|
|
3562
|
-
`--metadata
|
|
3778
|
+
`--metadata=^;^${metadata.join(";")}`,
|
|
3779
|
+
// What the standing firewall rule for IAP's range matches, so the tunnels to the
|
|
3780
|
+
// workload's dockerd and attest ports open on these VMs and no others.
|
|
3781
|
+
"--tags=th-cs-venue",
|
|
3782
|
+
// The family's 11 GB default leaves the workload a 7.7 GB root, and dockerd there
|
|
3783
|
+
// runs vfs, which copies every layer and every container's rootfs in full. Pulling
|
|
3784
|
+
// node:20-bookworm left 992 MB, and the next container create failed ENOSPC
|
|
3785
|
+
// (measured, TERM-1094). COS runs overlay2 on its own disk and keeps the default.
|
|
3786
|
+
"--boot-disk-size=50GB"
|
|
3563
3787
|
];
|
|
3564
3788
|
}
|
|
3565
3789
|
function assertInstanceIdentity(fn, id) {
|
|
@@ -3633,7 +3857,13 @@ function gcpBootArgv(p) {
|
|
|
3633
3857
|
// expectation uses (TERM-832): two inline spellings of one value is how
|
|
3634
3858
|
// the boot path and the verifier drift apart silently.
|
|
3635
3859
|
`--service-account=${venueIdentityEmail(p.project)}`,
|
|
3636
|
-
|
|
3860
|
+
// A Confidential Space VM takes `cloud-platform` instead (TERM-1094): the launcher
|
|
3861
|
+
// pulls the workload image and mints the attestation token with this account, and
|
|
3862
|
+
// under `--no-scopes` it can do neither. What bounds a lifted token there is the
|
|
3863
|
+
// account's two grants — `confidentialcomputing.workloadUser` and a reader on the
|
|
3864
|
+
// one venue image repository — plus point 2 above, which is unchanged: the fence
|
|
3865
|
+
// still denies untrusted code the metadata server.
|
|
3866
|
+
...p.venueImage === "confidential-space" ? ["--scopes=cloud-platform"] : ["--no-scopes"],
|
|
3637
3867
|
"--shielded-secure-boot",
|
|
3638
3868
|
"--shielded-vtpm",
|
|
3639
3869
|
"--shielded-integrity-monitoring",
|
|
@@ -3666,7 +3896,7 @@ function gcpDeleteArgv(p) {
|
|
|
3666
3896
|
"--quiet"
|
|
3667
3897
|
];
|
|
3668
3898
|
}
|
|
3669
|
-
var DEFAULT_GCP_PROJECT, DEFAULT_GCP_ZONE, DEFAULT_GCP_MACHINE_TYPE, GcpPlacementError, GCP_MAX_RUN_DURATION_SECONDS, GCP_MANAGED_LABEL_KEY, GCP_RUN_LABEL_KEY, CONFIDENTIAL_SPACE_MACHINE_TYPE, WORKLOAD_IMAGE_REFERENCE, GCP_LABEL_VALUE, GCE_INSTANCE_NAME, GCP_RESOURCE_ID;
|
|
3899
|
+
var DEFAULT_GCP_PROJECT, DEFAULT_GCP_ZONE, DEFAULT_GCP_MACHINE_TYPE, GcpPlacementError, GCP_MAX_RUN_DURATION_SECONDS, GCP_MANAGED_LABEL_KEY, GCP_RUN_LABEL_KEY, CONFIDENTIAL_SPACE_MACHINE_TYPE, WORKLOAD_IMAGE_REFERENCE, CLIENT_CERT_BASE64URL, MIN_NONCE_BYTES, GCP_LABEL_VALUE, GCE_INSTANCE_NAME, GCP_RESOURCE_ID;
|
|
3670
3900
|
var init_gcpPlacement = __esm({
|
|
3671
3901
|
"../../packages/envrun/dist/gcpPlacement.js"() {
|
|
3672
3902
|
"use strict";
|
|
@@ -3685,6 +3915,8 @@ var init_gcpPlacement = __esm({
|
|
|
3685
3915
|
GCP_RUN_LABEL_KEY = "th-run";
|
|
3686
3916
|
CONFIDENTIAL_SPACE_MACHINE_TYPE = "n2d-standard-2";
|
|
3687
3917
|
WORKLOAD_IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,511}$/;
|
|
3918
|
+
CLIENT_CERT_BASE64URL = /^[A-Za-z0-9_-]{16,8192}$/;
|
|
3919
|
+
MIN_NONCE_BYTES = 10;
|
|
3688
3920
|
GCP_LABEL_VALUE = /^[a-z0-9_-]{1,63}$/;
|
|
3689
3921
|
GCE_INSTANCE_NAME = /^[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
|
3690
3922
|
GCP_RESOURCE_ID = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -3726,7 +3958,10 @@ var init_emptyGitConfig = __esm({
|
|
|
3726
3958
|
|
|
3727
3959
|
// ../../packages/envrun/dist/hostedVenue.js
|
|
3728
3960
|
import { spawn as spawn3, spawnSync as spawnSync5 } from "child_process";
|
|
3729
|
-
import {
|
|
3961
|
+
import { createHash as createHash4, X509Certificate } from "crypto";
|
|
3962
|
+
import { chmodSync as chmodSync2, existsSync as existsSync6, mkdtempSync as mkdtempSync3, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
3963
|
+
import { request as httpsRequest } from "https";
|
|
3964
|
+
import { createServer } from "net";
|
|
3730
3965
|
import { join as join11 } from "path";
|
|
3731
3966
|
import { tmpdir as tmpdir3 } from "os";
|
|
3732
3967
|
function credentialInGitConfig(text) {
|
|
@@ -3928,6 +4163,89 @@ timed out after ${String(timeoutMs)}ms` : stderr,
|
|
|
3928
4163
|
});
|
|
3929
4164
|
});
|
|
3930
4165
|
}
|
|
4166
|
+
function socketErrorReason(err) {
|
|
4167
|
+
if (err.message !== "")
|
|
4168
|
+
return err.message;
|
|
4169
|
+
const code = err.code;
|
|
4170
|
+
if (code !== void 0)
|
|
4171
|
+
return code;
|
|
4172
|
+
const inner = err.errors;
|
|
4173
|
+
if (Array.isArray(inner) && inner[0] instanceof Error && inner[0].message !== "") {
|
|
4174
|
+
return inner[0].message;
|
|
4175
|
+
}
|
|
4176
|
+
return err.name;
|
|
4177
|
+
}
|
|
4178
|
+
function fetchAttestationOverTls(req) {
|
|
4179
|
+
return new Promise((resolve2) => {
|
|
4180
|
+
let whole;
|
|
4181
|
+
const settle = (reading) => {
|
|
4182
|
+
if (whole !== void 0)
|
|
4183
|
+
clearTimeout(whole);
|
|
4184
|
+
resolve2(reading);
|
|
4185
|
+
};
|
|
4186
|
+
let cert;
|
|
4187
|
+
let key;
|
|
4188
|
+
try {
|
|
4189
|
+
cert = readFileSync4(req.certPath);
|
|
4190
|
+
key = readFileSync4(req.keyPath);
|
|
4191
|
+
} catch (err) {
|
|
4192
|
+
settle({ ok: false, reason: `the client key could not be read: ${describeErr(err)}` });
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
const call = httpsRequest({
|
|
4196
|
+
host: "localhost",
|
|
4197
|
+
port: req.port,
|
|
4198
|
+
path: "/attest",
|
|
4199
|
+
method: "GET",
|
|
4200
|
+
servername: "localhost",
|
|
4201
|
+
cert,
|
|
4202
|
+
key,
|
|
4203
|
+
rejectUnauthorized: false,
|
|
4204
|
+
agent: false,
|
|
4205
|
+
timeout: req.timeoutMs
|
|
4206
|
+
}, (res) => {
|
|
4207
|
+
const peer = res.socket.getPeerCertificate();
|
|
4208
|
+
let body = "";
|
|
4209
|
+
res.setEncoding("utf8");
|
|
4210
|
+
res.on("data", (chunk) => {
|
|
4211
|
+
body += chunk;
|
|
4212
|
+
if (body.length > MAX_TOKEN_LENGTH) {
|
|
4213
|
+
settle({ ok: false, reason: "the answer is longer than any attestation token" });
|
|
4214
|
+
res.destroy();
|
|
4215
|
+
}
|
|
4216
|
+
});
|
|
4217
|
+
res.on("end", () => {
|
|
4218
|
+
if (res.statusCode !== 200) {
|
|
4219
|
+
settle({
|
|
4220
|
+
ok: false,
|
|
4221
|
+
reason: `the attest endpoint answered HTTP ${String(res.statusCode)}`
|
|
4222
|
+
});
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
const raw = peer.raw;
|
|
4226
|
+
if (raw === void 0 || raw.length === 0) {
|
|
4227
|
+
settle({ ok: false, reason: "the attest endpoint presented no certificate" });
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
settle({ ok: true, token: body.trim(), peerCertDer: new Uint8Array(raw) });
|
|
4231
|
+
});
|
|
4232
|
+
res.on("error", (err) => {
|
|
4233
|
+
settle({ ok: false, reason: socketErrorReason(err) });
|
|
4234
|
+
});
|
|
4235
|
+
});
|
|
4236
|
+
whole = setTimeout(() => {
|
|
4237
|
+
settle({ ok: false, reason: `no complete answer within ${String(req.timeoutMs)}ms` });
|
|
4238
|
+
call.destroy();
|
|
4239
|
+
}, req.timeoutMs);
|
|
4240
|
+
call.on("timeout", () => {
|
|
4241
|
+
call.destroy(new Error(`no answer within ${String(req.timeoutMs)}ms`));
|
|
4242
|
+
});
|
|
4243
|
+
call.on("error", (err) => {
|
|
4244
|
+
settle({ ok: false, reason: socketErrorReason(err) });
|
|
4245
|
+
});
|
|
4246
|
+
call.end();
|
|
4247
|
+
});
|
|
4248
|
+
}
|
|
3931
4249
|
function quoteForRemoteShell(arg) {
|
|
3932
4250
|
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
3933
4251
|
}
|
|
@@ -4262,27 +4580,370 @@ ${res.stderr}`);
|
|
|
4262
4580
|
function manualDeleteCommand(vm, project, zone) {
|
|
4263
4581
|
return `gcloud compute instances delete ${vm} --project=${project} --zone=${zone} --quiet`;
|
|
4264
4582
|
}
|
|
4583
|
+
function clientCertArgv(keyPath, certPath) {
|
|
4584
|
+
return [
|
|
4585
|
+
"req",
|
|
4586
|
+
"-x509",
|
|
4587
|
+
"-newkey",
|
|
4588
|
+
"ec",
|
|
4589
|
+
"-pkeyopt",
|
|
4590
|
+
"ec_paramgen_curve:P-256",
|
|
4591
|
+
"-nodes",
|
|
4592
|
+
"-keyout",
|
|
4593
|
+
keyPath,
|
|
4594
|
+
"-out",
|
|
4595
|
+
certPath,
|
|
4596
|
+
"-subj",
|
|
4597
|
+
"/CN=terminalhire-worker",
|
|
4598
|
+
"-days",
|
|
4599
|
+
"1",
|
|
4600
|
+
"-addext",
|
|
4601
|
+
"extendedKeyUsage=clientAuth"
|
|
4602
|
+
];
|
|
4603
|
+
}
|
|
4604
|
+
function iapPortTunnelArgv(vm, project, zone, remotePort, localPort) {
|
|
4605
|
+
return [
|
|
4606
|
+
"compute",
|
|
4607
|
+
"start-iap-tunnel",
|
|
4608
|
+
vm,
|
|
4609
|
+
String(remotePort),
|
|
4610
|
+
`--local-host-port=localhost:${String(localPort)}`,
|
|
4611
|
+
`--project=${project}`,
|
|
4612
|
+
`--zone=${zone}`,
|
|
4613
|
+
"--iap-tunnel-disable-connection-check"
|
|
4614
|
+
];
|
|
4615
|
+
}
|
|
4616
|
+
function spkiSha256Hex(certDer) {
|
|
4617
|
+
const spki = new X509Certificate(certDer).publicKey.export({ type: "spki", format: "der" });
|
|
4618
|
+
return createHash4("sha256").update(spki).digest("hex");
|
|
4619
|
+
}
|
|
4620
|
+
function certMetadataValue(pem) {
|
|
4621
|
+
return new X509Certificate(pem).raw.toString("base64url");
|
|
4622
|
+
}
|
|
4623
|
+
function requireWorkloadDigest(reference) {
|
|
4624
|
+
const named = /@(sha256:[0-9a-f]{64})$/.exec(reference);
|
|
4625
|
+
if (named === null) {
|
|
4626
|
+
throw new GcpPlacementError("VENUE_WORKLOAD_IMAGE must name its image by digest (\u2026@sha256:<64 hex>) for a Confidential Space venue: the attestation is checked against that digest, and a tag names whatever was pushed last.");
|
|
4627
|
+
}
|
|
4628
|
+
return named[1];
|
|
4629
|
+
}
|
|
4630
|
+
function underStageRoot(path) {
|
|
4631
|
+
if (!path.startsWith(`${CS_STAGE_ROOT}/`)) {
|
|
4632
|
+
throw new Error(`${path} is outside ${CS_STAGE_ROOT}, the only directory the stage helpers mount`);
|
|
4633
|
+
}
|
|
4634
|
+
return path;
|
|
4635
|
+
}
|
|
4636
|
+
function userFlag(owner) {
|
|
4637
|
+
return `--user=${String(owner.uid)}:${String(owner.gid)}`;
|
|
4638
|
+
}
|
|
4639
|
+
function csStagePrepareArgv(stageBase, owner) {
|
|
4640
|
+
const dirs = Object.values(venueStagePaths(underStageRoot(stageBase))).map(quoteForRemoteShell);
|
|
4641
|
+
return [
|
|
4642
|
+
"run",
|
|
4643
|
+
"--rm",
|
|
4644
|
+
"--network=none",
|
|
4645
|
+
`--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
|
|
4646
|
+
"--",
|
|
4647
|
+
STAGE_HELPER_IMAGE,
|
|
4648
|
+
"sh",
|
|
4649
|
+
"-c",
|
|
4650
|
+
`mkdir -p ${dirs.join(" ")} && chown -R ${String(owner.uid)}:${String(owner.gid)} ` + quoteForRemoteShell(stageBase)
|
|
4651
|
+
];
|
|
4652
|
+
}
|
|
4653
|
+
function csUntarArgv(dir, owner) {
|
|
4654
|
+
return [
|
|
4655
|
+
"run",
|
|
4656
|
+
"-i",
|
|
4657
|
+
"--rm",
|
|
4658
|
+
"--network=none",
|
|
4659
|
+
userFlag(owner),
|
|
4660
|
+
`--volume=${underStageRoot(dir)}:/dst:rw`,
|
|
4661
|
+
"--",
|
|
4662
|
+
STAGE_HELPER_IMAGE,
|
|
4663
|
+
"tar",
|
|
4664
|
+
"-xf",
|
|
4665
|
+
"-",
|
|
4666
|
+
"-C",
|
|
4667
|
+
"/dst"
|
|
4668
|
+
];
|
|
4669
|
+
}
|
|
4670
|
+
function csUntarProxyArgv(dir, owner) {
|
|
4671
|
+
const d = quoteForRemoteShell(underStageRoot(dir));
|
|
4672
|
+
return [
|
|
4673
|
+
"run",
|
|
4674
|
+
"-i",
|
|
4675
|
+
"--rm",
|
|
4676
|
+
"--network=none",
|
|
4677
|
+
userFlag(owner),
|
|
4678
|
+
`--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
|
|
4679
|
+
"--",
|
|
4680
|
+
STAGE_HELPER_IMAGE,
|
|
4681
|
+
"sh",
|
|
4682
|
+
"-c",
|
|
4683
|
+
`mkdir -p ${d} && tar -C ${d} -xf - && chmod 0644 ${d}/*.js && chmod 0755 ${d}`
|
|
4684
|
+
];
|
|
4685
|
+
}
|
|
4686
|
+
function csRemoveArgv(dir, owner) {
|
|
4687
|
+
return [
|
|
4688
|
+
"run",
|
|
4689
|
+
"--rm",
|
|
4690
|
+
"--network=none",
|
|
4691
|
+
userFlag(owner),
|
|
4692
|
+
`--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
|
|
4693
|
+
"--",
|
|
4694
|
+
STAGE_HELPER_IMAGE,
|
|
4695
|
+
"rm",
|
|
4696
|
+
"-rf",
|
|
4697
|
+
"--",
|
|
4698
|
+
underStageRoot(dir)
|
|
4699
|
+
];
|
|
4700
|
+
}
|
|
4701
|
+
function sshStageTransport(vm, project, zone, env, io) {
|
|
4702
|
+
return {
|
|
4703
|
+
/**
|
|
4704
|
+
* `id -u` / `id -g` on the venue, as the account that untarred the tree.
|
|
4705
|
+
*
|
|
4706
|
+
* That account is the owner BY CONSTRUCTION — the untar runs as the ssh user,
|
|
4707
|
+
* so asking it who it is answers exactly the question the mount needs, without
|
|
4708
|
+
* a chown and without assuming a uid. One ssh round trip, once per run.
|
|
4709
|
+
*
|
|
4710
|
+
* Both ids come from ONE invocation. Two calls could straddle a reconnect and
|
|
4711
|
+
* pair a uid with another session's gid — a third wrong answer rather than a
|
|
4712
|
+
* half-right one, which is the reason `GuestUser` is a pair in the first place.
|
|
4713
|
+
*/
|
|
4714
|
+
readOwner: () => {
|
|
4715
|
+
const res = io.exec(
|
|
4716
|
+
"gcloud",
|
|
4717
|
+
iapSshArgv(vm, project, zone, "id -u && id -g"),
|
|
4718
|
+
OWNER_PROBE_TIMEOUT_MS,
|
|
4719
|
+
// `env`, like every other gcloud call here. TERM-724 made the venue boot under the
|
|
4720
|
+
// SERVICE configuration rather than whoever is at the terminal, and a probe that
|
|
4721
|
+
// omitted this would ssh under the person's own credential — then report an owner
|
|
4722
|
+
// measured against a session the run does not use.
|
|
4723
|
+
env
|
|
4724
|
+
);
|
|
4725
|
+
if (!res.ok) {
|
|
4726
|
+
throw new HostedVenueError(`could not read the staged tree's owner on ${vm}: ${execDetail(res).slice(0, 300)}. Refusing rather than falling back to this machine\u2019s uid, which would hand the guest an id with no write access to its own clone.`);
|
|
4727
|
+
}
|
|
4728
|
+
const lines = res.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
4729
|
+
const ids2 = lines.slice(-2).map((l) => Number(l));
|
|
4730
|
+
if (ids2.length !== 2 || !ids2.every((n) => Number.isInteger(n) && n > 0)) {
|
|
4731
|
+
throw new HostedVenueError(`the venue ${vm} answered 'id -u && id -g' with ${JSON.stringify(res.stdout.slice(0, 120))}, which is not a uid/gid pair. Refusing rather than guessing an owner.`);
|
|
4732
|
+
}
|
|
4733
|
+
return { uid: ids2[0], gid: ids2[1] };
|
|
4734
|
+
},
|
|
4735
|
+
push: (from, to) => io.pushTree(from, "gcloud", iapUntarArgv(vm, project, zone, to), STAGE_PUSH_TIMEOUT_MS, env),
|
|
4736
|
+
pushProxy: (from, dir) => io.pushTree(from, "gcloud", iapUntarProxyArgv(vm, project, zone, dir), STAGE_PUSH_TIMEOUT_MS, env),
|
|
4737
|
+
removeProxy: (dir) => {
|
|
4738
|
+
io.exec("gcloud", iapSshArgv(vm, project, zone, `rm -rf ${quoteForRemoteShell(dir)}`), PROXY_CLEANUP_TIMEOUT_MS, env);
|
|
4739
|
+
}
|
|
4740
|
+
};
|
|
4741
|
+
}
|
|
4742
|
+
function dockerStageTransport(docker3, env, io) {
|
|
4743
|
+
const pushVia = (from, args) => {
|
|
4744
|
+
const [file, ...rest] = docker3.commandLine(args);
|
|
4745
|
+
if (file === void 0)
|
|
4746
|
+
throw new Error("the docker client returned an empty command line");
|
|
4747
|
+
return io.pushTree(from, file, rest, STAGE_PUSH_TIMEOUT_MS, env);
|
|
4748
|
+
};
|
|
4749
|
+
return {
|
|
4750
|
+
// Fixed rather than probed: the helpers unpack as this account, so it owns the tree.
|
|
4751
|
+
readOwner: () => CS_GUEST_USER,
|
|
4752
|
+
push: (from, to) => pushVia(from, csUntarArgv(to, CS_GUEST_USER)),
|
|
4753
|
+
pushProxy: (from, dir) => pushVia(from, csUntarProxyArgv(dir, CS_GUEST_USER)),
|
|
4754
|
+
removeProxy: (dir) => {
|
|
4755
|
+
docker3.sync(csRemoveArgv(dir, CS_GUEST_USER), { timeoutMs: PROXY_CLEANUP_TIMEOUT_MS });
|
|
4756
|
+
}
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
function bothTunnels(a, b) {
|
|
4760
|
+
return {
|
|
4761
|
+
kill: (signal) => {
|
|
4762
|
+
let failed = false;
|
|
4763
|
+
let error;
|
|
4764
|
+
for (const t of [a, b]) {
|
|
4765
|
+
try {
|
|
4766
|
+
t.kill(signal);
|
|
4767
|
+
} catch (err) {
|
|
4768
|
+
if (!failed)
|
|
4769
|
+
error = err;
|
|
4770
|
+
failed = true;
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
if (failed)
|
|
4774
|
+
throw error;
|
|
4775
|
+
},
|
|
4776
|
+
failure: () => a.failure() ?? b.failure()
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4779
|
+
function makeClientTls(io, allocated, vm) {
|
|
4780
|
+
let dir;
|
|
4781
|
+
try {
|
|
4782
|
+
dir = io.makePrivateDir();
|
|
4783
|
+
} catch (err) {
|
|
4784
|
+
throw new HostedVenueError(`could not create a private directory for ${vm}'s client key: ${describeErr(err)}`, "ours");
|
|
4785
|
+
}
|
|
4786
|
+
allocated.onRollback(() => {
|
|
4787
|
+
try {
|
|
4788
|
+
io.removeTree(dir);
|
|
4789
|
+
} catch (err) {
|
|
4790
|
+
throw new HostedVenueError(`the directory ${dir} holding ${vm}'s client key could not be removed and is left behind: ${describeErr(err)}`);
|
|
4791
|
+
}
|
|
4792
|
+
});
|
|
4793
|
+
const certPath = join11(dir, "client.pem");
|
|
4794
|
+
const keyPath = join11(dir, "client-key.pem");
|
|
4795
|
+
const made = io.exec("openssl", clientCertArgv(keyPath, certPath), OPENSSL_TIMEOUT_MS, {});
|
|
4796
|
+
if (!made.ok) {
|
|
4797
|
+
throw new HostedVenueError(`could not make ${vm}'s client certificate with openssl: ${execDetail(made).slice(0, 300)}`, "ours");
|
|
4798
|
+
}
|
|
4799
|
+
let metadataValue;
|
|
4800
|
+
try {
|
|
4801
|
+
const pem = io.readTextIfPresent(certPath);
|
|
4802
|
+
if (pem === null)
|
|
4803
|
+
throw new Error(`openssl wrote nothing to ${certPath}`);
|
|
4804
|
+
metadataValue = certMetadataValue(pem);
|
|
4805
|
+
} catch (err) {
|
|
4806
|
+
throw new HostedVenueError(`could not read back ${vm}'s client certificate: ${describeErr(err)}`, "ours");
|
|
4807
|
+
}
|
|
4808
|
+
return { dir, certPath, keyPath, metadataValue };
|
|
4809
|
+
}
|
|
4810
|
+
async function reachConfidentialSpace(a) {
|
|
4811
|
+
const { io, vm } = a;
|
|
4812
|
+
let ports;
|
|
4813
|
+
try {
|
|
4814
|
+
ports = await io.freeLocalPorts(2);
|
|
4815
|
+
} catch (err) {
|
|
4816
|
+
throw new HostedVenueError(`could not find two free local ports for ${vm}'s forwards: ${describeErr(err)}`, "ours");
|
|
4817
|
+
}
|
|
4818
|
+
const [attestPort, dockerPort] = ports;
|
|
4819
|
+
if (attestPort === void 0 || dockerPort === void 0 || attestPort === dockerPort) {
|
|
4820
|
+
throw new HostedVenueError(`could not find two distinct free local ports for ${vm}'s forwards`, "ours");
|
|
4821
|
+
}
|
|
4822
|
+
const forward = (remote, local) => {
|
|
4823
|
+
const t = io.spawnTunnel("gcloud", iapPortTunnelArgv(vm, a.project, a.zone, remote, local), a.env);
|
|
4824
|
+
a.allocated.onRollback(() => {
|
|
4825
|
+
try {
|
|
4826
|
+
t.kill("SIGTERM");
|
|
4827
|
+
} catch {
|
|
4828
|
+
}
|
|
4829
|
+
});
|
|
4830
|
+
return t;
|
|
4831
|
+
};
|
|
4832
|
+
const tunnel = bothTunnels(forward(CS_ATTEST_PORT, attestPort), forward(CS_DOCKER_PORT, dockerPort));
|
|
4833
|
+
const deadline = io.now() + CS_READY_BUDGET_MS;
|
|
4834
|
+
let reading = { ok: false, reason: "no request was made" };
|
|
4835
|
+
while (io.now() < deadline) {
|
|
4836
|
+
const dead = tunnel.failure();
|
|
4837
|
+
if (dead !== null) {
|
|
4838
|
+
throw new HostedVenueError(`the IAP port forward to ${vm} could not be kept up \u2014 ${dead}`);
|
|
4839
|
+
}
|
|
4840
|
+
reading = await io.fetchAttestation({
|
|
4841
|
+
port: attestPort,
|
|
4842
|
+
certPath: a.tls.certPath,
|
|
4843
|
+
keyPath: a.tls.keyPath,
|
|
4844
|
+
timeoutMs: CS_ATTEST_TIMEOUT_MS
|
|
4845
|
+
});
|
|
4846
|
+
if (reading.ok)
|
|
4847
|
+
break;
|
|
4848
|
+
await io.sleep(CS_ATTEST_INTERVAL_MS);
|
|
4849
|
+
}
|
|
4850
|
+
if (!reading.ok) {
|
|
4851
|
+
throw new HostedVenueError(`${vm}'s attest endpoint did not answer within ${String(CS_READY_BUDGET_MS / 1e3)}s \u2014 last reason: ${reading.reason}`);
|
|
4852
|
+
}
|
|
4853
|
+
let spki;
|
|
4854
|
+
let venuePem;
|
|
4855
|
+
try {
|
|
4856
|
+
spki = spkiSha256Hex(reading.peerCertDer);
|
|
4857
|
+
venuePem = new X509Certificate(reading.peerCertDer).toString();
|
|
4858
|
+
} catch (err) {
|
|
4859
|
+
throw new HostedVenueError(`${vm}'s attest endpoint presented a certificate that does not parse: ${describeErr(err)}`);
|
|
4860
|
+
}
|
|
4861
|
+
let jwks;
|
|
4862
|
+
try {
|
|
4863
|
+
jwks = await io.fetchConfidentialSpaceJwks();
|
|
4864
|
+
} catch (err) {
|
|
4865
|
+
throw new HostedVenueError(`could not reach the Confidential Space key set to check ${vm}'s attestation \u2014 ` + describeErr(err), "ours", { cause: err });
|
|
4866
|
+
}
|
|
4867
|
+
const verdict = await verifyConfidentialSpaceToken(reading.token, {
|
|
4868
|
+
audience: expectedAudience(a.runId),
|
|
4869
|
+
nonce: a.runId,
|
|
4870
|
+
additionalNonces: [spki],
|
|
4871
|
+
instanceName: vm,
|
|
4872
|
+
allowedZones: [a.zone],
|
|
4873
|
+
projectId: a.project,
|
|
4874
|
+
allowedImageDigests: [a.imageDigest],
|
|
4875
|
+
containerEnv: { TH_CLIENT_CERT: a.tls.metadataValue }
|
|
4876
|
+
}, { fetchJwks: () => Promise.resolve(jwks), now: io.now() });
|
|
4877
|
+
if (!verdict.ok) {
|
|
4878
|
+
throw new HostedVenueError(`${vm} presented an attestation this lease refuses (${verdict.reason}): ${verdict.detail}`);
|
|
4879
|
+
}
|
|
4880
|
+
const caPath = join11(a.tls.dir, "venue.pem");
|
|
4881
|
+
try {
|
|
4882
|
+
io.writeFile(caPath, venuePem);
|
|
4883
|
+
} catch (err) {
|
|
4884
|
+
throw new HostedVenueError(`could not write ${vm}'s attested certificate to ${caPath}: ${describeErr(err)}`, "ours");
|
|
4885
|
+
}
|
|
4886
|
+
const dockerAt = `tcp://localhost:${String(dockerPort)}`;
|
|
4887
|
+
const docker3 = io.dockerOverTls(dockerAt, { caPath, certPath: a.tls.certPath, keyPath: a.tls.keyPath }, CS_RUN_ARGS);
|
|
4888
|
+
const daemon = io.classifyDaemon(docker3);
|
|
4889
|
+
if (!daemon.distinct) {
|
|
4890
|
+
throw new HostedVenueError(`refusing to hand out a hosted lease: ${describeVenueDaemon(daemon)}`);
|
|
4891
|
+
}
|
|
4892
|
+
const prepared = docker3.sync(csStagePrepareArgv(a.stageBase, CS_GUEST_USER), {
|
|
4893
|
+
timeoutMs: MKDIR_TIMEOUT_MS
|
|
4894
|
+
});
|
|
4895
|
+
if (prepared.error || prepared.status !== 0) {
|
|
4896
|
+
throw new HostedVenueError(`could not prepare the stage on ${vm}: ${dockerDetail(prepared).slice(0, 300)}`);
|
|
4897
|
+
}
|
|
4898
|
+
const pulled = docker3.sync(["pull", DEFAULT_CONTAINER_IMAGE], { timeoutMs: CS_PULL_TIMEOUT_MS });
|
|
4899
|
+
if (pulled.error || pulled.status !== 0) {
|
|
4900
|
+
throw new HostedVenueError(`could not pull ${DEFAULT_CONTAINER_IMAGE} on ${vm}: ${dockerDetail(pulled).slice(0, 300)}`);
|
|
4901
|
+
}
|
|
4902
|
+
return {
|
|
4903
|
+
docker: docker3,
|
|
4904
|
+
tunnel,
|
|
4905
|
+
socketDir: a.tls.dir,
|
|
4906
|
+
socketPath: dockerAt,
|
|
4907
|
+
venueDaemonId: daemon.venueDaemonId,
|
|
4908
|
+
venueIdentity: { kind: "confidential-space", token: reading.token, claims: verdict.value },
|
|
4909
|
+
transport: dockerStageTransport(docker3, a.env, io)
|
|
4910
|
+
};
|
|
4911
|
+
}
|
|
4265
4912
|
function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
|
|
4266
4913
|
const project = opts.project ?? DEFAULT_GCP_PROJECT;
|
|
4267
4914
|
const zone = opts.zone ?? DEFAULT_GCP_ZONE;
|
|
4268
4915
|
const gcloudConfig = opts.gcloudConfig ?? VENUE_GCLOUD_CONFIG;
|
|
4269
4916
|
const keyFile = resolveServiceAccountKeyFile(opts);
|
|
4270
4917
|
const image = venueImageFromEnv(process.env);
|
|
4918
|
+
const csImageDigest = image.venueImage === "confidential-space" ? requireWorkloadDigest(image.workloadImage) : null;
|
|
4271
4919
|
const env = venueGcloudEnv(gcloudConfig);
|
|
4272
4920
|
return {
|
|
4273
4921
|
kind: "hosted-pool",
|
|
4274
4922
|
acquire: (runId) => acquireTransactionally(async (allocated) => {
|
|
4275
4923
|
const vm = venueInstanceName(runId);
|
|
4276
4924
|
const stageBase = `/tmp/th-stage-${runId}`;
|
|
4277
|
-
const
|
|
4925
|
+
const bootParams = (clientCert) => image.venueImage === "confidential-space" ? {
|
|
4278
4926
|
vmName: vm,
|
|
4279
4927
|
project,
|
|
4280
4928
|
zone,
|
|
4281
4929
|
runId,
|
|
4282
|
-
machineType: opts.machineType ??
|
|
4930
|
+
machineType: opts.machineType ?? CONFIDENTIAL_SPACE_MACHINE_TYPE,
|
|
4931
|
+
venueImage: "confidential-space",
|
|
4932
|
+
workloadImage: image.workloadImage,
|
|
4933
|
+
clientCert
|
|
4934
|
+
} : {
|
|
4935
|
+
vmName: vm,
|
|
4936
|
+
project,
|
|
4937
|
+
zone,
|
|
4938
|
+
runId,
|
|
4939
|
+
machineType: opts.machineType ?? DEFAULT_GCP_MACHINE_TYPE,
|
|
4283
4940
|
...image
|
|
4284
|
-
}
|
|
4941
|
+
};
|
|
4942
|
+
let bootArgv = gcpBootArgv(bootParams(CLIENT_CERT_STAND_IN));
|
|
4285
4943
|
ensureVenueServiceCredentials(gcloudConfig, keyFile, io);
|
|
4944
|
+
const clientTls = csImageDigest === null ? null : { ...makeClientTls(io, allocated, vm), imageDigest: csImageDigest };
|
|
4945
|
+
if (clientTls !== null)
|
|
4946
|
+
bootArgv = gcpBootArgv(bootParams(clientTls.metadataValue));
|
|
4286
4947
|
const boot = io.exec("gcloud", bootArgv, BOOT_TIMEOUT_MS, env);
|
|
4287
4948
|
let instanceState = "unknown";
|
|
4288
4949
|
allocated.onRollback(() => {
|
|
@@ -4298,6 +4959,20 @@ function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
|
|
|
4298
4959
|
throw new HostedVenueError(`could not boot ${vm}: ${verdict2.reason.slice(0, 400)}`, verdict2.source);
|
|
4299
4960
|
}
|
|
4300
4961
|
instanceState = "exists";
|
|
4962
|
+
if (clientTls !== null) {
|
|
4963
|
+
return finishAcquire(await reachConfidentialSpace({
|
|
4964
|
+
vm,
|
|
4965
|
+
project,
|
|
4966
|
+
zone,
|
|
4967
|
+
runId,
|
|
4968
|
+
stageBase,
|
|
4969
|
+
env,
|
|
4970
|
+
io,
|
|
4971
|
+
allocated,
|
|
4972
|
+
tls: clientTls,
|
|
4973
|
+
imageDigest: clientTls.imageDigest
|
|
4974
|
+
}));
|
|
4975
|
+
}
|
|
4301
4976
|
const deadline = io.now() + SSH_READY_BUDGET_MS;
|
|
4302
4977
|
let last = { retryable: true, reason: "no probe ran" };
|
|
4303
4978
|
let identity = null;
|
|
@@ -4392,43 +5067,56 @@ function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
|
|
|
4392
5067
|
if (!prepared.ok) {
|
|
4393
5068
|
throw new HostedVenueError(`could not prepare the stage on ${vm}: ${execDetail(prepared).slice(0, 300)}`);
|
|
4394
5069
|
}
|
|
4395
|
-
|
|
4396
|
-
for (const name of [stageVolumes.clone, stageVolumes.jail]) {
|
|
4397
|
-
const created = docker3.sync(volumeCreateArgv(name, runId), {
|
|
4398
|
-
timeoutMs: VOLUME_CREATE_TIMEOUT_MS
|
|
4399
|
-
});
|
|
4400
|
-
if (created.error || created.status !== 0) {
|
|
4401
|
-
throw new HostedVenueError(`could not create the stage volume ${name} on ${vm}: ${dockerDetail(created).slice(0, 300)}`);
|
|
4402
|
-
}
|
|
4403
|
-
}
|
|
4404
|
-
for (const name of [stageVolumes.clone, stageVolumes.jail]) {
|
|
4405
|
-
const probe = docker3.sync(execProbeArgv(name), {
|
|
4406
|
-
timeoutMs: EXEC_PROBE_TIMEOUT_MS
|
|
4407
|
-
});
|
|
4408
|
-
if (probe.error || probe.status !== 0) {
|
|
4409
|
-
throw new HostedVenueError(`${vm} cannot execute a file written to its own Docker volume ${name}, so no install step with a native postinstall could run there. Refusing before staging anything: ${dockerDetail(probe).slice(0, 300)}`);
|
|
4410
|
-
}
|
|
4411
|
-
}
|
|
4412
|
-
const after = io.classifyDaemon(docker3);
|
|
4413
|
-
if (!after.distinct || after.venueDaemonId !== venueDaemonId) {
|
|
4414
|
-
throw new HostedVenueError(`the daemon at ${socketPath} changed while the stage volumes were being created and proved: it now answers as ${after.distinct ? `daemon ${after.venueDaemonId}` : describeVenueDaemon(after)}, not ${venueDaemonId}. Refusing to hand out a lease whose proof was made elsewhere.`);
|
|
4415
|
-
}
|
|
4416
|
-
return makeLease({
|
|
4417
|
-
runId,
|
|
4418
|
-
vm,
|
|
4419
|
-
project,
|
|
4420
|
-
zone,
|
|
4421
|
-
stageBase,
|
|
4422
|
-
stageVolumes,
|
|
5070
|
+
return finishAcquire({
|
|
4423
5071
|
docker: docker3,
|
|
4424
5072
|
tunnel,
|
|
4425
5073
|
socketDir,
|
|
4426
5074
|
socketPath,
|
|
4427
5075
|
venueDaemonId,
|
|
4428
5076
|
venueIdentity,
|
|
4429
|
-
env,
|
|
4430
|
-
io
|
|
5077
|
+
transport: sshStageTransport(vm, project, zone, env, io)
|
|
4431
5078
|
});
|
|
5079
|
+
function finishAcquire(r) {
|
|
5080
|
+
const { docker: docker4, socketPath: socketPath2, venueDaemonId: venueDaemonId2 } = r;
|
|
5081
|
+
const stageVolumes = stageVolumeNames(vm);
|
|
5082
|
+
for (const name of [stageVolumes.clone, stageVolumes.jail]) {
|
|
5083
|
+
const created = docker4.sync(volumeCreateArgv(name, runId), {
|
|
5084
|
+
timeoutMs: VOLUME_CREATE_TIMEOUT_MS
|
|
5085
|
+
});
|
|
5086
|
+
if (created.error || created.status !== 0) {
|
|
5087
|
+
throw new HostedVenueError(`could not create the stage volume ${name} on ${vm}: ${dockerDetail(created).slice(0, 300)}`);
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
for (const name of [stageVolumes.clone, stageVolumes.jail]) {
|
|
5091
|
+
const probe = docker4.sync(execProbeArgv(name), {
|
|
5092
|
+
timeoutMs: EXEC_PROBE_TIMEOUT_MS
|
|
5093
|
+
});
|
|
5094
|
+
if (probe.error || probe.status !== 0) {
|
|
5095
|
+
throw new HostedVenueError(`${vm} cannot execute a file written to its own Docker volume ${name}, so no install step with a native postinstall could run there. Refusing before staging anything: ${dockerDetail(probe).slice(0, 300)}`);
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
const after = io.classifyDaemon(docker4);
|
|
5099
|
+
if (!after.distinct || after.venueDaemonId !== venueDaemonId2) {
|
|
5100
|
+
throw new HostedVenueError(`the daemon at ${socketPath2} changed while the stage volumes were being created and proved: it now answers as ${after.distinct ? `daemon ${after.venueDaemonId}` : describeVenueDaemon(after)}, not ${venueDaemonId2}. Refusing to hand out a lease whose proof was made elsewhere.`);
|
|
5101
|
+
}
|
|
5102
|
+
return makeLease({
|
|
5103
|
+
runId,
|
|
5104
|
+
vm,
|
|
5105
|
+
project,
|
|
5106
|
+
zone,
|
|
5107
|
+
stageBase,
|
|
5108
|
+
stageVolumes,
|
|
5109
|
+
docker: docker4,
|
|
5110
|
+
tunnel: r.tunnel,
|
|
5111
|
+
socketDir: r.socketDir,
|
|
5112
|
+
socketPath: socketPath2,
|
|
5113
|
+
venueDaemonId: venueDaemonId2,
|
|
5114
|
+
venueIdentity: r.venueIdentity,
|
|
5115
|
+
transport: r.transport,
|
|
5116
|
+
env,
|
|
5117
|
+
io
|
|
5118
|
+
});
|
|
5119
|
+
}
|
|
4432
5120
|
})
|
|
4433
5121
|
};
|
|
4434
5122
|
}
|
|
@@ -4487,28 +5175,7 @@ function makeLease(p) {
|
|
|
4487
5175
|
return null;
|
|
4488
5176
|
};
|
|
4489
5177
|
let stagedOwner;
|
|
4490
|
-
|
|
4491
|
-
const res = p.io.exec(
|
|
4492
|
-
"gcloud",
|
|
4493
|
-
iapSshArgv(p.vm, p.project, p.zone, "id -u && id -g"),
|
|
4494
|
-
OWNER_PROBE_TIMEOUT_MS,
|
|
4495
|
-
// `p.env`, like every other gcloud call here. TERM-724 made the venue boot under the
|
|
4496
|
-
// SERVICE configuration rather than whoever is at the terminal, and a probe that
|
|
4497
|
-
// omitted this would ssh under the person's own credential — then report an owner
|
|
4498
|
-
// measured against a session the run does not use.
|
|
4499
|
-
p.env
|
|
4500
|
-
);
|
|
4501
|
-
if (!res.ok) {
|
|
4502
|
-
throw new HostedVenueError(`could not read the staged tree's owner on ${p.vm}: ${execDetail(res).slice(0, 300)}. Refusing rather than falling back to this machine\u2019s uid, which would hand the guest an id with no write access to its own clone.`);
|
|
4503
|
-
}
|
|
4504
|
-
const lines = res.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
4505
|
-
const ids2 = lines.slice(-2).map((l) => Number(l));
|
|
4506
|
-
if (ids2.length !== 2 || !ids2.every((n) => Number.isInteger(n) && n > 0)) {
|
|
4507
|
-
throw new HostedVenueError(`the venue ${p.vm} answered 'id -u && id -g' with ${JSON.stringify(res.stdout.slice(0, 120))}, which is not a uid/gid pair. Refusing rather than guessing an owner.`);
|
|
4508
|
-
}
|
|
4509
|
-
return { uid: ids2[0], gid: ids2[1] };
|
|
4510
|
-
};
|
|
4511
|
-
stagedOwner = readStagedOwner();
|
|
5178
|
+
stagedOwner = p.transport.readOwner();
|
|
4512
5179
|
return {
|
|
4513
5180
|
kind: "hosted-pool",
|
|
4514
5181
|
runId: p.runId,
|
|
@@ -4543,7 +5210,7 @@ function makeLease(p) {
|
|
|
4543
5210
|
const dir = venueProxyDir(p.stageBase);
|
|
4544
5211
|
const buffer = stageProxyCode();
|
|
4545
5212
|
try {
|
|
4546
|
-
const res = await p.
|
|
5213
|
+
const res = await p.transport.pushProxy(buffer.dir, dir);
|
|
4547
5214
|
if (!res.ok) {
|
|
4548
5215
|
throw new HostedVenueError(`could not stage the egress proxy onto ${p.vm}: ${execDetail(res).slice(0, 300)}`);
|
|
4549
5216
|
}
|
|
@@ -4553,7 +5220,7 @@ function makeLease(p) {
|
|
|
4553
5220
|
return {
|
|
4554
5221
|
dir,
|
|
4555
5222
|
cleanup: () => {
|
|
4556
|
-
p.
|
|
5223
|
+
p.transport.removeProxy(dir);
|
|
4557
5224
|
}
|
|
4558
5225
|
};
|
|
4559
5226
|
},
|
|
@@ -4561,7 +5228,7 @@ function makeLease(p) {
|
|
|
4561
5228
|
check("staging the tree");
|
|
4562
5229
|
const paths = venueStagePaths(p.stageBase);
|
|
4563
5230
|
const push = async (from, to) => {
|
|
4564
|
-
const res = await p.
|
|
5231
|
+
const res = await p.transport.push(from, to);
|
|
4565
5232
|
if (!res.ok) {
|
|
4566
5233
|
throw new HostedVenueError(`could not stage ${from} onto ${p.vm}: ${execDetail(res).slice(0, 300)}`);
|
|
4567
5234
|
}
|
|
@@ -4719,7 +5386,7 @@ function makeLease(p) {
|
|
|
4719
5386
|
}
|
|
4720
5387
|
};
|
|
4721
5388
|
}
|
|
4722
|
-
var SSH_READY_BUDGET_MS, SSH_PROBE_INTERVAL_MS, SSH_PROBE_TIMEOUT_MS, TUNNEL_BUDGET_MS, TUNNEL_POLL_INTERVAL_MS, GOOGLE_JWKS_URL, JWKS_FETCH_TIMEOUT_MS, CREDENTIAL_QUERY_PARAM, UNDECODABLE, STAGE_PUSH_TIMEOUT_MS, DISPATCHED_PROBE_TIMEOUT_MS, DISPATCHED_STATUS_ARGV, DISPATCHED_GIT_CANDIDATES, PROXY_CLEANUP_TIMEOUT_MS, OWNER_PROBE_TIMEOUT_MS, BOOT_TIMEOUT_MS, MKDIR_TIMEOUT_MS, DELETE_TIMEOUT_MS, LOCAL_GCLOUD_TIMEOUT_MS, SERVICE_ACCOUNT_ACTIVATE_TIMEOUT_MS, SOCKET_DIR_PREFIX, VENUE_SOCKET_NAME, HostedVenueError, VENUE_GCLOUD_CONFIG, SERVICE_ACCOUNT_SUFFIX, GCLOUD_PRINCIPAL_OVERRIDES, defaultHostedVenueIo, VENUE_SSH_USER, GCE_METADATA_IDENTITY_URL, COMPACT_JWT, STAGE_HELPER_IMAGE, VOLUME_CREATE_TIMEOUT_MS, EXEC_PROBE_TIMEOUT_MS, POPULATE_TIMEOUT_MS, STAGE_VOLUME_LABEL_KEY, STAGE_PROOF_PREFIX, IAP_NOT_READY, IAP_BACKEND_UNREACHABLE, IAP_DENIED, TERMINAL_GCP, INSTANCE_NOT_RUNNING, PREEMPTED, HOST_KEY_MISMATCH, SSH_KEY_NOT_READY, DAEMON_NOT_READY, SSH_NOT_ANSWERING;
|
|
5389
|
+
var SSH_READY_BUDGET_MS, SSH_PROBE_INTERVAL_MS, SSH_PROBE_TIMEOUT_MS, TUNNEL_BUDGET_MS, TUNNEL_POLL_INTERVAL_MS, GOOGLE_JWKS_URL, JWKS_FETCH_TIMEOUT_MS, CREDENTIAL_QUERY_PARAM, UNDECODABLE, STAGE_PUSH_TIMEOUT_MS, DISPATCHED_PROBE_TIMEOUT_MS, DISPATCHED_STATUS_ARGV, DISPATCHED_GIT_CANDIDATES, PROXY_CLEANUP_TIMEOUT_MS, OWNER_PROBE_TIMEOUT_MS, BOOT_TIMEOUT_MS, MKDIR_TIMEOUT_MS, DELETE_TIMEOUT_MS, LOCAL_GCLOUD_TIMEOUT_MS, SERVICE_ACCOUNT_ACTIVATE_TIMEOUT_MS, SOCKET_DIR_PREFIX, VENUE_SOCKET_NAME, HostedVenueError, VENUE_GCLOUD_CONFIG, SERVICE_ACCOUNT_SUFFIX, GCLOUD_PRINCIPAL_OVERRIDES, defaultHostedVenueIo, VENUE_SSH_USER, GCE_METADATA_IDENTITY_URL, COMPACT_JWT, STAGE_HELPER_IMAGE, VOLUME_CREATE_TIMEOUT_MS, EXEC_PROBE_TIMEOUT_MS, POPULATE_TIMEOUT_MS, STAGE_VOLUME_LABEL_KEY, STAGE_PROOF_PREFIX, IAP_NOT_READY, IAP_BACKEND_UNREACHABLE, IAP_DENIED, TERMINAL_GCP, INSTANCE_NOT_RUNNING, PREEMPTED, HOST_KEY_MISMATCH, SSH_KEY_NOT_READY, DAEMON_NOT_READY, SSH_NOT_ANSWERING, CS_ATTEST_PORT, CS_DOCKER_PORT, CS_READY_BUDGET_MS, CS_ATTEST_TIMEOUT_MS, CS_PULL_TIMEOUT_MS, CS_ATTEST_INTERVAL_MS, OPENSSL_TIMEOUT_MS, CS_RUN_ARGS, CS_GUEST_USER, CS_STAGE_ROOT, CLIENT_CERT_STAND_IN;
|
|
4723
5390
|
var init_hostedVenue = __esm({
|
|
4724
5391
|
"../../packages/envrun/dist/hostedVenue.js"() {
|
|
4725
5392
|
"use strict";
|
|
@@ -4840,7 +5507,55 @@ var init_hostedVenue = __esm({
|
|
|
4840
5507
|
throw new Error("JWKS fetch returned a body with no keys array");
|
|
4841
5508
|
}
|
|
4842
5509
|
return body;
|
|
4843
|
-
}
|
|
5510
|
+
},
|
|
5511
|
+
fetchConfidentialSpaceJwks: async () => {
|
|
5512
|
+
const getJson = async (url, what) => {
|
|
5513
|
+
const res = await globalThis.fetch(url, {
|
|
5514
|
+
signal: AbortSignal.timeout(JWKS_FETCH_TIMEOUT_MS)
|
|
5515
|
+
});
|
|
5516
|
+
if (!res.ok) {
|
|
5517
|
+
throw new Error(`Confidential Space ${what} returned HTTP ${String(res.status)}`);
|
|
5518
|
+
}
|
|
5519
|
+
return res.json();
|
|
5520
|
+
};
|
|
5521
|
+
const discovery = await getJson(CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL, "discovery");
|
|
5522
|
+
const jwksUri = typeof discovery === "object" && discovery !== null ? discovery.jwks_uri : void 0;
|
|
5523
|
+
if (typeof jwksUri !== "string" || !jwksUri.startsWith("https://")) {
|
|
5524
|
+
throw new Error("Confidential Space discovery names no https jwks_uri");
|
|
5525
|
+
}
|
|
5526
|
+
const body = await getJson(jwksUri, "key set");
|
|
5527
|
+
if (typeof body !== "object" || body === null || !Array.isArray(body.keys)) {
|
|
5528
|
+
throw new Error("Confidential Space key set has no keys array");
|
|
5529
|
+
}
|
|
5530
|
+
return body;
|
|
5531
|
+
},
|
|
5532
|
+
freeLocalPorts: async (n) => {
|
|
5533
|
+
const servers = [];
|
|
5534
|
+
try {
|
|
5535
|
+
for (let i = 0; i < n; i += 1) {
|
|
5536
|
+
const server = createServer();
|
|
5537
|
+
servers.push(server);
|
|
5538
|
+
await new Promise((resolve2, reject) => {
|
|
5539
|
+
server.once("error", reject);
|
|
5540
|
+
server.listen(0, "localhost", () => {
|
|
5541
|
+
resolve2();
|
|
5542
|
+
});
|
|
5543
|
+
});
|
|
5544
|
+
}
|
|
5545
|
+
return servers.map((s) => s.address().port);
|
|
5546
|
+
} finally {
|
|
5547
|
+
await Promise.all(servers.map((s) => new Promise((resolve2) => {
|
|
5548
|
+
s.close(() => {
|
|
5549
|
+
resolve2();
|
|
5550
|
+
});
|
|
5551
|
+
})));
|
|
5552
|
+
}
|
|
5553
|
+
},
|
|
5554
|
+
writeFile: (path, text) => {
|
|
5555
|
+
writeFileSync6(path, text, { mode: 384, flag: "wx" });
|
|
5556
|
+
},
|
|
5557
|
+
fetchAttestation: (req) => fetchAttestationOverTls(req),
|
|
5558
|
+
dockerOverTls: (endpoint, tls, runArgs) => remoteDockerClient(endpoint, { tls, runArgs })
|
|
4844
5559
|
};
|
|
4845
5560
|
VENUE_SSH_USER = "th-runner";
|
|
4846
5561
|
GCE_METADATA_IDENTITY_URL = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity";
|
|
@@ -4861,6 +5576,17 @@ var init_hostedVenue = __esm({
|
|
|
4861
5576
|
SSH_KEY_NOT_READY = /Permission denied \(publickey/;
|
|
4862
5577
|
DAEMON_NOT_READY = /Cannot connect to the Docker daemon/;
|
|
4863
5578
|
SSH_NOT_ANSWERING = /Connection refused|Connection reset|Connection closed by|kex_exchange_identification|Operation timed out/;
|
|
5579
|
+
CS_ATTEST_PORT = 8443;
|
|
5580
|
+
CS_DOCKER_PORT = 2376;
|
|
5581
|
+
CS_READY_BUDGET_MS = 48e4;
|
|
5582
|
+
CS_ATTEST_TIMEOUT_MS = 15e3;
|
|
5583
|
+
CS_PULL_TIMEOUT_MS = 6e5;
|
|
5584
|
+
CS_ATTEST_INTERVAL_MS = 5e3;
|
|
5585
|
+
OPENSSL_TIMEOUT_MS = 3e4;
|
|
5586
|
+
CS_RUN_ARGS = ["--oom-score-adj", "1000"];
|
|
5587
|
+
CS_GUEST_USER = { uid: 1e3, gid: 1e3 };
|
|
5588
|
+
CS_STAGE_ROOT = "/tmp";
|
|
5589
|
+
CLIENT_CERT_STAND_IN = "stand-in-certificate";
|
|
4864
5590
|
}
|
|
4865
5591
|
});
|
|
4866
5592
|
|