Skip to content

vsync v0.10.0 — Spec

Status: design · target package @muthuishere/vsync · wire-format compatible with 0.9.x · purely additive — two new subcommands.

One theme: mint a bootstrap blob and rotate the passphrase that protects the bundle, so a runtime read library (specced in parallel as v0.12-vsync-s3-client.md) has a stable, scriptable two-input contract — VSYNC_CONFIG (the blob from vsync runtime-token) + VSYNC_PASSPHRASE (rotated by vsync rotate-passphrase). The library reads, vsync writes. Nothing in the existing CLI changes.

For prior context, see v0.2-secret-lib.md (crypto envelope), v0.4-audit-log.md (append protocol — rotate-passphrase reuses it), v0.12-vsync-s3-client.md (the library this spec serves), and v0.11-conformance-test-vectors.md (shared vector format for both writers and readers).


1. Overview

0.9.x0.10.0
Subcommands1012 — adds runtime-token, rotate-passphrase
Bootstrap blob formatn/a (no runtime lib yet)vsync-cfg-v1:<base64-gzip-json> (§4)
Passphrase rotationmanual (re-init + re-push)first-class verb with atomic manifest swap + audit row
Bundle / share / audit / manifest formatsunchangedunchanged (audit gains a new event value, see §3.3)

Every 0.9.x deployment keeps working byte-for-byte. The two new verbs are read-mostly (runtime-token) and rotate-only (rotate-passphrase); existing push/pull/sync flows are untouched.

Decisions locked from the design huddle (referenced where applied):

  • C — bootstrap shape is vsync-cfg-v1:<base64gz>; the runtime needs two inputs (this blob + the passphrase). Never one mega-env-var with the passphrase inside it.
  • Grotate-passphrase is mandatory in v1. rotate-iam-key is out of scope (§5).
  • J — monotonic gen=N counter, source of truth is the manifest meta cell (RQEM0001).
  • I — pre-1.0; no compat shims. Unknown blob prefix / unknown v → fail loud, exit ≠ 0.

2. vsync runtime-token

Mints the VSYNC_CONFIG bootstrap blob the runtime library reads.

2.1 Signature

bash
vsync runtime-token --env=<env>
  [--access-key=<AKIA...>] [--secret-key=<...>]
  [--bucket=<...>] [--endpoint=<...>] [--region=<...>] [--prefix=<...>]
  [--json]
  [--no-validate]
  [--interactive]
  [--repo=<name>]

2.2 Behaviour

  1. Resolve repo (per src/repo.ts::getRepoName) and load the existing per-(repo, env) config via src/envconfig.ts::loadEnvConfig. Config absence → exit 4 with the standard ConfigFileMissingError recovery hint (init / import).

  2. Populate the blob fields flag → config → fail:

    • endpoint, region, bucket, prefix (default <repo>/<env>/) come from cfg.s3.* when no flag is passed. Flags override config values (do not persist back to disk — runtime-token is read-only against the config file).
    • accessKeyId, secretAccessKey — if flags absent and --interactive (or no flag at all on a TTY), prompt via src/prompt.ts (matches bin/init.ts). On !isTty() with missing creds, fail with exit 1 and a clear pass --access-key=… / --secret-key=… message.
  3. Construct the blob JSON (canonical schema in §4). env field equals the --env= value verbatim.

  4. Validation (default on). Use the supplied creds to perform a single read against s3://<bucket>/<prefix>manifest (HEAD or 1-byte ranged GET; the existing Bun.S3Client stat() call is sufficient). The point is to catch wrong-key / wrong-bucket / wrong-region at issue time instead of at the application's first boot.

    • 200 / 304 → validated, continue.
    • 403 / 401 → exit 2, "credentials accepted by S3 but cannot read <prefix>manifest — check IAM policy".
    • DNS / connect / TLS / 5xx → exit 3, "could not reach <endpoint> — check network / endpoint URL".
    • 404 on manifest → not fatal. A freshly-init'd env may not have been pushed yet; print a stderr warning ("<prefix>manifest does not exist yet — run vsync push <env> before booting apps") and continue to emit the blob. Validation goal is to catch auth/reachability errors; absence of data is a separate operator problem.
  5. --no-validate skips step 4 entirely (for offline minting, tests, air-gapped CI).

  6. gzip the JSON → base64 (URL-safe alphabet, no padding) → prepend vsync-cfg-v1: → print to stdout with a trailing newline. The blob is the only thing on stdout (so vsync runtime-token … | pbcopy / piping to a secret manager just works); every progress line and warning goes to stderr.

  7. --json also dumps the pre-encoding JSON to stderr, preceded by a loud banner:

    ⚠ --json: the JSON below contains your AWS secret key in cleartext.
    ⚠ Do not paste this into Slack / tickets / logs. Use only for local debugging.

