credenshare

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

README

CredenShare for Go

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.

go get github.com/CredenShare/credenshare-sdk-go
package main

import (
    "context"
    "fmt"
    "os"

    credenshare "github.com/CredenShare/credenshare-sdk-go"
)

func main() {
    client, err := credenshare.New(os.Getenv("CREDENSHARE_KEY"), nil)
    if err != nil {
        panic(err)
    }

    share, err := client.CreateShare(context.Background(), credenshare.CreateParams{
        Title: "Staging deploy credentials",
        Fields: []credenshare.Field{
            {Key: "Username", Value: "deploy-bot", Type: "text"},
            {Key: "Password", Value: "correct horse", Type: "password"},
        },
    })
    if err != nil {
        panic(err)
    }

    fmt.Println(share.Link)
    // https://crs.sh/aB3dEf12#1xK9...
}

That link is the secret. The key lives in its fragment, which browsers never transmit. Anyone holding the link can read the content; we cannot, and cannot recover it for you.

No dependencies

Standard library only. HKDF is forty lines of crypto/hmac rather than a module — a security SDK earning a dependency for that is a poor trade, and one fewer thing in your supply chain is worth more to whoever audits this than the forty lines cost.


The field object

Field.Key is the visible label, not an identifier. Go's types stop the label: spelling that catches the dynamic clients, but a caller unmarshalling from JSON with the wrong member name lands in the same place: Key empty, every field rendered blank, nothing erroring anywhere. ValidateFields refuses that before anything is sent.

Type is one of text, password, date, multiline, markdown, source_code, and decides how the recipient sees it: password is masked behind a reveal, source_code is highlighted, markdown is rendered.

A passcode

share, err := client.CreateShare(ctx, credenshare.CreateParams{
    Title:    "Production database",
    Fields:   []credenshare.Field{{Key: "Password", Value: "s3cr3t", Type: "password"}},
    Passcode: "hunter2",
})

The passcode is mixed into the key derivation and never sent. The server receives only a one-way verifier, so it can check an attempt without gaining the ability to decrypt. Share the link and the passcode over different channels — that is the point of having both.

Listing and expiring

page, err := client.ListShares(ctx, 50, 1)
fmt.Println(page.Total, page.HasMore())

err = client.IterateShares(ctx, 100, func(s credenshare.ShareSummary) error {
    fmt.Println(s.ShortCode, s.ExpiredAt)
    return nil
})

err = client.ExpireShare(ctx, "aB3dEf12")

ListShares and GetShare return metadata only — never content, never a key. A short code belonging to another account reports exactly as one that does not exist, so a credential cannot be used to discover what other accounts hold.

ExpireShare removes the share rather than flagging it: a later GetShare returns ErrNotFound rather than a row with an expiry set. Worth knowing if you reconcile against your own records — a share you expired and one that never existed look identical afterwards.

There is deliberately no method to read a share over the API. The recipient path is protected by proof-of-work and captcha gates that bearer auth skips, so exposing it to a credential would be an enumeration bypass. Open the link in a browser.

Secure requests

A share hands a secret out. A request collects one in, from someone who needs no account and no key of their own.

created, err := client.CreateRequest(ctx, credenshare.CreateRequestParams{
    Title: "New starter credentials",
    Fields: []credenshare.RequestField{
        {Item: "Staging database password", Type: "password"},
        {Item: "VPN certificate", Type: "multiline"},
    },
})

// KEEP THIS. It is the only way to read the submissions.
seed := created.Seed

fmt.Println(created.CollectLink)
// https://crs.sh/r/aB3dEf12 — hand this to a human; holding it lets them submit, never read.
// created.AccessLink is the same link with the seed in its fragment. It IS the read
// capability, so treat it as the secret rather than as a convenience.

CreateRequest generates the keypair here and sends only the public half. The 32-byte seed comes back in SecureRequest.Seed and is never transmitted — which is what lets us hand you submissions we cannot read. Lose it and they are unrecoverable, by you and by us; there is no reissue.

