@umbra-privacy/qos-ceremony 0.1.2 → 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 +107 -27
  2. package/package.json +1 -1
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) {
@@ -33041,15 +33062,28 @@ function resolve(o3) {
33041
33062
 
33042
33063
  // src/artifacts.ts
33043
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
+ }
33044
33073
  async function pullArtifact(ctx, wd, apiPath, localRel) {
33045
33074
  const bytes = await ctx.api.getArtifact(ctx.namespace, ctx.nonce, apiPath);
33046
33075
  return wd.writeBytes(localRel, bytes);
33047
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
+ }
33048
33081
  async function buildSetDirs(ctx, wd, sets) {
33049
33082
  const members = await ctx.api.getMembers(ctx.namespace, ctx.nonce);
33050
33083
  const build = (name, set) => {
33051
33084
  const dir = wd.dir(name);
33052
33085
  for (const alias of set.members) {
33086
+ assertSafeAlias(alias);
33053
33087
  const hex = members[alias];
33054
33088
  if (!hex)
33055
33089
  throw new Error(`operator '${alias}' has no registered pubkey (admin register-operator)`);
@@ -33067,7 +33101,7 @@ async function buildSetDirs(ctx, wd, sets) {
33067
33101
  async function pullGenesisDir(ctx, wd) {
33068
33102
  const dir = wd.dir("namespace");
33069
33103
  await pullArtifact(ctx, wd, "genesis/genesis_output", "namespace/genesis_output");
33070
- 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(() => "");
33071
33105
  await pullArtifact(
33072
33106
  ctx,
33073
33107
  wd,
@@ -33117,7 +33151,7 @@ async function approve(o3) {
33117
33151
  try {
33118
33152
  const manifestPath = await pullArtifact(ctx, wd, "manifest/manifest", "manifest");
33119
33153
  const pivotHashPath = await pullArtifact(ctx, wd, "manifest/pivot.hash", "pivot.hash");
33120
- 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");
33121
33155
  const { manifestSetDir, shareSetDir, patchSetDir } = await buildSetDirs(ctx, wd, state2.sets);
33122
33156
  const approvalsDir = wd.dir("approvals");
33123
33157
  const json = ctx.qos.display(manifestPath, "manifest");
@@ -33185,7 +33219,7 @@ async function provision(o3) {
33185
33219
  "manifest/manifest_envelope",
33186
33220
  "manifest_envelope"
33187
33221
  );
33188
- 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`);
33189
33223
  const { manifestSetDir } = await buildSetDirs(ctx, wd, state2.sets);
33190
33224
  const ephWrappedSharePath = wd.path(`${ctx.alias}.eph_share`);
33191
33225
  const approvalPath = wd.path(`${ctx.alias}.attestation.approval`);
@@ -33247,14 +33281,14 @@ function buildWeightedForward(tgs, weights) {
33247
33281
  }
33248
33282
  }
33249
33283
  if (tgs.length === 0) throw new Error("shift-traffic: no target groups to forward to");
33250
- return [
33251
- {
33252
- Type: "forward",
33253
- ForwardConfig: {
33254
- TargetGroups: tgs.map((t) => ({ TargetGroupArn: t.arn, Weight: weights[t.nonce] ?? 0 }))
33255
- }
33256
- }
33257
- ];
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 } }];
33258
33292
  }
33259
33293
  async function discoverColorTargetGroups(elbv2, service, stack, role) {
33260
33294
  const all = [];
@@ -33296,13 +33330,22 @@ async function discoverColorTargetGroups(elbv2, service, stack, role) {
33296
33330
  }
33297
33331
  async function findListenerForTargetGroups(elbv2, lbArn, tgArns) {
33298
33332
  const r5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeListenersCommand({ LoadBalancerArn: lbArn }));
33333
+ const matches = [];
33299
33334
  for (const l3 of r5.Listeners ?? []) {
33300
33335
  const forwards = (l3.DefaultActions ?? []).flatMap((a5) => a5.ForwardConfig?.TargetGroups ?? []);
33301
- if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn))) {
33302
- if (l3.ListenerArn) return l3.ListenerArn;
33336
+ if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn)) && l3.ListenerArn) {
33337
+ matches.push(l3.ListenerArn);
33303
33338
  }
33304
33339
  }
33305
- 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];
33306
33349
  }
33307
33350
  async function shiftTraffic(opts) {
33308
33351
  const role = opts.listener ?? "app";
@@ -33314,8 +33357,25 @@ async function shiftTraffic(opts) {
33314
33357
  new Set(tgs.map((t) => t.arn))
33315
33358
  );
33316
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
+ }
33317
33377
  await elbv2.send(new import_client_elastic_load_balancing_v2.ModifyListenerCommand({ ListenerArn: listenerArn, DefaultActions: actions }));
33318
- return { listenerArn, actions };
33378
+ return { listenerArn, actions, applied: true };
33319
33379
  }
33320
33380
  async function shiftTrafficCommand(o3) {
33321
33381
  const service = o3.service ?? process.env.CEREMONY_SERVICE;
@@ -33326,16 +33386,36 @@ async function shiftTrafficCommand(o3) {
33326
33386
  const weights = {};
33327
33387
  for (const [k5, v] of Object.entries(parsed)) weights[Number(k5)] = v;
33328
33388
  const listener = o3.listener ?? "app";
33329
- 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({
33330
33410
  service,
33331
33411
  stack,
33332
33412
  region: o3.region,
33333
33413
  listener,
33334
- weights
33414
+ weights,
33415
+ force: o3.force
33335
33416
  });
33336
- const summary = (actions[0].ForwardConfig?.TargetGroups ?? []).map((t) => `${t.TargetGroupArn?.split("/").slice(-2, -1)[0] ?? t.TargetGroupArn}=${t.Weight}`).join(" ");
33337
- console.log(`shift-traffic: ${service}/${stack} ${listener} listener \u2192 ${summary}`);
33338
- console.log(` listener: ${listenerArn}`);
33417
+ console.log(`shift-traffic APPLIED \u2192 ${summary}`);
33418
+ console.log(` listener: ${applied.listenerArn}`);
33339
33419
  }
33340
33420
 
33341
33421
  // src/cli.ts
@@ -33370,7 +33450,7 @@ withCommon(
33370
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));
33371
33451
  program2.command("shift-traffic").description(
33372
33452
  "[ops] blue-green weighted traffic cutover across a service's colors (needs AWS creds)"
33373
- ).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));
33374
33454
  program2.parseAsync(process.argv).catch((e5) => {
33375
33455
  console.error(e5);
33376
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.2",
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",