Skip to content

Harden vector verification, audits, and cross-language conformance - #9

Merged
jaschadub merged 7 commits into
mainfrom
security/audit-fixes
Oct 10, 2026
Merged

jaschadub merged 7 commits into
mainfrom
security/audit-fixes

Conversation

@jaschadub

@jaschadub jaschadub commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Vector verification rejects precision widening, validates attestation dates, binds the model hash and record identity, and fails audits closed. Python adapters and CLI behavior follow the hardened contract, with matching Rust and TypeScript verification changes.

Shared positive and negative fixtures cover float edge cases and malformed attestations. CI now exercises all three implementations, fixture regeneration, and dependency checks; documentation states the resulting guarantees and deployment limits.

Validation: All seven CI jobs passed: Python 3.11–3.13, Rust, TypeScript, cross-language conformance, and dependency audits. No package release or tag is part of this PR.

The verifier cast any supplied vector to the pinned dtype before
hashing, so every float64 value that rounds to the same float32
verified against an f32 pin: ~28 unverified bits per element in any
float64-backed store (VP-1). It also raised on lone surrogates (VP-6)
and naive KeyEntry datetimes (VP-11), accepted regex-shaped but
impossible calendar dates (VP-9), padded or non-canonical base64url
signatures (VP-12), float-valued v (VP-13), unbounded kid/model on the
dict path (VP-14), empty vectors at sign time (VP-16), and offered no
way to check model_hash (VP-10) or to bind the revocation window to a
clock the key holder does not control (VP-5).

Verifier.verify now refuses a vector wider than vec_dtype as
SHAPE_MISMATCH, checks source before vector per spec section 5, takes
expected_model_hash and trusted_time, and never raises on pin content.
Signer.pin defaults vec_dtype to the input array's native dtype and
rejects empty vectors. The parser rejects surrogates, enforces calendar
validity, strict canonical base64url, integer v, and per-field byte
caps. hash_files implements the model_hash convention.

Regression tests: test_precision_widening (f64 low-bit channel is
rejected), test_parser_hardening, test_trusted_time, test_model_hash,
test_adversarial_perturbations, test_property_canonical (hypothesis).
The shipped audit treated a missing or empty pin field as a harmless
'unpinned' record and exited 0 (VP-2), never passed record, collection,
tenant, or model expectations to the verifier so pin-shuffle replay
passed (VP-3), stopped walking at the first malformed pin because the
adapter generator was finalized by the exception (VP-7), and the Chroma
HTTP adapter sent auth headers over plaintext with no guard (VP-15).
Every adapter also coerced vectors to float32 before the verifier saw
them, closing the audit's eyes to the VP-1 channel.

Adapters now return the store's native precision (schema-derived for
LanceDB and pgvector; declared single precision for Chroma, Qdrant and
Pinecone, with the narrowing verified exact), yield error-carrying
records instead of raising, and treat empty pin fields as parse
errors. BaseAdapter gains get_verified/iter_verified read wrappers.
The audit loop fails on unpinned records unless --allow-unpinned,
prints per-record ids, enforces vectorpin.record_id against the record
it sits on, and adds --collection-id/--tenant-id/--expected-model/
--expected-model-hash. A signed collection manifest (count + Merkle
root) can be written at ingestion and checked with --manifest, and
--sign-summary-key signs the JSON summary (verify-summary checks it).
Private key files that are group/world readable print a warning.
… Rust port

The Rust verifier narrowed an f64 slice to f32 before hashing, so bits
below float32 precision were never checked against an f32 pin (VP-1).
The ts parser accepted any digits, so 2026-03-00 reached u32 day-of-
year arithmetic that underflows (a panic with overflow checks, a
wrapped date without) before the signature was examined (VP-9). v was
truncated with 'as u32' so 4294967298 parsed as 2, and extra: null was
accepted (VP-13); kid/model had no byte cap on the from_value path
(VP-14); model_hash could not be required (VP-10); and the revocation
window had no verifier-trusted clock (VP-5). ed25519-dalek was built
without its zeroize feature, so SigningKey was not wiped on drop
(VP-16).

Verifier::verify returns the new PrecisionWidening variant for an f64
slice against an f32 pin, checks source before vector per spec
section 5, and takes expected_model_hash and trusted_time. Timestamps
are calendar-validated at parse time and converted with checked i64
arithmetic; parse_ts_unix is exported for building key windows. v and
vec_dim use checked conversions; extra: null is rejected; kid/model
are byte-capped before normalization. The ed25519-dalek zeroize
feature is enabled and proptest is added as a dev dependency.