That the seed is never transmitted is asserted, not assumed: before anything is sent, the serialized body and the outgoing Idempotency-Key are both scanned for it as unpadded base64url, as standard base64 (padded or not) and as hex, and a match is ErrRequestSeedTransmitted rather than a request. So an IdempotencyKey derived from the seed is refused, and so is a seed pasted into a title or a prompt.

SecureRequest withholds the seed and the access link from every representation Go reaches for — String, GoString, MarshalJSON and slog.LogValue — because %v is not the only way a secret reaches a log: json.Marshal serializes an exported []byte as base64 with nothing looking wrong, and slog with a JSON handler resolves the field itself without consulting String. The collect link is kept, because it is not a secret. All four accessors take a value receiver, so a dereferenced or copied SecureRequest redacts too.

CreateRequestParams carries the same four, and that window is the wider one: the seed is in the params you pass in, before the call that hands it back, so a %+v of what you are about to send used to print exactly what a %+v of the result does not. Its Passcode is withheld with the seed — it is transmitted, but it is a value you chose and may have reused. SeedKeypair carries them too, and there three of the five members are the private key in different clothes: Scalar is a *big.Int, which is itself a Stringer, so a plain %v printed the private scalar in decimal without anybody asking for a verbose verb.

Both links are also available for a request you already hold: client.CollectLinkFor(code) and client.AccessLinkFor(code, seed). Neither is derivable from an API response — the /r/ segment and the origin are the application's, and the access fragment is version-prefixed (#1 + unpadded base64url), which is exactly the part a hand-assembled link gets wrong.

Pass CreateRequestParams.Seed to create under a keypair you already hold — it must be credenshare.SeedLength bytes, and a wrong length is ErrMalformedKey before any crypto runs. That is the custody-derived case: take the seed from CustodyKeypair and an ephemeral runner rebuilds the same read capability with no local state.

page, err := client.ListSubmissions(ctx, created.ShortCode)
for _, submission := range page.Submissions {
    // or credenshare.DecryptSubmission(submission.Data, seed) — blob first, then the key
    fields, err := submission.Decrypt(seed)
    ...
}

err = client.IterateRequests(ctx, 100, func(r credenshare.RequestSummary) error { ... })
err = client.IterateSubmissions(ctx, created.ShortCode, func(s credenshare.Submission) error { ... })

ListSubmissions takes no page and no limit, because the endpoint takes none: it answers with every client-encrypted row and a Count. That Count is the API's own figure and is not backfilled from len(page.Submissions) when a response omits it — it stays zero. The two exist to be compared, and reconciling them here would erase the only signal that the server's count and its payload disagree. A client that asked for a second page would be handed the first one again, so this one does not offer the option — IterateSubmissions is one HTTP call with a callback, not a walk. ListRequests is paged, and its limit defaults to 25, the API's own default for every v1 list.

Listing returns the blobs sealed; nothing decrypts until you say so, so a caller counting submissions never holds the plaintext. SubmissionPage.SkippedNotEndToEndEncrypted counts submissions the API withheld because it could read them itself — legacy rows it will not serve over a credential. It is reported rather than swallowed so a short answer is never a mystery.

SecureRequest.PublicKey is the API's echo of the key that was registered, falling back to the locally derived value when a response carries none — so it is never empty on a successful create. It is the value to quote when reconciling against GetRequest, and filled in from our own keypair on both sides that comparison could only ever agree with itself.

Two encodings, one feature: a request's public_key travels as unpadded base64url and a submission's blob comes back as padded standard base64. Hand a blob to Decrypt verbatim; normalising it to the other alphabet produces something that will not open.

DeleteRequest is two-step, and the RequestDeletion it returns says which step it took: Outcome is "expired" on an active request — new submissions stop, the ones received are preserved — and "deleted" when called again on an already-expired one. One call does not remove the request. An empty Outcome means the API did not say, and is deliberately not reported as either: do not retry to find out, because the second call deletes what the first one expired.

