Documentation
¶
Overview ¶
Package credenshare implements client-side cryptography and the /v1 API client for CredenShare — end-to-end encrypted secret sharing.
Encryption happens on your machine. The content key never reaches CredenShare, which is what makes "we cannot read your data" a property of the system rather than a promise.
This package implements the published wire specification. The specification is normative — not this code, and not any other implementation. Where they disagree, the specification is right and this is a bug.
Why this is written from the spec rather than ported ¶
The application, this SDK and the three others are independent implementations that share no code. That is a supply-chain decision: a package the production application depended on would mean a compromised publish is a compromised application. The cost is drift, and drift here does not produce a test failure — it produces content that can never be decrypted. The conformance vectors are what hold the implementations together, and they include cases that decrypt material produced by a *different* implementation. Passing them is the only meaningful definition of correct.
Index ¶
- Constants
- Variables
- func AccessToken(contentKey []byte) (string, error)
- func ConformanceVectorsJSON() []byte
- func DecodeFragment(fragment string) ([]byte, error)
- func EncodeFragment(contentKey []byte) (string, error)
- func EncryptContent(contentKey []byte, fields []Field, opts ...EncryptOption) (string, error)
- func NewContentKey() ([]byte, error)
- func NewSeed() ([]byte, error)
- func PasscodeVerifier(passcode string) (string, error)
- func Retries(n int) *int
- func UnwrapWithSeed(wrapped string, seed []byte) ([]byte, error)
- func ValidateFields(fields []Field) error
- func WrapToPublicKey(payload, recipientPublicKey []byte, opts ...WrapOption) (string, error)
- type APIError
- type Call
- type Client
- func (c *Client) AccessLinkFor(shortCode string, seed []byte) (string, error)
- func (c *Client) CollectLinkFor(shortCode string) string
- func (c *Client) CreateRequest(ctx context.Context, params CreateRequestParams) (*SecureRequest, error)
- func (c *Client) CreateShare(ctx context.Context, params CreateParams) (*Share, error)
- func (c *Client) DeleteRequest(ctx context.Context, shortCode string) (*RequestDeletion, error)
- func (c *Client) Do(ctx context.Context, call Call) (map[string]any, error)
- func (c *Client) ExpireShare(ctx context.Context, shortCode string) error
- func (c *Client) GetRequest(ctx context.Context, shortCode string) (*RequestSummary, error)
- func (c *Client) GetShare(ctx context.Context, shortCode string) (*ShareSummary, error)
- func (c *Client) GetStats(ctx context.Context) (*Stats, error)
- func (c *Client) IterateRequests(ctx context.Context, limit int, fn func(RequestSummary) error) error
- func (c *Client) IterateShares(ctx context.Context, limit int, fn func(ShareSummary) error) error
- func (c *Client) IterateSubmissions(ctx context.Context, shortCode string, fn func(Submission) error) error
- func (c *Client) LinkFor(shortCode string, contentKey []byte) (string, error)
- func (c *Client) ListRequests(ctx context.Context, limit, page int) (*RequestPage, error)
- func (c *Client) ListShares(ctx context.Context, limit, page int) (*SharePage, error)
- func (c *Client) ListSubmissions(ctx context.Context, shortCode string) (*SubmissionPage, error)
- func (c *Client) ReadLink(_ string) ([]Field, error)
- type ConformanceCheck
- type ConformanceFailure
- type CreateParams
- type CreateRequestParams
- type Credential
- type DailyView
- type EncryptOption
- type Field
- type Options
- type RequestDeletion
- type RequestField
- type RequestPage
- type RequestSummary
- type SecureRequest
- type SeedKeypair
- type Share
- type ShareCounts
- type SharePage
- type ShareSummary
- type Stats
- type Submission
- type SubmissionPage
- type WrapOption
Constants ¶
const ( // DefaultBaseURL is the production API. DefaultBaseURL = "https://api.credenshare.io/v1" // DefaultLinkOrigin is where recipient links live. DefaultLinkOrigin = "https://crs.sh" // DefaultMaxRetries applies to network failures only, never to an HTTP status: a 5xx may // have committed and this client cannot tell. A create is safe to retry because the // Idempotency-Key and the body are both identical on the second attempt — which is the // entire reason the header is mandatory. DefaultMaxRetries = 2 // DefaultTimeout applies to each attempt when Options.Timeout is zero and no HTTPClient // with a timeout of its own is supplied. DefaultTimeout = 30 * time.Second )
const SeedLength = 32
SeedLength is the exact byte length of a secure request's seed.
Exported because a caller who stores a seed has to validate it on the way back in, and the alternative is a literal 32 in their code that nothing here would ever correct. The same figure the wire specification fixes for a content key — keyLen above is defined FROM this one so the two cannot drift — kept under its own name so that a seed and a content key remain separate ideas at a call site.
const SupportedVectorsVersion = 1
SupportedVectorsVersion is the fixture version this code was written against. A silent bump would mean every check asserts against a contract nobody wrote it for, which is worse than failing.
const Version = "0.2.0"
Version of this SDK.
Variables ¶
var ( // ErrMissingKey means a link arrived with no key at all. // // Usually something stripped the fragment: a chat client that "cleaned" the URL, a // redirect, a copy that stopped at the '#'. The remedy is to ask for the link again — not // to ask for the share to be recreated. ErrMissingKey = errors.New("no key in the link") // ErrMalformedKey means a key is present but unusable — truncated, or from a newer format. ErrMalformedKey = errors.New("the key in the link is unusable") // ErrWireFormat means content could not be read: a wrong passcode, or altered ciphertext. // The two are indistinguishable on purpose; telling them apart would hand an attacker an // oracle for guessing passcodes. ErrWireFormat = errors.New("the content could not be read") // ErrCredentialFormat means a credential is not in the expected shape. ErrCredentialFormat = errors.New("the credential is malformed") // ErrCustodySecretTransmitted fires at the request boundary if the custody secret was // about to leave the machine. If you see it, rotate the credential: the guarantee it // exists to provide — that the server *cannot* reconstruct the custody private key — is // gone the moment it reaches the wire. ErrCustodySecretTransmitted = errors.New("the custody secret was about to be transmitted") // ErrRequestSeedTransmitted fires at the create boundary if a secure request's private // seed was about to leave the machine. // // The mirror of ErrCustodySecretTransmitted, for the other secret this SDK holds that the // server must never see. A request's seed IS the ability to read its submissions: the // public half is published so submitters can seal to it, and the seed stays here, which is // what makes one submitter unable to read another's and us unable to read any of them. If // it reaches the wire that property is gone for every submission the request will ever // collect, so the remedy is to expire the request and create a new one under a new seed - // not to retry. ErrRequestSeedTransmitted = errors.New("the request seed was about to be transmitted") )
Sentinel errors, so callers can branch with errors.Is rather than on message text.
Several of these look identical on screen and have opposite remedies — a link that arrived without its key versus a link that arrived damaged; a spent plan allowance versus a rate limit. Distinguishing them is the difference between a caller who knows what to do and one who retries forever.
var ( // ErrAuthentication: the credential is unknown, revoked or expired. Mint a new one. ErrAuthentication = errors.New("the credential was not accepted") // ErrPermission: valid credential, not allowed to do this — a missing scope, or a plan // without API access. ErrPermission = errors.New("this credential may not do that") // ErrNotFound: no such share on this account. A share belonging to another account // reports identically, on purpose, so a credential cannot be used to discover what other // accounts hold. ErrNotFound = errors.New("no such share on this account") // ErrRateLimited: too many requests. Check APIError.RetryAfter. ErrRateLimited = errors.New("too many requests") // ErrQuotaExceeded: the plan's share allowance is spent. Distinct from ErrRateLimited — // waiting does not help, and the fix is a plan change or expiring old shares. ErrQuotaExceeded = errors.New("the plan's share allowance is spent") // ErrIdempotencyConflict: an Idempotency-Key was reused with a different request body. // // Almost always this means a caller passed the same key to two separate Create calls // expecting the second to be a no-op. It cannot be, and no option makes it one: // encryption is randomised per call — a fresh salt and IV every time, which AES-GCM // requires — so two calls with identical arguments, and even with the same content key, // still produce different ciphertext. The API is right to refuse. // // What the header actually protects is a NETWORK retry, where the body is byte-identical // because it is the same already-encrypted request being sent again. This client performs // those retries itself. ErrIdempotencyConflict = errors.New("this Idempotency-Key was used with a different body") // Transient and safe to retry. The API returns this rather than guessing, because // guessing "unlimited" would let an account exceed its plan and guessing "exhausted" // would break a healthy one during a billing hiccup. ErrServiceUnavailable = errors.New("the service could not resolve entitlements") // ErrDeliveryUnknown means the request reached the API but its outcome could not be // read. Distinct from ErrServiceUnavailable, which is an answer from the API saying // nothing was created; here the bytes were delivered and the server may have // committed. A CreateShare that returns this may have produced a share whose link // this process never saw. // // Do not retry with a fresh Idempotency-Key: that is how one secret becomes two, // each with its own link and audit trail. Repeat the identical request, or // reconcile by listing before retrying. ErrDeliveryUnknown = errors.New("the request was delivered and its outcome is unknown") // ErrNotSupported marks an operation this SDK deliberately does not expose. ErrNotSupported = errors.New("this operation is not exposed over the API by design") // ErrInvalidField marks a field object that does not match the wire format. ErrInvalidField = errors.New("a field is not valid") // ErrAPI is the fallback for a refusal with no more specific sentinel, so that // errors.Is(err, ErrAPI) matches every APIError rather than only some of them. ErrAPI = errors.New("the API refused this request") )
The sentinels an APIError wraps. Each names a remedy, not just a status.
var FieldTypes = []string{"text", "password", "date", "multiline", "markdown", "source_code"}
FieldTypes are the types the recipient view knows how to render (section 2.2.1).
Functions ¶
func AccessToken ¶
AccessToken derives the token the server uses to admit a reader.
The salt is empty so this is reproducible from the fragment alone, on any device, with nothing stored. The server keeps only a hash of it and learns nothing about the content key, because HKDF's domain separation makes the "access" output independent of the "content" one.
func ConformanceVectorsJSON ¶
func ConformanceVectorsJSON() []byte
ConformanceVectorsJSON returns the embedded fixture bytes, so a caller can hash them.
func DecodeFragment ¶
DecodeFragment parses a fragment back into a content key.
It returns ErrMissingKey when there is no fragment at all and ErrMalformedKey when there is one but it is not usable. The distinction is not pedantry: "your link is incomplete" and "this share expired" look identical on screen and have opposite remedies.
func EncodeFragment ¶
EncodeFragment encodes a content key as a URL fragment: "1" + base64url(key).
Bare, with a single leading version character and no "k=" prefix. A key=value appendix reads as optional and invites link-mangling clients to truncate it, and a truncated fragment must fail closed rather than look like a well-formed link missing a part.
func EncryptContent ¶
func EncryptContent(contentKey []byte, fields []Field, opts ...EncryptOption) (string, error)
EncryptContent encrypts a field array, returning the base64 blob the API accepts.
The blob uses standard base64, not base64url: it travels in a JSON body, never in a URL.
func NewContentKey ¶
NewContentKey returns a fresh 32-byte content key from the OS CSPRNG.
func NewSeed ¶ added in v0.2.0
NewSeed returns a fresh 32-byte keypair seed from the OS CSPRNG.
The same quantity of randomness as a content key, and a separate function on purpose: a seed reconstructs a P-256 keypair (section 3) and a content key encrypts content (section 2). One function serving both invites a caller to hand the wrong secret to the wrong primitive, which fails as "cannot decrypt" a long way from the mistake.
func PasscodeVerifier ¶
PasscodeVerifier derives a one-way verifier that lets the server check a passcode it cannot use.
func Retries ¶
Retries returns a pointer to n, for setting Options.MaxRetries inline.
Options.MaxRetries is a pointer so that Retries(0) genuinely disables retries; a plain int cannot distinguish "zero" from "not set".
func UnwrapWithSeed ¶
UnwrapWithSeed unwraps a payload with the seed whose public key it was wrapped to.
func ValidateFields ¶
ValidateFields checks a field array against section 2.2.1 before it is encrypted.
This exists because getting it wrong is invisible. A field object using "label" instead of "key" still encrypts, still posts, still decrypts and still renders — with every label blank and no error anywhere. Go's struct tags make the mistake harder than in the dynamic languages, but a caller building fields from a map[string]string or from JSON can still reach it, which is why the check exists here rather than only in the docs.
func WrapToPublicKey ¶
func WrapToPublicKey(payload, recipientPublicKey []byte, opts ...WrapOption) (string, error)
WrapToPublicKey wraps a payload to a published P-256 public key.
Layout: base64(0x01 || ephemeralPublic(65) || salt(16) || iv(12) || ciphertext+tag). Wrapping a 32-byte payload gives exactly 142 bytes, which is a useful field check.
The ephemeral keypair is fresh per wrap. Reusing one across wraps leaks the relationship between them.
Types ¶
type APIError ¶
type APIError struct {
Message string
Status int
// Code is the API's numeric error code, where it sends one.
Code int
// RequestID identifies the exact request in our logs. Quote it when reporting a problem.
RequestID string
// RetryAfter is seconds, set only on a rate limit.
RetryAfter int
// contains filtered or unexported fields
}
An APIError is any refusal from the API.
Use errors.As to reach Status, Code and RequestID, and errors.Is against the sentinels below to branch on what to do about it.
func (*APIError) Is ¶
Is makes ErrAPI the catch-all it is documented to be.
Unwrap alone only ever yields the SPECIFIC sentinel, so errors.Is(err, ErrAPI) matched exactly those refusals whose kind happened to be ErrAPI - four of the ten shapes - while errors.go and the README both told callers it matched every *APIError. A caller using it as the fallback arm of a type switch silently skipped 401, 403, 404, 429 and 503.
The specific sentinels keep matching through Unwrap; this only widens the catch-all.
type Call ¶ added in v0.2.0
type Call struct {
// Method is an HTTP method; empty means GET.
Method string
// Path is appended to the base URL and starts with a slash, as in "/shares".
Path string
// Body is serialised as JSON when non-nil.
Body any
// Query is appended as a query string.
Query url.Values
// Headers are applied AFTER this client's own, so a name set here — Authorization
// included — replaces what it would have sent. That is a way to break authentication
// rather than a way to act as somebody else; build a second Client for that.
Headers map[string]string
}
A Call is one request to an endpoint this SDK does not wrap. See Client.Do.
type Client ¶
type Client struct {
Credential *Credential
// contains filtered or unexported fields
}
A Client talks to the /v1 API.
func New ¶
New builds a client from a credential.
The credential accepts the two- or three-part form. With the three-part one, the custody secret stays on this machine — it derives a keypair locally and is never transmitted.
func (*Client) AccessLinkFor ¶ added in v0.2.0
AccessLinkFor is YOUR link for a secure request: the seed in the fragment, which browsers never send to a server.
Treat the result as the secret itself — it is the ability to read every submission to that request, on any device, with nothing stored. We cannot rebuild it, because the seed was never ours. Useful for turning a seed you stored at create time back into a link.
The fragment is "1" + unpadded base64url, the same encoding EncodeFragment produces for a share's content key and the same one the application's own reader parses. A hand-assembled link is where the version prefix gets left off, and a link missing it fails as though the request were gone.
func (*Client) CollectLinkFor ¶ added in v0.2.0
CollectLinkFor is the keyless collect link for a secure request — the one you hand to a human.
Deliberately without a fragment. Holding this link lets somebody SUBMIT and never read, which is what makes it safe to paste into a ticket. AccessLinkFor is the other half.
Not derivable from any API response: the /r/ segment and the origin are the application's, not the API's, so a caller assembling this by hand is guessing at both.
func (*Client) CreateRequest ¶ added in v0.2.0
func (c *Client) CreateRequest( ctx context.Context, params CreateRequestParams, ) (*SecureRequest, error)
CreateRequest creates a secure request — a keyless collect link — and RETURNS ITS SEED.
The keypair is generated on this machine. Only the public half is sent; the 32-byte seed comes back in SecureRequest.Seed and goes nowhere else, which is the whole point of the feature: submissions are sealed to a key we never held, so we can hand them to you and cannot read them ourselves. Keep the seed or the submissions are unrecoverable — by you, by us, by anybody. There is no reissue.
The seed never being transmitted is ASSERTED at the boundary rather than trusted to the field list below: the serialized body and the outgoing Idempotency-Key are both scanned for it before anything is sent, so a later edit that routes it into either one fails here with ErrRequestSeedTransmitted instead of in production.
The public key travels as UNPADDED BASE64URL while a submission's sealed blob comes back as PADDED STANDARD base64. Two encodings on one feature, which is worth knowing if you ever hand either to a decoder of your own; this SDK feeds each to the right one.
func (*Client) CreateShare ¶
CreateShare encrypts fields locally and creates a share.
Each field's Key is the visible label — not "label", "name" or "title", which the recipient view ignores silently and would render blank.
func (*Client) DeleteRequest ¶ added in v0.2.0
DeleteRequest expires a request, and deletes it on the second call.
Two-step by design, and the returned RequestDeletion says which happened rather than leaving you to infer it: "expired" on an active request — new submissions stop, the ones already received are preserved — and "deleted" when called again on an already-expired one, which removes it outright.
So a single call does NOT remove the request, and a caller who treats one call as a delete leaves the row in place. Deletion is irreversible: afterwards the sealed submissions are gone, and they were never readable by us in the first place.
A request belonging to another account reports as not-found, so this cannot be used to probe for requests elsewhere.
RequestDeletion.Outcome is empty when the API answered without one, and is deliberately not defaulted to either value. DO NOT retry on an unclear result, here or on ErrDeliveryUnknown: the second call deletes what the first one expired, and the submissions go with it. Reconcile with GetRequest instead — a request that is still listed with an expiry was expired, not deleted.
func (*Client) Do ¶ added in v0.2.0
Do performs one call against an endpoint this SDK does not wrap, returning the decoded JSON object.
The escape hatch, and it exists because the API outlives this SDK's coverage of it. A new endpoint, or a member of a response the typed methods drop, should not force a caller to reimplement authentication, retries, error mapping and the custody-secret boundary check — all of which apply here exactly as they do to CreateShare.
It does not widen what a credential may do. The same bearer token and the same scopes decide that, and the recipient read path ReadLink refuses is not on this API at all, so there is nothing here to reach it with.
A POST, PUT or PATCH is given an Idempotency-Key when Call.Headers does not carry one, generated once and repeated across this client's own retries. A GET and a DELETE are given nothing: neither endpoint reads the header, and a DELETE is idempotent by construction — see idempotencyKeyedMethods. Supply your own when you need a value you can reproduce yourself; a supplied one is never overwritten, and it is sent on whatever method you set.
func (*Client) ExpireShare ¶
ExpireShare expires a share immediately.
Irreversible: afterwards the content is unrecoverable by anyone, including CredenShare — the key was never ours, and now the ciphertext is gone too.
The share is REMOVED, not flagged. A later GetShare returns ErrNotFound rather than a row with an expiry set, and it drops out of ListShares. Worth knowing if you reconcile against your own records: a share you expired and one that never existed look identical afterwards.
A key can only expire shares its own account created. A short code belonging to somebody else reports as not-found, so this cannot be used to probe for shares elsewhere.
func (*Client) GetRequest ¶ added in v0.2.0
GetRequest returns one request's metadata.
It does not return submissions, and a request belonging to another account reports exactly as one that does not exist. A request the account has since deleted answers with its short code and nothing else rather than a 404, so an empty PublicKey and a nil ExpiredAt here mean "gone", not "never had one".
func (*Client) GetShare ¶
GetShare returns one share's metadata.
It does not consume a view, evaluate a passcode, or return content. A share belonging to another account reports exactly as one that does not exist.
func (*Client) IterateRequests ¶ added in v0.2.0
func (c *Client) IterateRequests(ctx context.Context, limit int, fn func(RequestSummary) error) error
IterateRequests walks every page, calling fn for each request.
The same loop as IterateShares, including the two things a hand-rolled version gets wrong: it does not stop on a page shorter than the limit, which the server may return in the middle of a result set, and it terminates on the counter it controls rather than on the page number the server echoes. Returning a non-nil error from fn stops the walk.
func (*Client) IterateShares ¶
IterateShares walks every page, calling fn for each share.
Written here because the hand-rolled version is usually wrong in the same way: it stops on the first page shorter than the limit, which is a page the server is entitled to return in the middle of a result set. Returning a non-nil error from fn stops the walk.
func (*Client) IterateSubmissions ¶ added in v0.2.0
func (c *Client) IterateSubmissions( ctx context.Context, shortCode string, fn func(Submission) error, ) error
IterateSubmissions calls fn for each submission to a request. Returning a non-nil error from fn stops it.
ONE HTTP call, unlike IterateShares and IterateRequests, and the difference is load-bearing rather than an inconsistency. Those two walk a paged endpoint. This one answers with every submission at once and ignores the page entirely, so a walk that asked for a second page would be handed the first one again — for a request with enough submissions, forever. The callback shape is kept so that reading submissions looks like reading shares at the call site.
func (*Client) LinkFor ¶
LinkFor assembles a recipient link.
The key lives in the fragment, which browsers never send to a server. That is what makes the link readable by its holder and opaque to us.
func (*Client) ListRequests ¶ added in v0.2.0
ListRequests returns one page of the account's requests, newest first. Metadata only.
The limit defaults to 25, which is the API's own default for every v1 list, and the server caps it at 100.
func (*Client) ListShares ¶
ListShares returns one page of the account's shares, newest first. Metadata only.
func (*Client) ListSubmissions ¶ added in v0.2.0
ListSubmissions returns the sealed submissions to a request — all of them, in one call.
Not paged, because the endpoint is not: it reads neither a page nor a limit and answers with every client-encrypted row plus a count. Asking for a second page would hand back the same rows, so this method does not offer one and IterateSubmissions does not walk.
The blobs come back sealed. Open them with Submission.Decrypt and the seed from CreateRequest — this method deliberately does not, so that a caller who only wants counts or timestamps never holds the plaintext, and so the seed appears at the call site that actually needs it.
func (*Client) ReadLink ¶
ReadLink is not implemented, on purpose.
The recipient path is deliberately absent from the API, because bearer auth skips the proof-of-work and captcha gates that protect it, and exposing it to a credential would be an enumeration bypass. Open the link in a browser, or use DecryptContent on a blob you hold.
type ConformanceCheck ¶
A ConformanceCheck is one named vector. Run returns nil on success.
func ConformanceChecks ¶
func ConformanceChecks() ([]ConformanceCheck, error)
ConformanceChecks returns every vector as an individually named check.
Returned as a slice rather than run, so a caller — the CLI, or `go test` — can report them one by one instead of stopping at the first, which matters when a derivation change breaks a whole section at once.
type ConformanceFailure ¶
A ConformanceFailure is one check that did not pass.
func RunConformance ¶
func RunConformance(verbose bool, log func(string)) (int, []ConformanceFailure, error)
RunConformance runs every check, collecting failures rather than stopping at the first.
type CreateParams ¶
type CreateParams struct {
Title string
Fields []Field
Description string
Passcode string
ExpiredAt string
AccessCountsLeft int
TimedView int
// Custody also wraps the content key to the custody public key derived from the
// credential's third part, so the share is readable from the dashboard later.
//
// Without it an API-created share is custody "none": the link is the only way back to the
// content, and losing it loses the secret. CustodyPublicKey exists to register that key,
// but until this flag there was no way to actually use it from a create.
Custody bool
// ItemKeyWrap is a wrap computed by the caller. Mutually exclusive with Custody.
ItemKeyWrap string
OrganizationID string
// IdempotencyKey is generated per call unless you set it. Setting your own does NOT make
// a second call a no-op: encryption is randomised per call, so the body differs and the
// API refuses with ErrIdempotencyConflict. That is the header working, not failing. What
// it protects is a network retry, which this client performs itself.
IdempotencyKey string
// ContentKey creates a share under a key you already hold — a link you handed out before
// the create, or a fixed key in a test. It does not make the request body reproducible.
ContentKey []byte
}
CreateParams describes a share to create.
type CreateRequestParams ¶ added in v0.2.0
type CreateRequestParams struct {
Title string
Fields []RequestField
Description string
// Passcode gates the collect link, and unlike a share's passcode IT IS SENT.
//
// Not an oversight and not a weaker choice: a share's passcode is mixed into the content
// key's derivation, so it must stay on your machine and the server gets a one-way
// verifier instead. A submission is sealed to this request's public key, and nothing
// about that encryption depends on the passcode — it is a server-side gate on who may
// open the form, so the server needs it. Do not reuse a value that protects anything
// else.
Passcode string
// ExpiredAt defaults to 30 DAYS from now when empty, applied by the API rather than
// here. Omitting it does not create a collect link that stays open forever.
ExpiredAt string
// MaxSubmission caps how many people may submit.
MaxSubmission int
// AccessCountsLeft caps how many times the link may be opened.
AccessCountsLeft int
RequiresLogin bool
RequiresMfa bool
RestrictedDomain []string
IPWhitelist []string
// OrganizationID scopes the request to a team the credential acts in.
OrganizationID string
// IdempotencyKey is generated per call unless you set it. Setting your own does NOT make
// a second call a no-op in general — but here, unlike a share, it can be: this body is
// deterministic given the same params and the same Seed, so a replay of the identical
// body returns the original short code. Change anything, including letting the seed be
// generated afresh, and the API refuses with ErrIdempotencyConflict. That is the header
// working, not failing.
//
// Do not derive it from Seed. This is a HEADER, so it leaves the machine — the boundary
// assertion in CreateRequest checks this value as well as the body, and refuses.
IdempotencyKey string
// Seed creates the request under a keypair you already hold, rather than a fresh one.
//
// The case this exists for is the custody-derived runner: take the seed from
// CustodyKeypair and hand it in, and an ephemeral container reconstructs the same read
// capability with no local state. Must be SeedLength bytes.
//
// Leave it nil and a fresh seed is generated here, returned in SecureRequest.Seed, and
// never transmitted.
Seed []byte
}
CreateRequestParams describes a secure request to create.
func (CreateRequestParams) GoString ¶ added in v0.2.0
func (p CreateRequestParams) GoString() string
GoString covers %#v, which would otherwise print the seed as []uint8{...}.
func (CreateRequestParams) LogValue ¶ added in v0.2.0
func (p CreateRequestParams) LogValue() slog.Value
LogValue withholds the seed and the passcode from log/slog.
slog reaches neither String nor MarshalJSON on its own terms: a JSON handler handed this struct resolves the []byte itself and writes the seed into the log line. This is the interface that stops it, and it is why passing params to slog.Info is safe rather than merely discouraged.
func (CreateRequestParams) MarshalJSON ¶ added in v0.2.0
func (p CreateRequestParams) MarshalJSON() ([]byte, error)
MarshalJSON withholds the seed and the passcode.
NOT the create body. The body CreateRequest sends is assembled member by member inside the method, against the names the API actually reads, so this being deliberately lossy cannot affect the wire — and the boundary assertion scans that serialized body, not this.
What it protects is the other direction: params written to a state file, an audit record or a queue message, where an exported []byte serializes as base64 with nothing at the call site looking wrong.
func (CreateRequestParams) String ¶ added in v0.2.0
func (p CreateRequestParams) String() string
String withholds the seed and the passcode.
The same four accessors SecureRequest carries, for the same reason and against a worse window: the seed is in THIS struct before the call that returns it, so the params on the way in leak precisely what the result on the way out was taught not to. A %+v of them printed the 32 bytes, %#v printed them as []uint8{...}, json.Marshal emitted them as base64 and an slog JSON handler wrote the same into the log line.
The passcode goes with the seed. It is sent — see the member — but it is a value the caller chose and may have reused, and a struct dump is how a chosen secret becomes a permanent record. Everything that is not a secret is kept, because a redaction that removes the identity is not usable by whoever is reading the log.
A VALUE receiver, not a pointer one: a pointer method is absent from a dereferenced or copied value's method set, and these params are passed BY VALUE to CreateRequest, so a pointer receiver would leave the overwhelmingly common rendering — %+v of the copy — printing the seed while the same verb on a pointer looked clean.
type Credential ¶
type Credential struct {
KeyID string
// contains filtered or unexported fields
}
A Credential is a parsed API credential: crs_sk_live_<keyId>.<authSecret>[.<custodySecret>].
The custody secret is held here but is NEVER placed in a request. It is a separate secret precisely so the server cannot reconstruct the custody private key — deriving it from the auth secret, which is transmitted on every call, would mean the server *could* decrypt. Not that it would; that it could.
func ParseCredential ¶
func ParseCredential(raw string) (*Credential, error)
ParseCredential parses the two- or three-part form.
func (*Credential) CustodyPublicKey ¶
func (c *Credential) CustodyPublicKey() (string, error)
CustodyPublicKey returns the base64url custody public key to register for account custody.
Only the public half leaves this machine. Any machine holding the credential derives the same keypair, so ephemeral runners need no local state.
func (*Credential) GoString ¶
func (c *Credential) GoString() string
GoString covers %#v, which would otherwise print the unexported fields.
func (*Credential) HasCustody ¶
func (c *Credential) HasCustody() bool
HasCustody reports whether a custody secret is present.
func (*Credential) String ¶
func (c *Credential) String() string
String never renders the secrets. A credential in a log line is a credential that has to be rotated, and %v on a struct is how that usually happens.
func (*Credential) WrapToCustody ¶
func (c *Credential) WrapToCustody(payload []byte) (string, error)
WrapToCustody wraps a payload to this credential's own custody public key.
Done here rather than by handing the secret out: the custody secret is the one value the server deliberately cannot hold, and an accessor is all it takes for it to reach a log.
type DailyView ¶ added in v0.2.0
type DailyView struct {
// Date is the API's own date string, YYYY-MM-DD, kept as a string rather than parsed:
// these are calendar buckets the server has already decided, and turning them into
// time.Time here would attach this process's location to a day that has none.
Date string `json:"date"`
Count int `json:"count"`
}
A DailyView is one day's view count.
type EncryptOption ¶
type EncryptOption func(*encryptConfig)
EncryptOption customises encryption. The unexported fixed-parameter options exist for the conformance vectors; production code cannot reach them, which is deliberate — a reused IV under the same key destroys AES-GCM's guarantees outright.
func WithPasscode ¶
func WithPasscode(passcode string) EncryptOption
WithPasscode mixes a passcode into the key derivation. The passcode itself is never sent; the server receives only a one-way verifier.
type Field ¶
type Field struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
// Extra holds members this version does not know about, so that a field written by a
// newer sender survives being read and written again here.
//
// BREAKING, from the commit that added it: a struct containing a map is not comparable,
// so `f1 == f2` no longer compiles and Field can no longer be a map key. Use
// [Field.Equal] for value equality, and key maps on Field.Key instead. The alternative
// was to keep dropping unknown members on every decrypt/re-encrypt round trip, which
// loses data silently - a worse failure than a compile error that names itself.
//
// Without it the struct is closed: decoding drops anything unrecognised, and re-encrypting
// writes the field back with those members gone. Nothing errors, and the loss is invisible
// until whoever added the member wonders where it went.
//
// json.RawMessage rather than any, so the original bytes are preserved exactly instead of
// being round-tripped through float64 and reformatted.
Extra map[string]json.RawMessage `json:"-"`
}
A Field is one labelled value in a share.
Key is the VISIBLE LABEL. It is not "label", "name" or "title" — those are silently ignored by the recipient view, which renders the field with a blank label and no error anywhere.
func DecryptContent ¶
DecryptContent decrypts a blob back into the field array.
A wrong passcode and a tampered blob are indistinguishable, deliberately: both surface as ErrWireFormat. Telling them apart would hand an attacker an oracle.
func DecryptSubmission ¶ added in v0.2.0
DecryptSubmission opens a sealed submission blob with the request's seed.
The blob comes FIRST and the seed second, matching Submission.Decrypt and the other three SDKs: the thing being opened, then the key that opens it.
The blob is unwrapped per section 4 of the wire specification, and the plaintext inside is the same field array a share carries (section 2.2.1) — so what comes back is a []Field, with unknown members preserved.
The blob is PADDED STANDARD base64: the encoding the API serves it in, and NOT the unpadded base64url the request's public key travels as. Hand it over verbatim; re-encoding it produces a blob that will not open. UnwrapWithSeed is the primitive underneath, for a payload that is not a field array.
A blob sealed to a different request's key and one that was altered are indistinguishable here, deliberately: both surface as ErrWireFormat.
func (Field) Equal ¶
Equal reports whether two fields carry the same members and values.
The replacement for `==`, which stopped compiling when Extra made Field non-comparable. Extra values are compared as raw bytes, so two fields whose unknown members differ only in JSON whitespace compare unequal - that is deliberate, since this type exists to preserve bytes it does not understand.
func (Field) MarshalJSON ¶
MarshalJSON writes key, value and type in that order, then any unknown members.
Declaration order is the wire form, so it cannot be left to encoding/json's struct ordering by accident — and unknown members are written after the three known ones, sorted, so the output is deterministic for a given field.
func (*Field) UnmarshalJSON ¶
UnmarshalJSON keeps every member, known or not.
type Options ¶
type Options struct {
BaseURL string
LinkOrigin string
HTTPClient *http.Client
// MaxRetries is a pointer so that 0 means "do not retry" rather than "unset".
// With a plain int the two are indistinguishable, and a caller who deliberately
// disabled retries silently got the default of 2 instead.
MaxRetries *int
// Timeout applies to each attempt. Zero uses DefaultTimeout. Ignored when HTTPClient
// is supplied with a timeout of its own.
Timeout time.Duration
}
Options configure a Client.
type RequestDeletion ¶ added in v0.2.0
type RequestDeletion struct {
// ShortCode is the request that was acted on. Populated from the API's echo, falling
// back to the short code that was asked for.
ShortCode string
// Outcome is "expired", "deleted", or EMPTY when the API answered without one.
//
// Empty is NOT coerced to "expired". The deployed API always sends an outcome, so an
// empty value means something changed on the other side — and inventing the
// safer-sounding of the two would tell a caller their submissions were preserved on a
// call that may have removed them.
Outcome string
}
A RequestDeletion is what DeleteRequest did.
Two members, matching the API's own answer, so a caller is told which of the two steps ran rather than left to infer it from a status code.
type RequestField ¶ added in v0.2.0
A RequestField is one prompt on the collect form.
Item is the VISIBLE PROMPT — "Staging database password" — and it is the request-side counterpart of a share Field's Key, with the same trap behind it: "label", "name" and "title" are silently ignored, and a request whose prompts are empty renders as a form of unnamed boxes to whoever you sent it to, with nothing erroring anywhere.
Type is how the prompt renders for the submitter, from the same vocabulary as a share field's — see FieldTypes. Left empty it is omitted from the request body and the API applies its documented default of "text"; that default belongs to the API, so this SDK does not restate it on the wire and cannot drift from it.
Two members and no more, unlike a share Field, which preserves members it does not know about. That is deliberate rather than an omission: the API unmarshals a request's prompts into exactly item and type, so an extra member is accepted without error and NOT STORED. A share's extras survive because they travel inside the ciphertext; a request's prompts are plaintext metadata and do not.
type RequestPage ¶ added in v0.2.0
type RequestPage struct {
Requests []RequestSummary
Page int
Limit int
Total int
TotalPages int
}
A RequestPage is one page of requests with the paging figures attached.
Same shape as SharePage, and for the same reason: a bare slice leaves a caller guessing whether more exists, and a caller who has to guess stops at the first short page.
func (*RequestPage) HasMore ¶ added in v0.2.0
func (p *RequestPage) HasMore() bool
HasMore reports whether another page exists.
Three rungs, in descending order of how much the server told us, which is the ladder Node, Python and Rust all climb. Answering false on a FULL page because the paging figures were absent is what makes a walk stop after page one and hand back a fraction of the account as though it were all of it — the truncation is silent, which is why the fallback exists rather than a bare comparison against TotalPages.
The Limit > 0 guard on the lower two rungs is load-bearing rather than defensive. A server echoing "limit": 0 is believed by intFrom, and 0 < Total is true on every page including empty ones, which converts the truncation into an unbounded run of requests — strictly worse than the bug the fallback fixes. With no limit to measure progress against, no rung may claim there is more.
type RequestSummary ¶ added in v0.2.0
type RequestSummary struct {
ShortCode string `json:"short_code"`
ExpiredAt *string `json:"expired_at"`
PublicKey string `json:"public_key"`
}
A RequestSummary is metadata for a request. Never a submission, and never a private key.
PublicKey is returned by the API and kept here deliberately, unlike anything key-shaped on a share: it is the PUBLIC half, you supplied it, and getting it back is how you confirm what was stored. It is empty on a request created before the API required one, and on the stub body the API returns for a request that has since been deleted.
type SecureRequest ¶ added in v0.2.0
type SecureRequest struct {
ShortCode string
// Seed is the 32-byte private seed for this request's keypair. KEEP IT. It is the only
// way to read the submissions — it was generated on this machine, never transmitted, and
// we cannot reissue it. Pass it to Submission.Decrypt or DecryptSubmission later, or
// feed it back through CreateRequestParams.Seed to reconstruct the same keypair.
Seed []byte
// PublicKey is the public half that was registered, unpadded base64url — the API's own
// echo of it, falling back to the value derived here when the response carried none.
//
// The echo rather than the local derivation, matching Node and Rust. This member exists to
// be quoted when reconciling against GetRequest, which returns the API's copy, and a field
// populated from our own keypair on both sides of that comparison can only ever agree with
// itself — it would document a check it does not perform.
//
// A blank echo counts as no echo and takes the fallback: an empty string is not a public
// key, and the same idiom governs RequestDeletion.ShortCode. So this is never empty on a
// successful create, whatever the API sent.
PublicKey string
// CollectLink is the keyless link you hand to a human.
//
// Safe to paste into a ticket: holding it lets somebody SUBMIT and never read. It
// carries no fragment, which is what makes that true.
CollectLink string
// AccessLink is your own link, with the seed in the fragment.
//
// TREAT IT AS THE SECRET ITSELF. Anyone holding it can read every submission to this
// request, on any device, with nothing stored — and we cannot rebuild it, because the
// seed was never ours. Withheld from every representation this type prints.
AccessLink string
ExpiredAt *string
}
A SecureRequest is a created secure request, and the only place its seed exists.
Every representation Go reaches for reflexively is redacted — String, GoString, MarshalJSON and slog.LogValue — because each of them is a route by which the seed would otherwise become a permanent plaintext record. See the methods below.
func (SecureRequest) GoString ¶ added in v0.2.0
func (r SecureRequest) GoString() string
GoString covers %#v, which would otherwise print the seed bytes.
func (SecureRequest) LogValue ¶ added in v0.2.0
func (r SecureRequest) LogValue() slog.Value
LogValue withholds the seed and the access link from log/slog.
slog reaches neither String nor MarshalJSON: a JSON handler handed this struct resolves the []byte itself and writes the seed into the log line. This is the interface that stops it, and it is why passing a SecureRequest to slog.Info is safe rather than merely discouraged.
func (SecureRequest) MarshalJSON ¶ added in v0.2.0
func (r SecureRequest) MarshalJSON() ([]byte, error)
MarshalJSON withholds the seed and the access link.
encoding/json is the path a struct most often leaves a process by — a response body, a state file, a queue message — and an exported []byte member serializes as base64 with nothing about the call site looking wrong. The collect link is kept, because it is not a secret.
Deliberately lossy: the result does not unmarshal back into a usable SecureRequest, which is the point. Store the seed yourself, on purpose, somewhere you chose.
func (SecureRequest) String ¶ added in v0.2.0
func (r SecureRequest) String() string
String withholds the seed and the access link. The seed is the read capability for every submission this request will ever collect, and %v on a struct is how a secret usually reaches a log.
A VALUE receiver, not a pointer one: a pointer method is absent from a dereferenced or copied value's method set, so a %+v of *created would print the seed bytes in full while the same verb on the pointer looked clean.
type SeedKeypair ¶
type SeedKeypair struct {
Seed []byte
Scalar *big.Int
PrivateKey *ecdh.PrivateKey
PublicKeyRaw []byte
PublicKeyB64URL string
}
A SeedKeypair is a P-256 keypair reconstructed from a 32-byte seed.
Storing the seed rather than a serialized key is what lets an entire private key live in a URL fragment, and what lets ephemeral automation derive the same key with no local state.
Three of its five members are the private key in different clothes — the seed it was derived from, the scalar itself, and the *ecdh.PrivateKey — so every representation Go reaches for reflexively is redacted: String, GoString, MarshalJSON and slog.LogValue. See the methods below. Read the private half through the members on purpose, or through PrivateKey.ECDH; do not read it out of a log line.
func CustodyKeypair ¶
func CustodyKeypair(custodySecret string) (*SeedKeypair, error)
CustodyKeypair derives the custody keypair from the third part of an API credential (section 3.1).
The custody secret is never transmitted. It is a *separate* secret from the auth secret precisely so that the server cannot reconstruct this private key: the auth secret goes over the wire on every request, so deriving custody from it would mean the server *could* decrypt. Not that it would — that it could, which is what zero-knowledge is meant to remove.
The empty salt is deliberate: the derivation has to be reproducible from the credential alone, on any machine, with nothing stored.
func KeypairFromSeed ¶
func KeypairFromSeed(seed []byte) (*SeedKeypair, error)
KeypairFromSeed derives a P-256 keypair from a 32-byte seed (section 3).
48 bytes of HKDF output rather than 32 is deliberate: the extra 128 bits make the modular bias negligible. Reducing mod n-1 and adding one yields a scalar in [1, n-1], excluding zero, which is not a valid private key.
func (SeedKeypair) GoString ¶ added in v0.2.0
func (k SeedKeypair) GoString() string
GoString covers %#v, which would otherwise print the seed as []uint8{...}.
func (SeedKeypair) LogValue ¶ added in v0.2.0
func (k SeedKeypair) LogValue() slog.Value
LogValue withholds the private halves from log/slog.
slog consults neither String nor MarshalJSON on its own terms: a JSON handler handed this struct resolves the []byte itself and writes the seed into the log line. This is the interface that stops it, and it is why passing a keypair to slog.Info is safe rather than merely discouraged.
func (SeedKeypair) MarshalJSON ¶ added in v0.2.0
func (k SeedKeypair) MarshalJSON() ([]byte, error)
MarshalJSON withholds the seed, the scalar and the private key.
encoding/json is the path a struct most often leaves a process by — a state file, an audit record, a queue message — and an exported []byte serializes as base64 with nothing about the call site looking wrong.
Deliberately lossy: the result does not unmarshal back into a usable keypair, which is the point. Persist the seed yourself, on purpose, somewhere you chose, and derive the keypair again with KeypairFromSeed. PublicKeyRaw is omitted because PublicKeyB64URL is the same 65 bytes in the encoding the API and the links use.
func (SeedKeypair) String ¶ added in v0.2.0
func (k SeedKeypair) String() string
String withholds the seed, the scalar and the private key, and prints the public half.
%v on a struct is how a private key usually reaches a log, and this struct is worse than most: Scalar is a *big.Int, which is itself a Stringer, so plain %v printed the private scalar in decimal without anybody having asked for a verbose verb.
A VALUE receiver, not a pointer one: a pointer method is absent from a dereferenced or copied value's method set, so %+v of *keypair would print the seed in full while the same verb on the pointer KeypairFromSeed returns looked clean.
type Share ¶
type Share struct {
// itself: anyone holding it can read the content, and CredenShare cannot.
Link string
// ContentKey is kept if you need to build your own link or decrypt later.
}
A Share is a created share, and the only place its link exists.
type ShareCounts ¶ added in v0.2.0
type ShareCounts struct {
// Active, Expired and TotalViewed are the API's shares.active, shares.expired and
// shares.total_viewed.
}
ShareCounts is the shares breakdown on Stats.
Its own type, nested under Stats.Shares, rather than three flattened members: the API nests them, the specification nests them, and the sibling SDKs nest them, so the same expression reads the same in all four. Flattening also puts an Active next to a view series on one struct, where "active what" stops being obvious.
type SharePage ¶
type SharePage struct {
}
A SharePage is one page of shares with the paging figures attached.
A bare slice would leave a caller guessing whether more exists, and a caller who has to guess guesses wrong — usually by stopping at the first short page.
type ShareSummary ¶
type ShareSummary struct {
}
A ShareSummary is metadata for a share. Never content, and never a key.
Deliberately thin, because the API is: /v1 returns the short code and the expiry and nothing else. There is no Title here even though you supply one on create — the server does not return it, and a field that is always empty reads as broken rather than absent.
type Stats ¶ added in v0.2.0
type Stats struct {
// is not a state the deployed API produces.
Shares ShareCounts
// DailyViews is a contiguous window, OLDEST FIRST and zero-filled — the series the
// dashboard sparkline draws. Always present and possibly empty, so an empty slice means
// no views rather than no data: do not treat length zero as "the field was absent".
//
// Zero-filled matters if you compute your own deltas. A day with no views is a bucket
// with a count of 0, not a gap, so consecutive entries are consecutive days.
DailyViews []DailyView
}
Stats is the account's usage figures.
The per-member breakdown the dashboard shows is deliberately absent from the API: a credential scoped to read statistics should not become a way to enumerate colleagues. So there is nothing to expose here, rather than something missing.
type Submission ¶ added in v0.2.0
type Submission struct {
ShortCode string
CreatedAt string
Data string
// EncryptionType is what the API says the blob is. The submissions endpoint returns only
// client-encrypted rows, and reports the ones it withheld — see
// SubmissionPage.SkippedNotEndToEndEncrypted.
EncryptionType string
}
A Submission is one sealed answer to a request.
Data is the sealed blob exactly as the API served it: STANDARD base64, padded, per section 4 of the wire specification. This is the one place on /v1 that returns content, and it is the metadata-only rule working rather than an exception to it — the blob is sealed to the request's public key, so handing it over discloses nothing to us.
Nothing here decrypts on its own. Call Decrypt with the seed you kept.
There is no expiry member. The submissions endpoint sends short_code, created_at, data and encryption_type and nothing else, and a member that is always nil reads as a broken field rather than as an absent one.
type SubmissionPage ¶ added in v0.2.0
type SubmissionPage struct {
Submissions []Submission
// Count is the API's own count member: the number of rows it RETURNED. Kept alongside
// len(Submissions) rather than replacing it, so that the two disagreeing is visible
// instead of being silently reconciled here.
//
// NOT defaulted to len(Submissions) when the API omits the member. It stays zero, which is
// as close as this language gets to the absent value Node, Python and Rust expose here.
// Filling it in from the slice would make the two figures agree by construction and
// destroy the only signal that the server's own count and its payload disagree — which is
// the single thing this member is for. The deployed endpoint always sends a count, so zero
// beside a non-empty slice means "the server did not say", not "no submissions".
Count int
// SkippedNotEndToEndEncrypted counts submissions the API withheld because they are not
// client-encrypted — legacy rows the server can actually read, which it will not return
// over a bearer-authenticated API.
//
// Surfaced rather than swallowed: a caller reconciling against their dashboard would
// otherwise see fewer submissions than it shows and have no way to learn why.
SkippedNotEndToEndEncrypted int
}
A SubmissionPage is every submission to a request.
The whole set rather than one page of it: the endpoint answers with all of them and a count, and reads neither a page nor a limit. So there are no paging figures here to expose and nothing to walk — ListSubmissions is one call, and IterateSubmissions is one call with a callback. A client that paged this endpoint would be handed the same rows again.
type WrapOption ¶
type WrapOption func(*wrapConfig)
WrapOption customises a wrap. As with EncryptOption, fixed parameters are reachable only from the conformance tests.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
credenshare-conformance
command
Command credenshare-conformance verifies an installed copy of the SDK against the packaged wire-specification vectors.
|
Command credenshare-conformance verifies an installed copy of the SDK against the packaged wire-specification vectors. |
|
Package webhooks verifies CredenShare webhook deliveries.
|
Package webhooks verifies CredenShare webhook deliveries. |