Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"cryptography>=42.0",
"numpy>=1.26",
"cryptography>=42,<46",
"numpy>=1.26,<3",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
unicode-normalization = "0.1"
zeroize = "1"
thiserror = "1.0"
hex = "0.4"
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
Expand Down
1 change: 1 addition & 0 deletions rust/vectorpin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
base64 = { workspace = true }
unicode-normalization = { workspace = true }
zeroize = { workspace = true }
thiserror = { workspace = true }
hex = { workspace = true }
rand = "0.8"
Expand Down
14 changes: 9 additions & 5 deletions rust/vectorpin/benches/perf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn bench_hash_vector(c: &mut Criterion) {

fn bench_sign(c: &mut Criterion) {
let mut group = c.benchmark_group("sign");
let signer = Signer::generate("bench".into());
let signer = Signer::generate("bench".into()).expect("test signer generate");
let text = make_text(1024);
for &d in VECTOR_DIMS {
let v = make_vector(d);
Expand All @@ -83,9 +83,11 @@ fn bench_sign(c: &mut Criterion) {

fn bench_verify(c: &mut Criterion) {
let mut group = c.benchmark_group("verify_full");
let signer = Signer::generate("bench".into());
let signer = Signer::generate("bench".into()).expect("test signer generate");
let mut verifier = Verifier::new();
verifier.add_key(signer.key_id(), signer.public_key_bytes());
verifier
.add_key(signer.key_id(), signer.public_key_bytes())
.unwrap();
let text = make_text(1024);
for &d in VECTOR_DIMS {
let v = make_vector(d);
Expand All @@ -111,9 +113,11 @@ fn bench_verify(c: &mut Criterion) {

fn bench_verify_signature_only(c: &mut Criterion) {
let mut group = c.benchmark_group("verify_signature_only");
let signer = Signer::generate("bench".into());
let signer = Signer::generate("bench".into()).expect("test signer generate");
let mut verifier = Verifier::new();
verifier.add_key(signer.key_id(), signer.public_key_bytes());
verifier
.add_key(signer.key_id(), signer.public_key_bytes())
.unwrap();
let text = make_text(1024);
// Signature-only verification cost is independent of the vector
// body — the dim doesn't enter the canonical header until vector
Expand Down
8 changes: 5 additions & 3 deletions rust/vectorpin/examples/basic_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ fn main() {
let embedding: Vec<f32> = (0..128).map(|i| (i as f32) * 0.01).collect();
let source = "The quick brown fox jumps over the lazy dog.";

let signer = Signer::generate("demo-2026-05".to_string());
let signer = Signer::generate("demo-2026-05".to_string()).expect("test signer generate");
let pin = signer
.pin(source, "text-embedding-3-large", embedding.as_slice())
.expect("pin creation");
Expand All @@ -20,7 +20,9 @@ fn main() {
println!();

let mut verifier = Verifier::new();
verifier.add_key(signer.key_id(), signer.public_key_bytes());
verifier
.add_key(signer.key_id(), signer.public_key_bytes())
.unwrap();

// 1. honest verify
let r = verifier.verify_full::<&[f32]>(&pin, Some(source), Some(embedding.as_slice()), None);
Expand All @@ -42,7 +44,7 @@ fn main() {
println!("3. wrong source text -> {:?}", r);

// 4. wrong signing key (rogue signer with same kid as legit)
let rogue = Signer::generate("demo-2026-05".to_string());
let rogue = Signer::generate("demo-2026-05".to_string()).expect("test signer generate");
let rogue_pin = rogue
.pin(source, "m", embedding.as_slice())
.expect("rogue pin");
Expand Down
7 changes: 5 additions & 2 deletions rust/vectorpin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
//! use vectorpin::{Signer, Verifier};
//!
//! // Ingestion: produce an embedding, sign a pin for it.
//! let signer = Signer::generate("prod-2026-05".to_string());
//! let signer = Signer::generate("prod-2026-05".to_string()).expect("generate signer");
//! let embedding: Vec<f32> = vec![0.1, 0.2, 0.3, /* ... */];
//! let pin = signer
//! .pin("The quick brown fox.", "text-embedding-3-large", embedding.as_slice())
Expand All @@ -42,7 +42,9 @@
//! // Read/audit: parse the stored JSON and verify against ground truth.
//! let parsed = vectorpin::Pin::from_json(&stored).expect("parse pin");
//! let mut verifier = Verifier::new();
//! verifier.add_key(signer.key_id(), signer.public_key_bytes());
//! verifier
//! .add_key(signer.key_id(), signer.public_key_bytes())
//! .expect("valid pubkey");
//!
//! let result = verifier.verify_full(
//! &parsed,
Expand Down Expand Up @@ -124,6 +126,7 @@
//! defeat, see the companion preprint at
//! [10.5281/zenodo.20058256](https://doi.org/10.5281/zenodo.20058256).

#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
#![warn(rustdoc::broken_intra_doc_links)]
Expand Down
39 changes: 23 additions & 16 deletions rust/vectorpin/src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use std::collections::BTreeMap;

use ed25519_dalek::{Signer as _, SigningKey, VerifyingKey};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;

use crate::attestation::{
check_nfc, check_string_safe, AttestationError, Pin, PinHeader, PROTOCOL_VERSION,
Expand Down Expand Up @@ -53,20 +54,23 @@ pub struct Signer {
impl Signer {
/// Generate a fresh Ed25519 signer. Tests and demos only.
///
/// Panics if `key_id` is empty (the API for new pins requires a kid
/// — tests are the only generation path and a panic is acceptable
/// there).
pub fn generate(key_id: String) -> Self {
assert!(!key_id.is_empty(), "key_id must be non-empty");
/// Returns `Err(SignerError::EmptyKeyId)` if `key_id` is empty, or
/// the underlying validation error if `key_id` is not NFC or
/// contains control characters / bidi overrides. Use a KMS-backed
/// signer for production.
pub fn generate(key_id: String) -> Result<Self, SignerError> {
if key_id.is_empty() {
return Err(SignerError::EmptyKeyId);
}
// Validate the kid against v2 string rules so a generated signer
// can never produce a pin a strict verifier would reject.
check_string_safe(&key_id, "key_id").expect("key_id contains unsafe chars");
check_nfc(&key_id, "key_id").expect("key_id is not NFC");
check_string_safe(&key_id, "key_id").map_err(SignerError::InvalidString)?;
check_nfc(&key_id, "key_id").map_err(SignerError::InvalidString)?;
let mut rng = rand::rngs::OsRng;
Signer {
Ok(Signer {
signing_key: SigningKey::generate(&mut rng),
key_id,
}
})
}

/// Load a signer from a 32-byte raw Ed25519 private seed.
Expand Down Expand Up @@ -95,9 +99,10 @@ impl Signer {
VerifyingKey::from(&self.signing_key).to_bytes()
}

/// 32-byte raw Ed25519 private seed. Treat as a secret.
pub fn private_key_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes()
/// 32-byte raw Ed25519 private seed, wrapped in [`Zeroizing`] so the
/// caller's copy is wiped on drop. Treat as a high-value secret.
pub fn private_key_bytes(&self) -> Zeroizing<[u8; 32]> {
Zeroizing::new(self.signing_key.to_bytes())
}

/// Create a [`Pin`] for `(source, model, vector)`.
Expand Down Expand Up @@ -164,6 +169,8 @@ impl Signer {
check_string_safe(&ts, "ts")?;
check_nfc(&ts, "ts")?;

let vec_dim = u32::try_from(vector.len())
.map_err(|_| SignerError::InvalidVector("vec_dim exceeds u32".into()))?;
let header = PinHeader {
v: PROTOCOL_VERSION,
kid: self.key_id.clone(),
Expand All @@ -172,7 +179,7 @@ impl Signer {
source_hash: hash_text(&source_nfc),
vec_hash: hash_vector(vector, dtype),
vec_dtype: dtype.as_str().to_owned(),
vec_dim: vector.len() as u32,
vec_dim,
ts,
extra: extra_nfc,
};
Expand Down Expand Up @@ -243,7 +250,7 @@ mod tests {

#[test]
fn pin_round_trip_basic() {
let signer = Signer::generate("test".into());
let signer = Signer::generate("test".into()).expect("test signer generate");
let v: Vec<f32> = vec![1.0, 2.0, 3.0];
let pin = signer.pin("hello", "model", v.as_slice()).unwrap();
assert_eq!(pin.kid(), "test");
Expand All @@ -267,15 +274,15 @@ mod tests {

#[test]
fn signer_rejects_nan() {
let signer = Signer::generate("k".into());
let signer = Signer::generate("k".into()).expect("test signer generate");
let v: Vec<f32> = vec![1.0, f32::NAN, 3.0];
let err = signer.pin("x", "m", v.as_slice()).unwrap_err();
assert!(matches!(err, SignerError::InvalidVector(_)));
}

#[test]
fn signer_rejects_infinity() {
let signer = Signer::generate("k".into());
let signer = Signer::generate("k".into()).expect("test signer generate");
let v: Vec<f64> = vec![1.0, f64::INFINITY];
let err = signer.pin("x", "m", v.as_slice()).unwrap_err();
assert!(matches!(err, SignerError::InvalidVector(_)));
Expand Down
39 changes: 26 additions & 13 deletions rust/vectorpin/src/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub enum VerifyError {
TenantMismatch,
/// Pin's `vec_dtype` is not understood by this build.
UnsupportedDtype(String),
/// Public key bytes provided to `add_key` did not decode to a valid
/// Edwards point. The registration was refused rather than silently
/// dropped, so callers can detect bad key material at setup time.
KeyDecodeFailed(String),
}

impl std::fmt::Display for VerifyError {
Expand Down Expand Up @@ -90,6 +94,9 @@ impl std::fmt::Display for VerifyError {
VerifyError::CollectionMismatch => write!(f, "vectorpin.collection_id mismatch"),
VerifyError::TenantMismatch => write!(f, "vectorpin.tenant_id mismatch"),
VerifyError::UnsupportedDtype(s) => write!(f, "unsupported canonical dtype: {s}"),
VerifyError::KeyDecodeFailed(kid) => {
write!(f, "public key for kid {kid:?} failed to decode")
}
}
}
}
Expand Down Expand Up @@ -185,10 +192,11 @@ impl Verifier {
}

/// Register a public key under `kid` with no validity window.
pub fn add_key(&mut self, kid: &str, public_key_bytes: [u8; 32]) {
if let Ok(vk) = VerifyingKey::from_bytes(&public_key_bytes) {
self.keys.insert(kid.to_owned(), KeyEntry::new(vk));
}
pub fn add_key(&mut self, kid: &str, public_key_bytes: [u8; 32]) -> Result<(), VerifyError> {
let vk = VerifyingKey::from_bytes(&public_key_bytes)
.map_err(|_| VerifyError::KeyDecodeFailed(kid.to_owned()))?;
self.keys.insert(kid.to_owned(), KeyEntry::new(vk));
Ok(())
}

/// Register a fully-specified [`KeyEntry`] under `kid`.
Expand Down Expand Up @@ -287,7 +295,8 @@ impl Verifier {

// Step 6: vector check.
if let Some(vec) = opts.vector {
if vec.len() as u32 != pin.header.vec_dim {
let supplied_dim = u32::try_from(vec.len()).unwrap_or(u32::MAX);
if supplied_dim != pin.header.vec_dim {
return Err(VerifyError::ShapeMismatch {
supplied: vec.len(),
expected: pin.header.vec_dim,
Expand Down Expand Up @@ -376,8 +385,8 @@ impl LegacyV1Verifier {
}

/// Forwarded: register a public key.
pub fn add_key(&mut self, kid: &str, public_key_bytes: [u8; 32]) {
self.inner.add_key(kid, public_key_bytes);
pub fn add_key(&mut self, kid: &str, public_key_bytes: [u8; 32]) -> Result<(), VerifyError> {
self.inner.add_key(kid, public_key_bytes)
}

/// Forwarded: register a [`KeyEntry`] with optional validity window.
Expand Down Expand Up @@ -492,9 +501,11 @@ mod tests {
use crate::signer::Signer;

fn fixture(kid: &str) -> (Signer, Verifier, Vec<f32>) {
let signer = Signer::generate(kid.into());
let signer = Signer::generate(kid.into()).expect("test signer generate");
let mut verifier = Verifier::new();
verifier.add_key(signer.key_id(), signer.public_key_bytes());
verifier
.add_key(signer.key_id(), signer.public_key_bytes())
.unwrap();
let v: Vec<f32> = (0..16).map(|i| (i as f32) * 0.1).collect();
(signer, verifier, v)
}
Expand Down Expand Up @@ -532,12 +543,14 @@ mod tests {

#[test]
fn unknown_key_is_caught() {
let signer = Signer::generate("rogue".into());
let signer = Signer::generate("rogue".into()).expect("test signer generate");
let v: Vec<f32> = vec![1.0, 2.0, 3.0];
let pin = signer.pin("x", "m", v.as_slice()).unwrap();
let other = Signer::generate("prod".into());
let other = Signer::generate("prod".into()).expect("test signer generate");
let mut verifier = Verifier::new();
verifier.add_key(other.key_id(), other.public_key_bytes());
verifier
.add_key(other.key_id(), other.public_key_bytes())
.unwrap();
let err = verifier.verify_signature(&pin).unwrap_err();
assert!(matches!(err, VerifyError::UnknownKey(_)));
}
Expand All @@ -564,7 +577,7 @@ mod tests {

#[test]
fn key_expired_lower_bound() {
let signer = Signer::generate("k".into());
let signer = Signer::generate("k".into()).expect("test signer generate");
let v: Vec<f32> = vec![1.0, 2.0];
let pin = signer.pin("x", "m", v.as_slice()).unwrap();
let pin_unix = parse_v2_ts_unix(&pin.header.ts).unwrap();
Expand Down
7 changes: 5 additions & 2 deletions rust/vectorpin/tests/cross_lang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ fn run_v2_fixture(bundle: &V2Bundle, fx: &V2Fixture) {
// Round-trip and verify.
let parsed = Pin::from_json(&pin.to_json()).expect("rust parses its own JSON");
let mut verifier = Verifier::new();
verifier.add_key(&bundle.key_id, signer.public_key_bytes());
verifier
.add_key(&bundle.key_id, signer.public_key_bytes())
.unwrap();
verifier
.verify_full::<&[f32]>(&parsed, Some(&fx.input.source), None, None)
.expect("rust verifies own pin");
Expand Down Expand Up @@ -235,6 +237,7 @@ fn classify(err: &VerifyError) -> &'static str {
VerifyError::CollectionMismatch => "COLLECTION_MISMATCH",
VerifyError::TenantMismatch => "TENANT_MISMATCH",
VerifyError::UnsupportedDtype(_) => "PARSE_ERROR",
VerifyError::KeyDecodeFailed(_) => "UNKNOWN_KEY",
}
}

Expand All @@ -247,7 +250,7 @@ fn run_negative(bundle: &V2NegativeBundle, fx: &V2NegativeFixture) {
let pk: [u8; 32] = b64(&bundle.public_key_b64)
.try_into()
.expect("public key 32 bytes");
verifier.add_key(&bundle.key_id, pk);
verifier.add_key(&bundle.key_id, pk).unwrap();

// The pin may fail to parse — that itself is a PARSE_ERROR outcome.
let parsed = match Pin::from_json(&fx.pin_json) {
Expand Down
Loading
Loading