Stats

stats, err := client.GetStats(ctx)
fmt.Println(stats.Shares.Active, stats.Shares.Expired, stats.Shares.TotalViewed)
for _, day := range stats.DailyViews {   // oldest first, zero-filled
    fmt.Println(day.Date, day.Count)
}

Cheaper than paging the share list to count. 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.

Idempotency and retries

Every create carries an Idempotency-Key. It exists so a network retry cannot leave a second copy of a credential in the world, with its own link and audit trail, that you do not know about. This client performs those retries itself, repeating the byte-identical request.

Setting your own IdempotencyKey does not make a second CreateShare a no-op, and no field makes it one: encryption is randomised per call — a fresh salt and IV every time, which AES-GCM requires — so the body differs and the API refuses with ErrIdempotencyConflict. That is the header working, not failing.

Only network failures are retried. A 5xx is surfaced, because it may have committed and this client cannot tell.

A POST, PUT or PATCH gets an Idempotency-Key whether or not the method that built it remembered one, generated once per call and repeated across this client's retries. A key you supplied is never overwritten, whatever its capitalisation, and it is forwarded on any method — you may be reproducing it yourself on retry, which is the only thing the header protects.

A GET and a DELETE are given nothing. Neither endpoint reads the header; a repeated delete is idempotent by construction, because the row is gone either way; and ExpireShare's DELETE /shares/{code} has sent those exact bytes since 0.1.4, which a minor release does not get to change in exchange for an inert header.

That backstop also covers Client.Do, the escape hatch for an endpoint this SDK does not wrap:

body, err := client.Do(ctx, credenshare.Call{
    Method: http.MethodPost,
    Path:   "/some-new-endpoint",
    Body:   map[string]any{"a": 1},
})

Do gets the same authentication, retries, error mapping and custody-secret boundary check as every typed method, and widens nothing: the same bearer token and scopes decide what it may reach.


Verifying webhooks

import "github.com/CredenShare/credenshare-sdk-go/webhooks"

func handler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)   // the RAW bytes, before any decoding
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    if err := webhooks.Verify(body, r.Header.Get(webhooks.SignatureHeader),
        []string{os.Getenv("WEBHOOK_SECRET")}, nil); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    // ...
}

Two things people get wrong, both of which this package tries to make hard:

Verify the raw body. Read it with io.ReadAll and verify those bytes. Re-serialising decoded JSON changes them — key order, spacing, escapes — and the signature will not match. It is the most common reason a correct integration appears broken.

Pass both secrets while rotating. For 24 hours after you rotate, deliveries carry both signatures so you can roll your configuration without dropping anything:

webhooks.Verify(body, header, []string{newSecret, oldSecret}, nil)

Verify returns only an error. A (bool, error) signature invites ok, _ := Verify(...), and a receiver that ignores the error accepts everything while looking like it checks.


API credentials

crs_sk_live_<keyId>.<authSecret>.<custodySecret>
                                  └ never transmitted

The third part is optional and, when present, stays on your machine. It is a separate secret precisely so the server cannot reconstruct your custody 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 removes.

The bearer value is assembled from the parsed parts rather than by trimming the string, so a third part cannot survive a formatting mistake and reach the wire, and there is a second assertion at the request boundary. Credential implements both String and GoString, so neither %v nor %#v can spill a secret into a log.

key, err := client.Credential.CustodyPublicKey()  // register this; only the public half leaves

The wire specification

This SDK implements the CredenShare wire and crypto specification, which ships in this repository as CRYPTO_WIRE_SPEC.md. The specification is normative — not this code, and not any other implementation. Where they disagree, this is the bug.

Versioning, and how a release is cut, is in VERSIONING.md. Worth reading before the first one: this SDK is not on a registry yet, and the release path needs per-repository settings that do not exist yet.

The application and the four SDKs share no code, deliberately: 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 vectors are embedded with go:embed, so they travel with the binary and cannot go missing in a container that shipped only the executable:

go run github.com/CredenShare/credenshare-sdk-go/cmd/credenshare-conformance@v0.2.0 -v

Non-zero exit on failure, so it works as a deployment gate. The vectors include cases that decrypt and unwrap material produced by a different implementation — passing them means this client can read what another one wrote, which is interoperability rather than self-consistency.

One Go-specific trap, handled

encoding/json escapes <, > and & as \u003c, \u003e and \u0026 by default. No other implementation escapes, so a field containing any of them would produce a blob this client can decrypt and no other client can reproduce. This SDK turns the escaping off in both places it matters: EncryptContent, and Field's own marshaller.

The inner one is the load-bearing call, and not for the reason you might expect. encoding/json's compact pass never unescapes, so an escape written by Field.MarshalJSON survives EncryptContent's SetEscapeHTML(false) untouched — the outer setting cannot undo an inner escape. (Calling the package-level json.Marshal on a Field directly does still escape, since that compacts with escaping on. It does not affect the wire format, which only ever goes through EncryptContent.)

The conformance fixture now carries a case containing all three characters, so this is caught by the vectors rather than by folklore. A dedicated test asserts it as well, because the trap is silent: the blob decrypts perfectly here and nowhere else.

Errors

Branch with errors.Is; reach the status and request id with errors.As to *APIError.

Sentinel Means What helps
ErrMissingKey a link arrived with no key ask for the link again — something stripped it
ErrMalformedKey the key is present but unusable the link is truncated; ask for it again
ErrWireFormat wrong passcode, or altered content check the passcode. The two are indistinguishable by design
ErrAuthentication credential unknown or revoked mint a new one
ErrPermission missing scope, or a plan without API access check scopes, or upgrade
ErrQuotaExceeded the plan's share allowance is spent waiting does not help — expire old shares or change plan
ErrIdempotencyConflict a key was replayed with a different body expected on a caller-level replay; see above
ErrRateLimited too many requests wait APIError.RetryAfter seconds
ErrServiceUnavailable a real HTTP 503; entitlements could not be resolved nothing was created; retry
ErrDeliveryUnknown delivered, but no response was read it may have committed. Repeat the identical request — a fresh key here is how one secret becomes two
ErrNotFound no such share, or not yours a code from another account reads exactly like one that never existed
ErrInvalidField a field is not {Key, Value, Type} Key is the visible label — not "label", "name" or "title"
ErrNotSupported the operation is deliberately absent ReadLink — open the link in a browser instead
ErrCustodySecretTransmitted the custody secret was about to be sent rotate the credential; the zero-knowledge property is gone once it reaches the wire
ErrRequestSeedTransmitted a request seed was about to be sent expire the request and create a new one — do not retry
ErrAPI any other refusal the fallback, so errors.Is(err, ErrAPI) matches every APIError

Licence

Apache-2.0. Open source is a requirement here, not a preference: if the client performing the encryption is closed, the claim that we cannot read your data is unverifiable.

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

View Source
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
)
View Source
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.

View Source
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.

View Source
const Version = "0.2.0"

Version of this SDK.

Variables

View Source
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.

View Source
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")

	// ErrServiceUnavailable: entitlements could not be resolved, so nothing was created.
	// 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.

View Source
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

func AccessToken(contentKey []byte) (string, error)

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

func DecodeFragment(fragment string) ([]byte, error)

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

func EncodeFragment(contentKey []byte) (string, error)

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

func NewContentKey() ([]byte, error)

NewContentKey returns a fresh 32-byte content key from the OS CSPRNG.

func NewSeed added in v0.2.0

func NewSeed() ([]byte, error)

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

func PasscodeVerifier(passcode string) (string, error)

PasscodeVerifier derives a one-way verifier that lets the server check a passcode it cannot use.

func Retries

func Retries(n int) *int

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

func UnwrapWithSeed(wrapped string, seed []byte) ([]byte, error)