2.3 Exit codes

CodeMeaning
0Blob written to stdout
1Missing required input (no creds + no TTY, missing --env, etc.)
2Credential validation failed — creds reach S3 but can't read <prefix>manifest
3S3 unreachable (DNS / network / TLS / 5xx)
4Per-(repo, env) config file missing (ConfigFileMissingError)

3. vsync rotate-passphrase

Re-encrypts the vault bundle under a new passphrase, atomically swaps the manifest pointer, appends one audit row. The passphrase is the secret the runtime lib uses as KDF input to unwrap the bundle's RQE1 envelope — rotating it is the first-class operator response to "passphrase leaked into logs / Slack / an ex-employee's terminal".

3.1 Signature

bash
vsync rotate-passphrase --env=<env>
  [--new-passphrase=<...>]
  [--interactive]
  [--repo=<name>]
  [--no-audit] [--note=<text>] [--meta key=value]

--no-audit / --note / --meta follow v0.4 semantics unchanged.

3.2 Behaviour

  • Load config + key via loadEnvConfig(repo, env). Missing → exit 5 with ConfigFileMissingError. (Code 5 reserved so 0–4 mirror runtime-token's contract.)
  • Prompt for the old passphrase via src/prompt.ts (askSecret). On !isTty() and no --old-passphrase flag (deliberately not exposed — old passphrases should never appear in shell history; --new-passphrase is exposed only to support automation that already pipes the new value from a secret store), fail with exit 1.
  • Fetch the current latest manifest from S3 (RQEM0001). Pull the bundle it points at. Attempt to decrypt with the old passphrase + the keychain AES key. Wrong passphrase → exit 1 ("old passphrase does not decrypt the current bundle — refusing to rotate").
  • Read the current gen counter from the manifest's meta cell (meta.gen, defaulting to 0 if absent — first rotation on a pre-0.10 bundle).
  • Acquire the new passphrase: --new-passphrase= flag wins; otherwise prompt twice (entry + confirmation; mismatch → re-prompt up to 3 times, then exit 1). Minimum length: 12 chars (existing src/passphrase.ts policy if it exists; otherwise hard-coded here). No --interactive re-prompt when the flag is given unless --interactive is passed.

3.3 Atomic flow

1. Decrypt current bundle with old passphrase                       [in-memory]
2. Re-encrypt the plaintext with new passphrase                     [fresh salt, fresh nonce per v0.2 §3]
3. PUT new bundle to s3://<bucket>/<prefix>v=<newTs>                [new object key; old key untouched]
4. PUT new manifest (RQEM0001) with:
     - pointer = newTs
     - meta.gen = N + 1
     - meta.prev_gen = N
     - meta.rotated_at = ISO timestamp
   conditional on If-Match: <currentManifestEtag>                   [v0.4 protocol reused]
5. Append audit row:
     action = "rotate"
     version_ts = newTs
     meta = {"event":"rotate","gen":N+1,"prev_gen":N}               [merged with user --note/--meta]

Step 1–2 are pure in-memory and cheap; they exist as the "did the operator type the right old passphrase?" gate. Steps 3–5 are the only operations that touch remote state.

Rollback / failure modes — at no point may the bundle and manifest disagree:

Failing stepState on diskOperator-visible recovery
1 (decrypt)Manifest + bundle untouched"old passphrase wrong — refusing to rotate" (exit 1)
2 (encrypt)SameInternal bug; print stack + exit 2
3 (PUT new bundle)Old manifest still points at old bundleExit 2. Stale object at v=<newTs> is harmless (manifest never named it). Optional best-effort DeleteObject printed as a hint, not auto-issued.
4 (manifest swap)Two bundles in bucket, manifest still on old oneExit 3. 412 Precondition Failed → concurrent rotation; print "manifest changed under us — another rotation is in flight; re-run after confirming with your teammate". Other 5xx → "manifest swap failed, old passphrase still works; safe to retry".
5 (audit append)Rotation has succeeded. Manifest is on the new bundle. Only the audit row failed.Exit 4, but with a copy-pasteable "manual audit row" block (one CSV line the operator can append via S3 console / aws s3 cp) so the team can reconcile. Crucially: do not roll back the manifest.

Bundle deletion of the now-orphaned old object is deferred — keeping it means a teammate with a stale VSYNC_PASSPHRASE gets 403 on manifest pointer chase rather than a confusing decrypt error. A future vsync gc <env> can prune by age.

3.4 Post-success output (stderr; stdout is empty for scriptability)

text
✓ Bundle re-encrypted with new passphrase (gen=N → gen=N+1)
✓ Manifest pointer updated atomically
✓ Audit log entry written

Next steps:
  1. Update VSYNC_PASSPHRASE (or contents of VSYNC_PASSPHRASE_FILE) in your secret store / host file
  2. Roll-restart apps in <env>

⚠ Apps booting between this moment and step 1 will fail to decrypt.
  This rotation race window is operator-owned; vsync cannot bridge it in v1.

The race-window warning is load-bearing — two-passphrase grace-period rotation was considered and deferred to v2 (see §5). Operators need to know the window exists and that minimising it is their job.

3.5 Exit codes

CodeMeaning
0Rotation complete; audit row written
1Wrong old passphrase / mismatched new passphrase confirmation / missing input
2S3 error during bundle re-upload (step 3)
3Manifest swap failed — 412 conflict (concurrent rotation) or other manifest-PUT failure (step 4)
4Rotation succeeded but audit append failed — recovery hint printed (step 5)
5Per-(repo, env) config file missing

4. Config blob — canonical JSON schema

The plaintext payload inside the vsync-cfg-v1: envelope. Field order is not significant; whitespace is not significant (the gzip step strips it anyway).

json
{
  "v": 1,
  "endpoint": "https://s3.example.com",
  "region": "us-east-1",
  "bucket": "myapp-secrets",
  "accessKeyId": "AKIA...",
  "secretAccessKey": "...",
  "prefix": "myapp/dev/",
  "env": "dev",
  "salt": "<base64 16 bytes>",
  "iterations": 600000
}
FieldTypeRequiredNotes
vintegeryesEnvelope version. v0.10 emits 1. Readers MUST reject any other value (decision I).
endpointstringyesFull URL incl. scheme. No trailing slash.
regionstringyesS3 region. Pass-through; vsync does not validate against an allow-list.
bucketstringyesBucket name only (no s3:// prefix).
accessKeyIdstringyesIAM access key.
secretAccessKeystringyesIAM secret.
prefixstringyesTrailing slash required (the runtime appends manifest / v=<ts> to it). Default at mint time: <repo>/<env>/.
envstringyesEchoed into the runtime so log lines can identify which env booted. No effect on path resolution.
saltstringyesPBKDF2-SHA256 salt — emitted verbatim from cfg.encryption.salt (a 24-char base64url ASCII string from vsync init). Readers MUST feed the UTF-8 bytes of this string directly to PBKDF2; do NOT base64-decode first. The field's base64-like alphabet is a CLI storage convention, not a transport encoding marker. Matches what src/crypto.ts::deriveKey feeds PBKDF2 on the CLI side, and matches the test-vector corpus (rqe1-decrypt/*). Emitter: salt: cfg.encryption.salt (string pass-through, no re-encoding). Length is variable (24 bytes for vsync v0.x-init'd envs).
iterationsintegeryesPBKDF2 iteration count. Reference: 600000 (v0.2 spec). Future libs may support other work factors; carried for forward-compat.

Wire format:

vsync-cfg-v1:<base64url(no-pad)( gzip( utf8( JSON ) ) )>

Unknown prefix → reader errors with unsupported VSYNC_CONFIG envelope: expected "vsync-cfg-v1:". Unknown vunsupported VSYNC_CONFIG version: v=<N> (decision I: fail loud, no shims).

Adding a field in a future minor (e.g. useSsl, sessionToken) keeps v: 1 as long as missing-field defaults are documented and existing readers ignore unknowns. A breaking change bumps the prefix to vsync-cfg-v2:.


5. Out of scope

  • The runtime library itself. Specced separately as v0.12-vsync-s3-client.md. This spec only emits the blob it consumes.
  • Cross-language test vectors. A shared format (RQE1 / RQEM0001 / vsync-cfg-v1: round-trip cases) lives in v0.11-conformance-test-vectors.md. Both v0.10 (the writer) and vsync-s3-client (the reader) consume it.
  • IAM key rotation (vsync rotate-iam-key). Explicitly out of scope. IAM key rotation is owned externally — cloud-admin team, AWS Secrets Manager auto-rotation, or aws iam create-access-key && delete-access-key. After the external rotation lands, the operator re-mints the bootstrap blob with:
    bash
    vsync runtime-token --env=production \
      --access-key=AKIA_NEW --secret-key=NEW_SECRET
    and updates VSYNC_CONFIG wherever it's stored. Building a vsync verb around this would be a thin wrapper over aws iam with no value-add; the line is drawn here on purpose.
  • Two-passphrase grace-period rotation. Would eliminate the race window in §3.4 by accepting either the old or new passphrase for a configurable overlap period. Real, but needs a manifest schema bump (carrying two pointers, or a "previous" salt). Deferred to v2; the §3.4 warning is the v1 stopgap.
  • Live in-process refresh on the lib side. Already excluded in vsync-s3-client. Apps restart to pick up rotated state.
  • Persisting the runtime-minted IAM creds back to cfg.s3. runtime-token is read-only against the config file. If the operator wants the new creds to be the local default, they re-run vsync init (or a future vsync set-creds).
  • Compat shims. Pre-1.0 (decision I). No silent migration of older blob versions, no fallback parsing.

6. Test plan summary

Test categories drawn from v0.11-conformance-test-vectors.md — same vector files exercise this CLI and the vsync-s3-client.

CategoryWhat it coversWhere
vec/runtime-token/*Mint-and-decode round-trips: fixed {env, prefix, …} → expected base64gz output. Verifies the canonical JSON field set, gzip determinism (level 6, no mtime), base64url (no-pad) alphabet.test/bin/runtime-token.test.ts
vec/blob-reject/*Unknown-prefix, unknown-v, malformed base64, gzip-of-non-JSON, missing required field. Each must error with the message documented in §4.shared with vsync-s3-client tests
vec/rotate-passphrase/atomic/*Each failure step (decrypt / encrypt / bundle PUT / manifest PUT / audit append) tagged with the expected exit code from §3.5. Run against an in-process S3 stub that can be told to fail at step N.test/bin/rotate-passphrase.test.ts
vec/rotate-passphrase/etag-conflictTwo concurrent rotations; one wins, the other hits 412 → exit 3 with the documented message. Mirrors v0.4 §5 audit-append conflict logic.same file
vec/rotate-passphrase/gen-countermeta.gen round-trips from 0 → N over repeated rotations. Pre-0.10 bundles (no meta.gen) start at 1.test/manifest.test.ts (extend)
vec/runtime-token/validateMock S3 returning 200 / 403 / 404 / network-error / 5xx for the <prefix>manifest HEAD. Exit codes 0 / 2 / 0-with-warning / 3 / 3.test/bin/runtime-token.test.ts

No new wire-format magic; existing RQE1 / RQEM0001 / audit CSV coverage stays green. Net delta: ~25 new tests (the bin/ suite grows, no src/ unit-test churn beyond the manifest meta-cell additions).


7. Implementation notes

  • bin/runtime-token.ts (new) — ~120 lines. Imports loadEnvConfig, askSecret, gzipSync (from bun:zlib or Node's node:zlib), and the existing S3Client for the validation HEAD.
  • bin/rotate-passphrase.ts (new) — ~180 lines. Imports loadEnvConfig, manifest read/write helpers from src/manifest.ts, RQE1 encrypt/decrypt from src/crypto.ts, audit-append from src/audit.ts (with the new action="rotate" enum value — extend bin/audit.ts formatter to recognise it).
  • bin/vsync.ts — add "runtime-token" and "rotate-passphrase" to SUBCOMMANDS, two switch arms, two usage lines under a new rotation section.
  • src/manifest.ts — extend meta shape with optional gen, prev_gen, rotated_at. Readers that ignore unknowns (today's behaviour) are unaffected.
  • src/audit.ts — accept "rotate" as a valid action value. The CSV column set is unchanged (v0.4 §4); the meta cell carries the rotation-specific fields.
  • README.md — two new entries in the cheat-sheet, one paragraph each.
  • package.json — version bump to 0.10.0.

Verb count: 10 → 12. Still readable in one --help page.

Released under the MIT License.