@umbra-privacy/qos-ceremony 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist-cli/cli.js +118 -28
  2. package/package.json +2 -2
package/dist-cli/cli.js CHANGED
@@ -31567,7 +31567,7 @@ var require_dist_cjs16 = __commonJS({
31567
31567
  };
31568
31568
  var DescribeTargetGroupsCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetGroups", DescribeTargetGroups$) {
31569
31569
  };
31570
- var DescribeTargetHealthCommand = class extends command5(_ep05, _mw05, "DescribeTargetHealth", DescribeTargetHealth$) {
31570
+ var DescribeTargetHealthCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetHealth", DescribeTargetHealth$) {
31571
31571
  };
31572
31572
  var DescribeTrustStoreAssociationsCommand = class extends command5(_ep05, _mw05, "DescribeTrustStoreAssociations", DescribeTrustStoreAssociations$) {
31573
31573
  };
@@ -31743,7 +31743,7 @@ var require_dist_cjs16 = __commonJS({
31743
31743
  var checkState$1 = async (client, input) => {
31744
31744
  let reason;
31745
31745
  try {
31746
- let result = await client.send(new DescribeTargetHealthCommand(input));
31746
+ let result = await client.send(new DescribeTargetHealthCommand2(input));
31747
31747
  reason = result;
31748
31748
  try {
31749
31749
  const returnComparator = () => {
@@ -31782,7 +31782,7 @@ var require_dist_cjs16 = __commonJS({
31782
31782
  var checkState = async (client, input) => {
31783
31783
  let reason;
31784
31784
  try {
31785
- let result = await client.send(new DescribeTargetHealthCommand(input));
31785
+ let result = await client.send(new DescribeTargetHealthCommand2(input));
31786
31786
  reason = result;
31787
31787
  try {
31788
31788
  const returnComparator = () => {
@@ -31846,7 +31846,7 @@ var require_dist_cjs16 = __commonJS({
31846
31846
  DescribeTagsCommand: DescribeTagsCommand2,
31847
31847
  DescribeTargetGroupAttributesCommand,
31848
31848
  DescribeTargetGroupsCommand: DescribeTargetGroupsCommand2,
31849
- DescribeTargetHealthCommand,
31849
+ DescribeTargetHealthCommand: DescribeTargetHealthCommand2,
31850
31850
  DescribeTrustStoreAssociationsCommand,
31851
31851
  DescribeTrustStoreRevocationsCommand,
31852
31852
  DescribeTrustStoresCommand,
@@ -32185,7 +32185,7 @@ var require_dist_cjs16 = __commonJS({
32185
32185
  exports2.DescribeTargetGroupsInput$ = DescribeTargetGroupsInput$;
32186
32186
  exports2.DescribeTargetGroupsOutput$ = DescribeTargetGroupsOutput$;
32187
32187
  exports2.DescribeTargetHealth$ = DescribeTargetHealth$;
32188
- exports2.DescribeTargetHealthCommand = DescribeTargetHealthCommand;
32188
+ exports2.DescribeTargetHealthCommand = DescribeTargetHealthCommand2;
32189
32189
  exports2.DescribeTargetHealthInput$ = DescribeTargetHealthInput$;
32190
32190
  exports2.DescribeTargetHealthInputIncludeEnum = DescribeTargetHealthInputIncludeEnum;
32191
32191
  exports2.DescribeTargetHealthOutput$ = DescribeTargetHealthOutput$;
@@ -32823,10 +32823,24 @@ var Workdir = class {
32823
32823
  };
32824
32824
 
32825
32825
  // ../qos-core/src/api.ts
32826
+ function enforceHttps(url) {
32827
+ if (!url) throw new Error("coordinator base URL is required");
32828
+ let u;
32829
+ try {
32830
+ u = new URL(url);
32831
+ } catch {
32832
+ throw new Error(`invalid coordinator base URL: ${url}`);
32833
+ }
32834
+ const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
32835
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
32836
+ throw new Error(`coordinator base URL must use https (got ${u.protocol}//${u.hostname})`);
32837
+ }
32838
+ return url;
32839
+ }
32826
32840
  var CeremonyApi = class {
32827
32841
  constructor(baseUrl) {
32828
32842
  this.baseUrl = baseUrl;
32829
- this.baseUrl = baseUrl.replace(/\/$/, "");
32843
+ this.baseUrl = enforceHttps(baseUrl.replace(/\/$/, ""));
32830
32844
  }
32831
32845
  base(ns, nonce) {
32832
32846
  return `${this.baseUrl}/ceremony/${encodeURIComponent(ns)}/${nonce}`;
@@ -32843,6 +32857,13 @@ var CeremonyApi = class {
32843
32857
  if (!r5.ok) throw new Error(`getArtifact ${path}: ${r5.status} ${await r5.text()}`);
32844
32858
  return Buffer.from(await r5.text(), "base64");
32845
32859
  }
32860
+ /** Fetch a namespace-shared artifact (`quorum_key.pub`, `shares/<alias>.share`) — nonce-free,
32861
+ * invariant across a service's colors. Same base64 encoding as getArtifact. */
32862
+ async getSharedArtifact(ns, path) {
32863
+ const r5 = await fetch(`${this.baseUrl}/shared/${encodeURIComponent(ns)}/${path}`);
32864
+ if (!r5.ok) throw new Error(`getSharedArtifact ${path}: ${r5.status} ${await r5.text()}`);
32865
+ return Buffer.from(await r5.text(), "base64");
32866
+ }
32846
32867
  /** Registered operator pubkeys (alias → hex) for the ceremony's service. The
32847
32868
  * registry is the source of member pubkeys (no per-ceremony upload). */
32848
32869
  async getMembers(ns, nonce) {
@@ -32966,9 +32987,19 @@ function resolveQosBin(explicit) {
32966
32987
  if (explicit) return explicit;
32967
32988
  if (process.env.QOS_CLIENT_BIN) return process.env.QOS_CLIENT_BIN;
32968
32989
  const embedded = (0, import_node_path2.join)(__dirname, "bin", `qos_client-${process.platform}-${process.arch}`);
32969
- if ((0, import_node_fs2.existsSync)(embedded)) return embedded;
32990
+ if ((0, import_node_fs2.existsSync)(embedded)) {
32991
+ ensureExecutable(embedded);
32992
+ return embedded;
32993
+ }
32970
32994
  return void 0;
32971
32995
  }
32996
+ function ensureExecutable(path) {
32997
+ try {
32998
+ const mode = (0, import_node_fs2.statSync)(path).mode;
32999
+ if ((mode & 73) !== 73) (0, import_node_fs2.chmodSync)(path, mode | 493);
33000
+ } catch {
33001
+ }
33002
+ }
32972
33003
 
32973
33004
  // src/commands/keygen.ts
32974
33005
  async function keygen(o3) {
@@ -33031,15 +33062,28 @@ function resolve(o3) {
33031
33062
 
33032
33063
  // src/artifacts.ts
33033
33064
  var import_node_fs4 = require("node:fs");
33065
+ var SAFE_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
33066
+ function assertSafeAlias(alias) {
33067
+ if (!SAFE_ALIAS.test(alias)) {
33068
+ throw new Error(
33069
+ `refusing coordinator-supplied member alias '${alias}' \u2014 not a safe filename (expected ${SAFE_ALIAS}); a malicious coordinator must not drive a local path`
33070
+ );
33071
+ }
33072
+ }
33034
33073
  async function pullArtifact(ctx, wd, apiPath, localRel) {
33035
33074
  const bytes = await ctx.api.getArtifact(ctx.namespace, ctx.nonce, apiPath);
33036
33075
  return wd.writeBytes(localRel, bytes);
33037
33076
  }
33077
+ async function pullShared(ctx, wd, sharedPath, localRel) {
33078
+ const bytes = await ctx.api.getSharedArtifact(ctx.namespace, sharedPath);
33079
+ return wd.writeBytes(localRel, bytes);
33080
+ }
33038
33081
  async function buildSetDirs(ctx, wd, sets) {
33039
33082
  const members = await ctx.api.getMembers(ctx.namespace, ctx.nonce);
33040
33083
  const build = (name, set) => {
33041
33084
  const dir = wd.dir(name);
33042
33085
  for (const alias of set.members) {
33086
+ assertSafeAlias(alias);
33043
33087
  const hex = members[alias];
33044
33088
  if (!hex)
33045
33089
  throw new Error(`operator '${alias}' has no registered pubkey (admin register-operator)`);
@@ -33057,7 +33101,7 @@ async function buildSetDirs(ctx, wd, sets) {
33057
33101
  async function pullGenesisDir(ctx, wd) {
33058
33102
  const dir = wd.dir("namespace");
33059
33103
  await pullArtifact(ctx, wd, "genesis/genesis_output", "namespace/genesis_output");
33060
- await pullArtifact(ctx, wd, "genesis/quorum_key.pub", "namespace/quorum_key.pub").catch(() => "");
33104
+ await pullShared(ctx, wd, "quorum_key.pub", "namespace/quorum_key.pub").catch(() => "");
33061
33105
  await pullArtifact(
33062
33106
  ctx,
33063
33107
  wd,
@@ -33107,7 +33151,7 @@ async function approve(o3) {
33107
33151
  try {
33108
33152
  const manifestPath = await pullArtifact(ctx, wd, "manifest/manifest", "manifest");
33109
33153
  const pivotHashPath = await pullArtifact(ctx, wd, "manifest/pivot.hash", "pivot.hash");
33110
- const quorumKeyPath = await pullArtifact(ctx, wd, "genesis/quorum_key.pub", "quorum_key.pub");
33154
+ const quorumKeyPath = await pullShared(ctx, wd, "quorum_key.pub", "quorum_key.pub");
33111
33155
  const { manifestSetDir, shareSetDir, patchSetDir } = await buildSetDirs(ctx, wd, state2.sets);
33112
33156
  const approvalsDir = wd.dir("approvals");
33113
33157
  const json = ctx.qos.display(manifestPath, "manifest");
@@ -33175,7 +33219,7 @@ async function provision(o3) {
33175
33219
  "manifest/manifest_envelope",
33176
33220
  "manifest_envelope"
33177
33221
  );
33178
- const sharePath = await pullArtifact(ctx, wd, `shares/${ctx.alias}.share`, `${ctx.alias}.share`);
33222
+ const sharePath = await pullShared(ctx, wd, `shares/${ctx.alias}.share`, `${ctx.alias}.share`);
33179
33223
  const { manifestSetDir } = await buildSetDirs(ctx, wd, state2.sets);
33180
33224
  const ephWrappedSharePath = wd.path(`${ctx.alias}.eph_share`);
33181
33225
  const approvalPath = wd.path(`${ctx.alias}.attestation.approval`);
@@ -33237,14 +33281,14 @@ function buildWeightedForward(tgs, weights) {
33237
33281
  }
33238
33282
  }
33239
33283
  if (tgs.length === 0) throw new Error("shift-traffic: no target groups to forward to");
33240
- return [
33241
- {
33242
- Type: "forward",
33243
- ForwardConfig: {
33244
- TargetGroups: tgs.map((t) => ({ TargetGroupArn: t.arn, Weight: weights[t.nonce] ?? 0 }))
33245
- }
33246
- }
33247
- ];
33284
+ const resolved = tgs.map((t) => ({ TargetGroupArn: t.arn, Weight: weights[t.nonce] ?? 0 }));
33285
+ const total = resolved.reduce((s, t) => s + (t.Weight ?? 0), 0);
33286
+ if (total <= 0) {
33287
+ throw new Error(
33288
+ "shift-traffic: refusing \u2014 the resulting target-group weights sum to 0, which black-holes ALL client traffic (503). Give at least one color a positive weight."
33289
+ );
33290
+ }
33291
+ return [{ Type: "forward", ForwardConfig: { TargetGroups: resolved } }];
33248
33292
  }
33249
33293
  async function discoverColorTargetGroups(elbv2, service, stack, role) {
33250
33294
  const all = [];
@@ -33286,13 +33330,22 @@ async function discoverColorTargetGroups(elbv2, service, stack, role) {
33286
33330
  }
33287
33331
  async function findListenerForTargetGroups(elbv2, lbArn, tgArns) {
33288
33332
  const r5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeListenersCommand({ LoadBalancerArn: lbArn }));
33333
+ const matches = [];
33289
33334
  for (const l3 of r5.Listeners ?? []) {
33290
33335
  const forwards = (l3.DefaultActions ?? []).flatMap((a5) => a5.ForwardConfig?.TargetGroups ?? []);
33291
- if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn))) {
33292
- if (l3.ListenerArn) return l3.ListenerArn;
33336
+ if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn)) && l3.ListenerArn) {
33337
+ matches.push(l3.ListenerArn);
33293
33338
  }
33294
33339
  }
33295
- throw new Error("shift-traffic: no listener forwards to the discovered target groups");
33340
+ if (matches.length === 0) {
33341
+ throw new Error("shift-traffic: no listener forwards to the discovered target groups");
33342
+ }
33343
+ if (matches.length > 1) {
33344
+ throw new Error(
33345
+ `shift-traffic: ${matches.length} listeners forward to these target groups \u2014 refusing to guess which to modify`
33346
+ );
33347
+ }
33348
+ return matches[0];
33296
33349
  }
33297
33350
  async function shiftTraffic(opts) {
33298
33351
  const role = opts.listener ?? "app";
@@ -33304,8 +33357,25 @@ async function shiftTraffic(opts) {
33304
33357
  new Set(tgs.map((t) => t.arn))
33305
33358
  );
33306
33359
  const actions = buildWeightedForward(tgs, opts.weights);
33360
+ if (opts.dryRun) return { listenerArn, actions, applied: false };
33361
+ if (!opts.force) {
33362
+ const noHealthy = [];
33363
+ for (const t of tgs) {
33364
+ if ((opts.weights[t.nonce] ?? 0) <= 0) continue;
33365
+ const h5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeTargetHealthCommand({ TargetGroupArn: t.arn }));
33366
+ const healthy = (h5.TargetHealthDescriptions ?? []).filter(
33367
+ (d5) => d5.TargetHealth?.State === "healthy"
33368
+ ).length;
33369
+ if (healthy === 0) noHealthy.push(t.nonce);
33370
+ }
33371
+ if (noHealthy.length > 0) {
33372
+ throw new Error(
33373
+ `shift-traffic: refusing cutover \u2014 color(s) [${noHealthy.join(", ")}] have 0 healthy targets; routing traffic to them would 503 every client. Wait for health, or pass --force.`
33374
+ );
33375
+ }
33376
+ }
33307
33377
  await elbv2.send(new import_client_elastic_load_balancing_v2.ModifyListenerCommand({ ListenerArn: listenerArn, DefaultActions: actions }));
33308
- return { listenerArn, actions };
33378
+ return { listenerArn, actions, applied: true };
33309
33379
  }
33310
33380
  async function shiftTrafficCommand(o3) {
33311
33381
  const service = o3.service ?? process.env.CEREMONY_SERVICE;
@@ -33316,16 +33386,36 @@ async function shiftTrafficCommand(o3) {
33316
33386
  const weights = {};
33317
33387
  for (const [k5, v] of Object.entries(parsed)) weights[Number(k5)] = v;
33318
33388
  const listener = o3.listener ?? "app";
33319
- const { listenerArn, actions } = await shiftTraffic({
33389
+ const plan = await shiftTraffic({
33390
+ service,
33391
+ stack,
33392
+ region: o3.region,
33393
+ listener,
33394
+ weights,
33395
+ dryRun: true
33396
+ });
33397
+ const summary = (plan.actions[0].ForwardConfig?.TargetGroups ?? []).map((t) => `${t.TargetGroupArn?.split("/").slice(-2, -1)[0] ?? t.TargetGroupArn}=${t.Weight}`).join(" ");
33398
+ console.log(`shift-traffic PLAN: ${service}/${stack} ${listener} listener \u2192 ${summary}`);
33399
+ console.log(` listener: ${plan.listenerArn}`);
33400
+ if (o3.dryRun) {
33401
+ console.log(" (dry-run \u2014 no change applied)");
33402
+ return;
33403
+ }
33404
+ if (!o3.yes) {
33405
+ throw new Error(
33406
+ "shift-traffic: refusing to apply a live traffic cutover without --yes (review the PLAN above, then re-run with --yes; or use --dry-run)"
33407
+ );
33408
+ }
33409
+ const applied = await shiftTraffic({
33320
33410
  service,
33321
33411
  stack,
33322
33412
  region: o3.region,
33323
33413
  listener,
33324
- weights
33414
+ weights,
33415
+ force: o3.force
33325
33416
  });
33326
- const summary = (actions[0].ForwardConfig?.TargetGroups ?? []).map((t) => `${t.TargetGroupArn?.split("/").slice(-2, -1)[0] ?? t.TargetGroupArn}=${t.Weight}`).join(" ");
33327
- console.log(`shift-traffic: ${service}/${stack} ${listener} listener \u2192 ${summary}`);
33328
- console.log(` listener: ${listenerArn}`);
33417
+ console.log(`shift-traffic APPLIED \u2192 ${summary}`);
33418
+ console.log(` listener: ${applied.listenerArn}`);
33329
33419
  }
33330
33420
 
33331
33421
  // src/cli.ts
@@ -33360,7 +33450,7 @@ withCommon(
33360
33450
  program2.command("status").description("show ceremony phase + blockers (read-only)").option("--api <url>", "coordinator API base URL (env CEREMONY_API)").option("--namespace <ns>", "ceremony namespace (env CEREMONY_NAMESPACE)").option("--nonce <n>", "manifest nonce (env CEREMONY_NONCE)").action(run(status));
33361
33451
  program2.command("shift-traffic").description(
33362
33452
  "[ops] blue-green weighted traffic cutover across a service's colors (needs AWS creds)"
33363
- ).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).action(run(shiftTrafficCommand));
33453
+ ).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).option("--dry-run", "print the planned weighted forward and exit without applying").option("--yes", "confirm applying the live traffic cutover (required unless --dry-run)").option("--force", "skip the target-health precheck (allow shifting to a 0-healthy color)").action(run(shiftTrafficCommand));
33364
33454
  program2.parseAsync(process.argv).catch((e5) => {
33365
33455
  console.error(e5);
33366
33456
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umbra-privacy/qos-ceremony",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "QOS TEE operator ceremony CLI — keygen/share/approve/provision + shift-traffic over the coordinator API. All crypto runs in qos_client (embedded for common platforms); your private key never leaves the device. Ships NO coordinator code.",
5
5
  "keywords": [
6
6
  "qos",
@@ -44,6 +44,6 @@
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
45
  "test": "vitest run",
46
46
  "bundle:cli": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist-cli/cli.js && chmod +x dist-cli/cli.js",
47
- "embed:qos": "mkdir -p dist-cli/bin && ( [ -f ../../../qos/target/release/qos_client ] && cp -L ../../../qos/target/release/qos_client dist-cli/bin/qos_client-darwin-arm64 || true ) && ( [ -f ../ceremony/dist-lambda/qos_client ] && cp -L ../ceremony/dist-lambda/qos_client dist-cli/bin/qos_client-linux-x64 || true ) && ls -la dist-cli/bin"
47
+ "embed:qos": "mkdir -p dist-cli/bin && ( [ -f ../../../qos/target/release/qos_client ] && cp -L ../../../qos/target/release/qos_client dist-cli/bin/qos_client-darwin-arm64 || true ) && ( [ -f ../ceremony/dist-lambda/qos_client ] && cp -L ../ceremony/dist-lambda/qos_client dist-cli/bin/qos_client-linux-x64 || true ) && ( chmod +x dist-cli/bin/* 2>/dev/null || true ) && ls -la dist-cli/bin"
48
48
  }
49
49
  }