UnwrapWithSeed unwraps a payload with the seed whose public key it was wrapped to.

func ValidateFields

func ValidateFields(fields []Field) error

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) Error

func (e *APIError) Error() string

func (*APIError) Is

func (e *APIError) Is(target error) bool

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.

func (*APIError) Unwrap

func (e *APIError) Unwrap() error

Unwrap exposes the specific sentinel so errors.Is works:

if errors.Is(err, credenshare.ErrQuotaExceeded) { ... }

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

func New(credential string, opts *Options) (*Client, error)

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

func (c *Client) AccessLinkFor(shortCode string, seed []byte) (string, error)

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

func (c *Client) CollectLinkFor(shortCode string) string

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

func (c *Client) CreateShare(ctx context.Context, params CreateParams) (*Share, error)

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

func (c *Client) DeleteRequest(ctx context.Context, shortCode string) (*RequestDeletion, error)

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

func (c *Client) Do(ctx context.Context, call Call) (map[string]any, error)

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

func (c *Client) ExpireShare(ctx context.Context, shortCode string) error

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

func (c *Client) GetRequest(ctx context.Context, shortCode string) (*RequestSummary, error)

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

func (c *Client) GetShare(ctx context.Context, shortCode string) (*ShareSummary, error)

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) GetStats added in v0.2.0

func (c *Client) GetStats(ctx context.Context) (*Stats, error)

GetStats returns the account's usage figures.

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

func (c *Client) IterateShares(ctx context.Context, limit int, fn func(ShareSummary) error) error

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

func (c *Client) LinkFor(shortCode string, contentKey []byte) (string, error)

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

func (c *Client) ListRequests(ctx context.Context, limit, page int) (*RequestPage, error)

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

func (c *Client) ListShares(ctx context.Context, limit, page int) (*SharePage, error)

ListShares returns one page of the account's shares, newest first. Metadata only.

func (*Client) ListSubmissions added in v0.2.0

func (c *Client) ListSubmissions(
	ctx context.Context, shortCode string,
) (*SubmissionPage, error)

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 (c *Client) ReadLink(_ string) ([]Field, error)

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

type ConformanceCheck struct {
	Name string
	Run  func() error
}

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

type ConformanceFailure struct {
	Name   string
	Reason string
}

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

func DecryptContent(contentKey []byte, blob string, passcode *string) ([]Field, error)

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

func DecryptSubmission(data string, seed []byte) ([]Field, error)

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

func (f Field) Equal(other Field) bool

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

func (f Field) MarshalJSON() ([]byte, error)

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

func (f *Field) UnmarshalJSON(data []byte) error

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

type RequestField struct {
	Item string `json:"item"`
	Type string `json:"type,omitempty"`
}

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 {
	ShortCode string
	// Link is the full recipient link, INCLUDING the key fragment. Treat it as the secret
	// 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.
	ContentKey []byte
	ExpiredAt  *string
	Custody    string
}

A Share is a created share, and the only place its link exists.

func (*Share) String

func (s *Share) String() string

String withholds the link. The link carries the key, so printing a Share should not spill it into a log.

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.
	Active      int
	Expired     int
	TotalViewed int
}

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 {
	Shares     []ShareSummary
	Page       int
	Limit      int
	Total      int
	TotalPages int
}

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.

func (*SharePage) HasMore

func (p *SharePage) HasMore() bool

HasMore reports whether another page exists.

type ShareSummary

type ShareSummary struct {
	ShortCode string  `json:"short_code"`
	ExpiredAt *string `json:"expired_at"`
}

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 {
	// Shares is the counts breakdown. Zero-valued when the API sent no shares object, which
	// 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.

func (Submission) Decrypt added in v0.2.0

func (s Submission) Decrypt(seed []byte) ([]Field, error)

Decrypt opens this submission with the seed from CreateRequest.

Equivalent to DecryptSubmission(s.Data, seed); it exists so that the blob and the seed cannot be transposed at the call site.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL