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 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 Client
- func (c *Client) CreateShare(ctx context.Context, params CreateParams) (*Share, error)
- func (c *Client) ExpireShare(ctx context.Context, shortCode string) error
- func (c *Client) GetShare(ctx context.Context, shortCode string) (*ShareSummary, error)
- func (c *Client) IterateShares(ctx context.Context, limit int, fn func(ShareSummary) error) error
- func (c *Client) LinkFor(shortCode string, contentKey []byte) (string, error)
- func (c *Client) ListShares(ctx context.Context, limit, page int) (*SharePage, error)
- func (c *Client) ReadLink(_ string) ([]Field, error)
- type ConformanceCheck
- type ConformanceFailure
- type CreateParams
- type Credential
- type EncryptOption
- type Field
- type Options
- type SeedKeypair
- type Share
- type SharePage
- type ShareSummary
- 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 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.1.4"
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") )
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 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 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) 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) 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) 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) 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) 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) ListShares ¶
ListShares returns one page of the account's shares, newest first. Metadata only.
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 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 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 (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 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.
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.
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 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 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. |