tests/audit_fixes.rs covers each finding (the f64 low-bit channel is
asserted rejected), VectorSmuggle-style perturbations, and property
tests over canonical bytes; cross_lang.rs consumes the extended
fixture schema (native input dtype, model hash, collection/tenant ids,
key windows, trusted time).
The TypeScript verifier narrowed a Float64Array (or a plain number[],
which is a 64-bit representation) to f32 before hashing, leaving the
discarded bits unverified against an f32 pin (VP-1). The signer
defaulted to f32 for 64-bit input, accepted lone surrogates that
TextEncoder then silently turned into U+FFFD (VP-6), tolerated base64
padding and non-canonical trailing bits in sig (VP-12), could not see
that JSON.parse had collapsed 2.0 to 2 for v (VP-13), accepted
extra: null, had no byte caps on kid/model (VP-14), used V8's
Date.parse which rolls 2026-02-30 forward (VP-9), could not require
model_hash (VP-10), had no verifier-trusted clock for the revocation
window (VP-5), and depended on Node's Buffer despite the Deno/Bun
claim (VP-18).

Verifier.verify now refuses a 64-bit vector against an f32 pin as
SHAPE_MISMATCH, checks source before vector per spec section 5, takes
expectedModelHash and trustedTime, and never throws on pin content.
Signer.pin defaults vecDtype to the input's native dtype and rejects
lone surrogates everywhere. The parser enforces calendar validity,
strict canonical base64url via a pure-JavaScript codec, minimal
integer literals for v/vec_dim on the JSON path, per-field byte caps,
and rejects extra: null. utf8ByteLength replaces Buffer.byteLength.

test/audit-fixes.test.ts covers each finding (the f64 low-bit channel
is asserted rejected), VectorSmuggle-style perturbations, and fast-
check properties over canonical bytes and the base64url codec.
The shared fixtures only perturbed vectors in the pinned dtype, had no
float edge cases, and lacked KEY_EXPIRED, SHAPE_MISMATCH,
COLLECTION_MISMATCH and TENANT_MISMATCH entries, so none of the three
ports' suites exercised the precision-widening boundary, calendar
validation, surrogate rejection, strict base64url, or integer parsing.

