credenshare

package module
v0.1.4 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: Apache-2.0 Imports: 24 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.

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.


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.1.4 -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
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 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.1.4"

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

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

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 (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 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 {
	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 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 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