Skip to content
Closed
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
54 changes: 54 additions & 0 deletions rust/Cargo.lock

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

2 changes: 2 additions & 0 deletions rust/vectorpin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ unicode-normalization = { workspace = true }
thiserror = { workspace = true }
hex = { workspace = true }
rand = "0.8"
zeroize = "1"
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "std"] }

[dev-dependencies]
# Tests reuse the same rand version as the crate.
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()).unwrap();
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()).unwrap();
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()).unwrap();
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 @@ -9,7 +9,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("non-empty kid");
let pin = signer
.pin(source, "text-embedding-3-large", embedding.as_slice())
.expect("pin creation");
Expand All @@ -19,7 +19,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())
.expect("valid public key");

// 1. honest verify
let r = verifier.verify_full::<&[f32]>(&pin, Some(source), Some(embedding.as_slice()), None);
Expand All @@ -41,7 +43,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("non-empty kid");
let rogue_pin = rogue
.pin(source, "m", embedding.as_slice())
.expect("rogue pin");
Expand Down
52 changes: 36 additions & 16 deletions rust/vectorpin/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
//! ```
//! use vectorpin::{Pin, Signer};
//!
//! let signer = Signer::generate("demo".to_string());
//! let signer = Signer::generate("demo".to_string()).unwrap();
//! let v: Vec<f32> = vec![1.0, 2.0, 3.0];
//! let pin = signer.pin("hello", "test-model", v.as_slice()).unwrap();
//!
Expand Down Expand Up @@ -144,12 +144,12 @@ impl PinHeader {
/// ```
/// use vectorpin::{Pin, Signer, Verifier};
///
/// let signer = Signer::generate("k1".to_string());
/// let signer = Signer::generate("k1".to_string()).unwrap();
/// let v: Vec<f32> = vec![1.0, 2.0, 3.0];
/// let pin = signer.pin("hello", "m", v.as_slice()).unwrap();
///
/// 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();
/// assert!(verifier.verify_signature(&Pin::from_json(&pin.to_json()).unwrap()).is_ok());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -194,6 +194,12 @@ pub enum AttestationError {
/// A required field was missing from the pin JSON.
#[error("missing required field: {0}")]
MissingField(&'static str),
/// The `extra` map contained a value that was not a JSON string.
/// The wire format only permits string values; non-string values
/// used to be silently dropped and are now rejected so callers see
/// the malformed input.
#[error("extra map value for key {0:?} is not a string")]
ExtraTypeMismatch(String),
}

impl Pin {
Expand Down Expand Up @@ -280,6 +286,26 @@ impl Pin {
.ok_or(AttestationError::MissingField(name))
}

let extra: BTreeMap<String, String> = match obj.get("extra") {
None => BTreeMap::new(),
Some(serde_json::Value::Object(m)) => {
let mut out = BTreeMap::new();
for (k, v) in m {
match v.as_str() {
Some(s) => {
out.insert(k.clone(), s.to_owned());
}
None => {
return Err(AttestationError::ExtraTypeMismatch(k.clone()));
}
}
}
out
}
// Anything other than absent-or-object for `extra` is malformed.
Some(_) => return Err(AttestationError::MissingField("extra")),
};

let header = PinHeader {
v,
model: s_field(obj, "model")?,
Expand All @@ -290,20 +316,14 @@ impl Pin {
source_hash: s_field(obj, "source_hash")?,
vec_hash: s_field(obj, "vec_hash")?,
vec_dtype: s_field(obj, "vec_dtype")?,
vec_dim: obj
.get("vec_dim")
.and_then(|x| x.as_u64())
.ok_or(AttestationError::MissingField("vec_dim"))? as u32,
vec_dim: u32::try_from(
obj.get("vec_dim")
.and_then(|x| x.as_u64())
.ok_or(AttestationError::MissingField("vec_dim"))?,
)
.map_err(|_| AttestationError::MissingField("vec_dim"))?,
ts: s_field(obj, "ts")?,
extra: obj
.get("extra")
.and_then(|x| x.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default(),
extra,
};

let kid = s_field(obj, "kid")?;
Expand Down
24 changes: 22 additions & 2 deletions rust/vectorpin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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("non-empty kid");
//! 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 @@ -40,7 +40,7 @@
//! // 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 @@ -125,6 +125,26 @@
#![warn(rust_2018_idioms)]
#![warn(rustdoc::broken_intra_doc_links)]
#![warn(rustdoc::missing_crate_level_docs)]
#![forbid(unsafe_code)]

// CHANGELOG (security hardening, branch `security/p2-hardening`):
// * BREAKING: `Signer::private_key_bytes` now returns
// `zeroize::Zeroizing<[u8; 32]>` (was `[u8; 32]`). The seed is zeroed
// on drop; deref to `&[u8; 32]` to use it.
// * BREAKING: `Signer::generate` now returns
// `Result<Self, SignerError>` (was panic on empty `key_id`). Empty
// `key_id` yields `SignerError::EmptyKeyId`.
// * BREAKING: `Verifier::add_key` now returns
// `Result<(), VerifyError>` (was silently dropping malformed public
// keys). Bad keys yield `VerifyError::KeyDecodeFailed`.
// * BREAKING: `Pin::from_json` now rejects pins whose `extra` map
// contains non-string values with `AttestationError::ExtraTypeMismatch`
// (previously silently dropped).
// * Internal: `vec_dim` cast is now checked (`u32::try_from`); oversize
// vectors return `SignerError::InvalidVector` or are treated as a
// shape mismatch on the verifier side.
// * Internal: timestamp formatting now uses the `time` crate.
// * Internal: `#![forbid(unsafe_code)]` applied to the crate.

pub mod attestation;
pub mod hash;
Expand Down
Loading
Loading