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.x | 0.10.0 | |
|---|---|---|
| Subcommands | 10 | 12 — adds runtime-token, rotate-passphrase |
| Bootstrap blob format | n/a (no runtime lib yet) | vsync-cfg-v1:<base64-gzip-json> (§4) |
| Passphrase rotation | manual (re-init + re-push) | first-class verb with atomic manifest swap + audit row |
| Bundle / share / audit / manifest formats | unchanged | unchanged (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. - G —
rotate-passphraseis mandatory in v1.rotate-iam-keyis out of scope (§5). - J — monotonic
gen=Ncounter, 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
vsync runtime-token --env=<env>
[--access-key=<AKIA...>] [--secret-key=<...>]
[--bucket=<...>] [--endpoint=<...>] [--region=<...>] [--prefix=<...>]
[--json]
[--no-validate]
[--interactive]
[--repo=<name>]2.2 Behaviour
Resolve
repo(persrc/repo.ts::getRepoName) and load the existing per-(repo, env) config viasrc/envconfig.ts::loadEnvConfig. Config absence → exit 4 with the standardConfigFileMissingErrorrecovery hint (init / import).Populate the blob fields flag → config → fail:
endpoint,region,bucket,prefix(default<repo>/<env>/) come fromcfg.s3.*when no flag is passed. Flags override config values (do not persist back to disk —runtime-tokenis read-only against the config file).accessKeyId,secretAccessKey— if flags absent and--interactive(or no flag at all on a TTY), prompt viasrc/prompt.ts(matchesbin/init.ts). On!isTty()with missing creds, fail with exit 1 and a clearpass --access-key=… / --secret-key=…message.
Construct the blob JSON (canonical schema in §4).
envfield equals the--env=value verbatim.Validation (default on). Use the supplied creds to perform a single read against
s3://<bucket>/<prefix>manifest(HEAD or 1-byte ranged GET; the existingBun.S3Clientstat()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>manifestdoes not exist yet — runvsync 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.
--no-validateskips step 4 entirely (for offline minting, tests, air-gapped CI).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 (sovsync runtime-token … | pbcopy/ piping to a secret manager just works); every progress line and warning goes to stderr.--jsonalso 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
| Code | Meaning |
|---|---|
| 0 | Blob written to stdout |
| 1 | Missing required input (no creds + no TTY, missing --env, etc.) |
| 2 | Credential validation failed — creds reach S3 but can't read <prefix>manifest |
| 3 | S3 unreachable (DNS / network / TLS / 5xx) |
| 4 | Per-(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
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 withConfigFileMissingError. (Code 5 reserved so 0–4 mirrorruntime-token's contract.) - Prompt for the old passphrase via
src/prompt.ts(askSecret). On!isTty()and no--old-passphraseflag (deliberately not exposed — old passphrases should never appear in shell history;--new-passphraseis exposed only to support automation that already pipes the new value from a secret store), fail with exit 1. - Fetch the current
latestmanifest 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
gencounter from the manifest'smetacell (meta.gen, defaulting to0if 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 (existingsrc/passphrase.tspolicy if it exists; otherwise hard-coded here). No--interactivere-prompt when the flag is given unless--interactiveis 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 step | State on disk | Operator-visible recovery |
|---|---|---|
| 1 (decrypt) | Manifest + bundle untouched | "old passphrase wrong — refusing to rotate" (exit 1) |
| 2 (encrypt) | Same | Internal bug; print stack + exit 2 |
| 3 (PUT new bundle) | Old manifest still points at old bundle | Exit 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 one | Exit 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)
✓ 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
| Code | Meaning |
|---|---|
| 0 | Rotation complete; audit row written |
| 1 | Wrong old passphrase / mismatched new passphrase confirmation / missing input |
| 2 | S3 error during bundle re-upload (step 3) |
| 3 | Manifest swap failed — 412 conflict (concurrent rotation) or other manifest-PUT failure (step 4) |
| 4 | Rotation succeeded but audit append failed — recovery hint printed (step 5) |
| 5 | Per-(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).
{
"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
}| Field | Type | Required | Notes |
|---|---|---|---|
v | integer | yes | Envelope version. v0.10 emits 1. Readers MUST reject any other value (decision I). |
endpoint | string | yes | Full URL incl. scheme. No trailing slash. |
region | string | yes | S3 region. Pass-through; vsync does not validate against an allow-list. |
bucket | string | yes | Bucket name only (no s3:// prefix). |
accessKeyId | string | yes | IAM access key. |
secretAccessKey | string | yes | IAM secret. |
prefix | string | yes | Trailing slash required (the runtime appends manifest / v=<ts> to it). Default at mint time: <repo>/<env>/. |
env | string | yes | Echoed into the runtime so log lines can identify which env booted. No effect on path resolution. |
salt | string | yes | PBKDF2-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). |
iterations | integer | yes | PBKDF2 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 v → unsupported 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 inv0.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, oraws iam create-access-key && delete-access-key. After the external rotation lands, the operator re-mints the bootstrap blob with:bashand updatesvsync runtime-token --env=production \ --access-key=AKIA_NEW --secret-key=NEW_SECRETVSYNC_CONFIGwherever it's stored. Building a vsync verb around this would be a thin wrapper overaws iamwith 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-tokenis read-only against the config file. If the operator wants the new creds to be the local default, they re-runvsync init(or a futurevsync 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.
| Category | What it covers | Where |
|---|---|---|
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-conflict | Two 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-counter | meta.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/validate | Mock 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. ImportsloadEnvConfig,askSecret,gzipSync(frombun:zlibor Node'snode:zlib), and the existingS3Clientfor the validation HEAD.bin/rotate-passphrase.ts(new) — ~180 lines. ImportsloadEnvConfig, manifest read/write helpers fromsrc/manifest.ts,RQE1encrypt/decrypt fromsrc/crypto.ts, audit-append fromsrc/audit.ts(with the newaction="rotate"enum value — extendbin/audit.tsformatter to recognise it).bin/vsync.ts— add"runtime-token"and"rotate-passphrase"toSUBCOMMANDS, two switch arms, two usage lines under a newrotationsection.src/manifest.ts— extendmetashape with optionalgen,prev_gen,rotated_at. Readers that ignore unknowns (today's behaviour) are unaffected.src/audit.ts— accept"rotate"as a validactionvalue. The CSV column set is unchanged (v0.4 §4); themetacell carries the rotation-specific fields.README.md— two new entries in the cheat-sheet, one paragraph each.package.json— version bump to0.10.0.
Verb count: 10 → 12. Still readable in one --help page.