v1.0.0-rc13
Pre-releaseCumulative since v1.0.0-rc10: rc11 and rc12 were cut without CHANGELOG
entries, so the changes they carried are recorded here rather than lost.
Fixed
-
Container images now report their real version. The Dockerfile built with
-ldflags="-s -w"and no-Xinjection, and the image workflow passed no
version build-arg, so the binary fell back to the hardcoded
version.Version = "1.0.0-rc10". Every image from rc11 onward reported
itself as1.0.0-rc10— the image tag said one thing andserverInfo/
/healthzsaid another.release.ymlalready injected the version correctly
into the standalone binaries; the image build was the one path that did not.The Dockerfile now takes
ARG VERSIONand injects it via-X, and
image.ymlpasses${{ steps.meta.outputs.version }}. The fallback in
internal/versionis nowdev, not a release-shaped number: a
plausible-but-wrong version is worse than an obviously-unset one, because it
fails silently. That stale constant is exactly why this went unnoticed across
two release candidates.
Added
-
Operator-configured data retention with an enforcing purge. The four
Postgres tables the keyless bundle writes (write_audit,signer_quota,
signer_blacklist,admin_audit) previously had no purge, TTL, or expiry
of any kind — rows accumulated indefinitely, and the retention periods our
own documentation cited were enforced by nothing. A new in-process purge now
enforces per-table windows:MCP_WRITE_AUDIT_RETENTION,
MCP_WRITE_AUDIT_GRANT_ROLE_RETENTION,MCP_SIGNER_QUOTA_RETENTION,
MCP_SIGNER_BLACKLIST_RETENTION,MCP_ADMIN_AUDIT_RETENTION, and
MCP_RETENTION_PURGE_INTERVAL(default1h).Every window defaults to unset, which means retain indefinitely — no
existing deployment changes behavior on upgrade. Retention is deliberately
operator-chosen rather than hardcoded: a self-hosting operator's obligations
are theirs to determine. The point of the feature is that a window an
operator does set is now enforced by the artifact rather than promised by a
document. Boot fails on a negative window, on a window with a non-positive
purge interval (it would never fire), and on agrantRolewindow shorter
than the ordinary one (it would purge the administrative trail before the
routine traffic it exists to outlive). Deletes are batched so a large first
sweep cannot hold long row locks. Seedocs/DATA_HANDLING.md§ 8.3 and
docs/RUNBOOK.md§ "Data retention". -
write_audit.method_selector(migration 0005). Records the 4-byte ABI
method selector of each broadcast. Under keyless writes every relayed
transaction shares one destination (checkRelayScopepermits only the anchor
precompile), soto_addrcould not distinguish an administrativegrantRole
call from a routine anchor write — which made the longergrantRole
retention window our privacy policy promises unenforceable. The selector
is derived from the loaded ABI (anchor.Client.MethodSelector), never
hardcoded: this precompile'sgrantRoletakes four arguments, so its selector
is not the well-known OpenZeppelingrantRole(bytes32,address)value. A
selector is a public function identifier and carries no caller data. Rows
written before this migration carry an empty selector and are treated as
ordinary writes. -
Trusted-proxy header hardening (C3/C5). Two defense-in-depth
controls, both gated on the existingNVNM_TRUST_PROXY_HEADERS
(defaultfalse, unchanged): (C3) the fail-rate and anon-read
limiters' client-IP derivation now walks a configurable number of
trusted hops in from the right ofX-Forwarded-For ++ RemoteAddr
instead of trusting the leftmost (client-forgeable)XFFentry, so
a forged left-prefix can no longer mint its own rate-limit bucket;
(C5) a newrequireForwardedHTTPSmiddleware rejects a request
carrying an explicit non-httpsX-Forwarded-Protowith403,
catching a plaintext-downgrade path inside the trust boundary — it
is deliberately lenient whenX-Forwarded-Protois absent (the
ingress remains the primary, fail-closed TLS gate; see
docs/RUNBOOK.md§ "Trusted-proxy header invariants (C3/C5)" for
the fail-open rationale). New env varNVNM_TRUSTED_PROXY_HOPS
(int, default1, must be>= 1; boot fails loud otherwise) sets
the trusted chain depth (1= single ingress,2= CDN +
ingress); documented canonically indocs/DATA_HANDLING.md§ 5.
Both controls are defense-in-depth behind the deploy-side
invariants (proxy strips inboundXFF; ingress setsXFPand
terminates TLS), which remain operator-owned. -
API-key hashing now supports HMAC-SHA256 under a server-held pepper
(KEY_HMAC_PEPPER, optional;KEY_HMAC_PEPPER_PREVIOUSfor one
rotation window). Keys carry ahash_version; legacy SHA-256 keys
(v0) continue to authenticate unchanged via versioned candidate
lookup. SettingKEY_HMAC_PEPPER_PREVIOUSwithoutKEY_HMAC_PEPPER
fails boot. -
Postgres key-store backend (
KEY_STORE_BACKEND=postgres). An
optional Postgres-backed key store for multi-replica deployments where
all replicas must share a single authoritativeapi_keystable.
File backend (KEY_STORE_BACKEND=file) remains the default and is
unchanged. New env vars:KEY_STORE_BACKEND,KEY_STORE_DSN
(required whenpostgres). Dependencies added:pgx/v5,goose/v3. -
Advisory-locked boot-time migrations. goose migrations for the
Postgres backend run automatically at startup under a
pg_advisory_lock, making concurrent replica boot safe without a
separate migration job. -
Persisted lazy v0→v1 rehash (Postgres backend). On first
authenticated use, a legacyv0key (plain SHA-256) is transparently
rehashed tov1(HMAC-SHA256 underKEY_HMAC_PEPPER) and the updated
digest is committed to theapi_keystable. -
Hard pepper gate (
ErrPepperRequired). When
KEY_STORE_BACKEND=postgresandAUTH_PROVIDER=apikey, boot fails
immediately ifKEY_HMAC_PEPPERis unset. Closes the risk of a
misconfigured replica falling back to unprotected v0 hashing on a
shared store. -
Key expiry enforcement (Phase 4).
expires_atis now enforced at
Lookup on both file and Postgres backends. A key expires when
now >= expires_at; a zero/NULLexpires_atnever expires. -
Three-way reject taxonomy with bounded disclosure. Auth rejections
are classified into three messages:invalid API key(no-match —
also returned on the timing-equalised miss path so non-holders cannot
distinguish unknown-key from known-key),key expired(matched row,
expiry passed; appends— renew at <KEY_RENEWAL_URL>when set), and
key revoked(matched row,enabled=false). The specific messages
are only reachable by a caller holding the matched key's bytes. -
KEY_DEFAULT_TTLenv var (default8760h≈ 1 year). Applied by
the admin REST API andkey-mgmt createwhen no per-keyttl
override is given.0means no default expiry. Does not apply to the
staticMCP_API_KEYpath (always non-expiring). -
KEY_RENEWAL_URLenv var (optional). URL appended to thekey expiredrejection message; empty = no hint appended. -
Admin REST API TTL support.
POST /admin/keysand
PATCH /admin/keys/{id}accept an optional"ttl"field (duration
string;"0"/"none"/"never"= no expiry; non-positive parsed
duration → HTTP 400). Admin responses (KeySummary) now include
expires_at; the zero-value0001-01-01T00:00:00Zmeans no expiry. -
key-mgmtCLI TTL and renew.key-mgmt creategains--ttl <dur|0>(default fromKEY_DEFAULT_TTL). Newkey-mgmt renew <client-id> --ttl <dur|0>subcommand extends or clears expiry from
now;--ttlis required (the default TTL is not applied on renew).
Fixed
nvnm_overviewno longer tells agents a keyless deployment requires an
API key. Theprereqslist was a package-level constant asserting that
broadcasting needs "an API key on this server with the writer or admin
role" — false since the authless flip, and model-visible to every connecting
client. It is now derived fromcfg.KeylessWrites, the same flag
RequiresAuthreads to make the actual enforcement decision, so the stated
requirement cannot drift from the enforced one. A keyless deployment now
reports that no credential is required and that the wallet signature is the
only identity the server sees.docs/RUNBOOK.mdno longer claims a hosted authless deployment "runs no
key store at all." The code does not permit that:loadAPIKeysfails boot
withErrHTTPAuthRequiredwhen neitherMCP_API_KEYnor
MCP_API_KEYS_FILEis set on the HTTP transport, and that guard has no
keyless exception. Such a deployment holds an operator credential that is
never issued to any caller; callers remain wholly anonymous, but the
Bearer-validation path stays mounted and an invalid token draws a401. The
same incorrect claim is corrected ininternal/config'sKeylessPGDSN
godoc.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>