Rust SDK
github.com/CredenShare/credenshare-sdk-rust
#![forbid(unsafe_code)], with primitives from RustCrypto — aes-gcm, hkdf, p256, subtle — rather than anything hand-rolled. A crate whose whole claim is that it encrypts correctly is the wrong place to be clever.
Installing
cargo add credenshare
A git dependency also works, if you want a specific tag or an unreleased fix:
[dependencies]
credenshare = { git = "https://github.com/CredenShare/credenshare-sdk-rust", tag = "v0.1.3" }
The MSRV is 1.88, and it comes from the optional client feature's dependency chain (ureq → url → idna → icu_*) rather than from the cryptography. Dropping that feature relaxes the floor, but not by much and not to nothing — the crate has never had a green build below 1.88 in any configuration, so treat a lower toolchain as untested rather than supported:
credenshare = { default-features = false }
That drops the HTTP client and compiles only the encryption, which is what you want if you post with your own client or run somewhere a TLS stack would be dead weight.
Quickstart
use credenshare::{CredenShare, CreateParams, Field};
fn main() -> Result<(), credenshare::Error> {
let client = CredenShare::new(&std::env::var("CREDENSHARE_KEY").unwrap())?;
let share = client.create_share(CreateParams {
title: "Staging deploy credentials".into(),
fields: vec![
Field::new("Username", "deploy-bot", "text"),
Field::new("Password", "correct horse", "password"),
],
..Default::default()
})?;
println!("{}", share.link);
// https://crs.sh/aB3dEf12#1xK9...
Ok(())
}
The client is synchronous. There is no async surface and no pluggable transport.
That link is the secret. The key rides in the fragment, which browsers never transmit. Anyone holding the link can read the content; we cannot, and cannot recover it for you.
The field object
Field::new(key, value, type) is the constructor you want. If you write a struct literal instead, note the member names:
pub struct Field {
pub key: String,
pub value: String,
#[serde(rename = "type")]
pub field_type: String,
#[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
pub extra: Map<String, Value>,
}
type is a Rust keyword, so the member is field_type and serde renames it on the wire. A deserialised Field has no .type to read. extra is declared last and flattened, so the three known members keep their declaration order on the wire — which the conformance vectors depend on.
key is the visible label, not an identifier — it is what the recipient reads. field_type is one of text, password, date, multiline, markdown, source_code, exported as credenshare::FIELD_TYPES. Validation checks only that it is present, not that it is a member of that array — check against the constant if the value is dynamic.
Creating
let share = client.create_share(CreateParams {
title: "Production database".into(),
fields: vec![Field::new("Password", "s3cr3t", "password")],
passcode: Some("hunter2".into()),
..Default::default()
})?;
There is no custody option on this client. Node, Python and Go can wrap the content key to your credential's custody key on create, so the share stays readable from the dashboard; a Rust-created share is readable only from its link.
A passcode is mixed into the content key derivation, not just checked by the server — so it is not a server-side gate you could bypass, and a passcode-protected share cannot be opened from the link alone. The server receives only a one-way verifier. Send the link and the passcode over different channels.
Requires the shares:write scope.
Listing and expiring
let page = client.list_shares(50, 1)?;
client.for_each_share(100, |s| {
println!("{} {:?}", s.short_code, s.expired_at);
Ok(())
})?;
client.expire_share("aB3dEf12")?;
list_shares and get_share need shares:read; create_share and expire_share need shares:write. There is no hierarchy between them, so a key minted with only shares:write fails on a list call — the most common first surprise.
Both return metadata only, never content and never a key. A short code belonging to another account reports exactly as one that does not exist.
expire_share removes the share rather than flagging it, so a later get_share fails as not-found. A share you expired and one that never existed are indistinguishable afterwards.
link_for(short_code, content_key) assembles a recipient link for a short code you already hold, if you kept the content key.
Verifying webhooks
use credenshare::webhooks;
webhooks::verify(
raw_body, // &[u8], before any decoding
header, // the X-CredenShare-Signature value
&[&webhook_secret],
&webhooks::Options::default(),
)?;
Verify the raw body. Re-serialising decoded JSON changes the bytes — key order, spacing, escapes — and the signature will not match.
Pass both secrets while rotating. For 24 hours after a rotation, deliveries carry both signatures:
webhooks::verify(raw_body, header, &[&new_secret, &old_secret], &Default::default())?;
verify returns Result<(), VerificationError> — there is no boolean to accidentally ignore. The header name is webhooks::SIGNATURE_HEADER and the ±5-minute window is DEFAULT_TOLERANCE_SECONDS.
Configuration
use credenshare::ClientOptions;
let client = CredenShare::with_options(&credential, ClientOptions {
base_url: "https://api.credenshare.io/v1".into(),
link_origin: "https://crs.sh".into(),
timeout: std::time::Duration::from_secs(30),
max_retries: 2,
})?;
link_origin changes only the links this client assembles — what create_share returns and what link_for builds; it is never sent to the API. The SDK reads no environment variables — std::env::var above is your own read.
Rough edges
Real behaviour worth knowing before it surprises you.
Fieldgained a member, so a struct literal needs..Default::default()— or useField::new.Eqis implemented, so anEqbound is fine;Hashis not, becauseserde_json::Valuehas none, soFieldstill cannot key aHashSetorBTreeMap. Key on the(key, value, field_type)tuple for that.- A short code that is not opaque is refused, not escaped.
get_shareandexpire_sharevalidate first — 1 to 64 characters, alphanumeric plus-and_— and returnError::InvalidArgumentfor anything else, so a crafted value never reaches the wire. This is the one client that rejects rather than encodes. for_each_sharecan fail for a paging reason. It refuses a response echoing a page number other than the one requested, and caps the walk atMAX_PAGES(100,000, re-exported from the crate root), rather than trusting the server to end it. Node and Python behave the same way; Go has the page-echo guard but no ceiling.Error::Internalcarries noApiDetails, andError::details()returnsNonefor it.read_linkreturns that variant and always fails.SeedKeypair's private halves are behind accessors and zeroized on drop.seedandscalararepub(crate), reachable only throughseed()andprivate_scalar(), so the hand-writtenDebugis no longer the only thing guarding them.- A webhook secret with a trailing newline fails. The blank check trims, but the HMAC is keyed with the untrimmed string. Rust, Python and Go behave this way — trim it yourself. Node now refuses such a secret by name.
Checking your build
The crate declares a credenshare-conformance binary. Inside a clone:
cargo run --bin credenshare-conformance -- -v
The -- matters: a bare -v is consumed by cargo as its own verbosity flag and never reaches the binary.
Cargo does not build or expose a dependency's binary targets, so that command is not available to a project that merely depends on the crate. From outside a clone, either install the binary:
cargo install credenshare
or call the conformance module from your own test suite, which is the better option for a deployment gate:
let (passed, failures) = credenshare::conformance::run(false, &mut |_| {})?;
assert!(failures.is_empty(), "{passed} passed, {} failed", failures.len());
conformance::VECTORS_JSON is the fixture itself, embedded with include_str!. See Conformance vectors.