v2.json gains f32 and f64 IEEE-754 edge-case vectors (signed zeros,
subnormals, max, 1 + ulp) and an f64 input pinned as f32 whose values
are exact round-to-nearest-even ties, carried with vec_native_dtype so
every port signs from the native input and verifies with the cast.
negative_v2.json gains widened_f64_low_bit_perturbation and
widened_f64_exact_values (SHAPE_MISMATCH), a truncated-vector
SHAPE_MISMATCH, padded and non-canonical-trailing-bits sig, four
calendar-invalid timestamps, a lone surrogate, v: 2.0, extra: null,
kid/model over the field caps, model_hash mismatch/missing,
collection and tenant mismatches, and two KEY_EXPIRED cases (one via
trusted_time). The Python and TypeScript consumers read the new
fields (Rust's was updated with its port); the generator self-checks
every positive fixture's hash against the cast of its native input.
…phy bound

ci.yml had python, rust, and fixture-drift jobs but never executed the
TypeScript tests, so the 'consumed in all three test suites' claim was
unenforced and a canonicalization regression in the TS port would have
shipped unnoticed (VP-8). No job audited dependencies, and
cryptography<46 blocked every release that fixes the advisories
pip-audit reports against 45.x.

Add a typescript job (npm ci, typecheck, test on Node 20), make the
fixture job run the Python, Rust, and TypeScript cross-language tests
after regenerating testvectors/, add a supply-chain job running cargo
audit, npm audit --omit=dev, and pip-audit, and run Rust clippy/test
with --all-features. Drop the cryptography upper bound (the Ed25519
and serialization APIs used are stable across majors and the cap only
pinned users to vulnerable releases) and add hypothesis to the dev
extra for the new property tests.
The README said 'any modification breaks the signature' while every
shipped adapter and verifier cast wider vectors before hashing, listed
a poisoned ingestion pipeline as in scope while also excluding the key
holder it co-locates the signer with (VP-4), claimed 'selectively
delete pins' was defended while the audit treated deleted pins as
harmless, described a cron job as 'continuously verifies', and claimed
Deno/Bun support, all-three-suites CI, and a Symbiont runtime
integration that did not hold (VP-18). The spec said 14-byte tag,
required escaping U+007F, and ordered source before vector while the
code did the reverse (VP-17); section 7 claimed revocation immediately
invalidates post-compromise forgeries (VP-5); the troubleshooting
guide documented cast-before-hash as intended; the CHANGELOG claimed
the audit continued past a malformed pin (VP-7).

State that the protected interval begins at signing and that signing
should run as a separate service; add the precision rule to spec
section 3.2, calendar validity and integer-literal rules to 4.1, field
caps to 4.3, the never-raise contract and model_hash expectation to 5,
the trusted-time limitation to 7 and 9, and the model_hash computation
procedure. Document fail-closed audits, manifests, signed summaries,
the read wrappers, the quantization boundary, and 'pin under the dtype
your store persists'. Correct the stale claims, and add an Unreleased
CHANGELOG section listing every fix by finding ID.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-10T19:29:29.408210Z 97b4df9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97b4df90e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +123 to +126
elif arr.dtype.itemsize > target.itemsize:
narrowed = arr.astype(target)
if not np.array_equal(narrowed.astype(arr.dtype), arr):
raise ValueError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept values that round-trip to the declared store dtype

When an f32-backed REST API serializes a stored value using its shortest JSON representation (for example, 0.1), the client parses it as the f64 value 0.1, which is not exactly equal to the widened f32 value even though narrowing recovers the original stored value. This exact-equality check therefore raises for ordinary Qdrant REST responses and aborts the audit; once native_dtype is supplied by the adapter, values that round-trip through that dtype need to be accepted rather than required to already be its exact widened representation.

Useful? React with 👍 / 👎.

Comment thread src/vectorpin/cli.py
Comment on lines +323 to +326
if write_manifest is not None:
path, signer = write_manifest
built = build_manifest(signer, collection_id or label_value, entries)
path.write_text(built.to_json() + "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not write a manifest after an incomplete audit

When any record fails verification or the adapter iterator aborts partway through, this block still signs and writes a manifest for the invalid or partial entries list. Because the manifest is intended to establish the trusted post-ingestion baseline, publishing that file can bless an incomplete collection despite the nonzero exit status; only write it after a complete, successful walk.

Useful? React with 👍 / 👎.

Comment on lines +71 to +72
MAX_KID_BYTES = 256
MAX_MODEL_BYTES = 1024

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the new scalar caps before signing

The new 256-byte kid and 1024-byte model limits are enforced only by parsers. The Python, TypeScript, and Rust signers still accept larger values and return signed pins, so a pin produced by the library cannot be parsed back by the same library—for example, Signer.generate(key_id="k" * 257).pin(...) succeeds but Pin.from_json(pin.to_json()) fails. Mirror these limits in signer construction and pin creation.

Useful? React with 👍 / 👎.

Comment on lines +371 to +372
if (!isValidCalendarTs(ts)) {
throw new Error('pin.ts is not a valid calendar date-time');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject calendar-invalid timestamps before signing

The new calendar check is not mirrored by the TypeScript or Rust signing paths. A TypeScript caller can pass timestamp: "2026-02-30T00:00:00Z" through formatTimestamp's regex fast path, and Rust PinOptions.timestamp likewise performs only string-safety checks; both produce signed pins that their newly hardened parsers immediately reject. Validate explicit timestamps with the same calendar predicate before signing.

Useful? React with 👍 / 👎.

Comment on lines +190 to +193
try:
datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
except ValueError:
raise ValueError(f"{field_name} is not a valid calendar date-time") from None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align year-zero timestamp validation across ports

Python's datetime.strptime rejects 0000-01-01T00:00:00Z, while the new Rust parse_ts explicitly treats that timestamp as valid and TypeScript's isValidCalendarTs plus Date.parse also accepts it. A pin with this otherwise pattern-conforming timestamp can therefore be signed and verified by those ports but cannot be parsed by Python, breaking the advertised cross-language wire contract; either reject year zero everywhere or validate it without Python's datetime year restriction.

Useful? React with 👍 / 👎.

@jaschadub
jaschadub merged commit dc1d94e into main Oct 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant