Skip to main content

Public links (audience caps)

@drakkar.software/starfish-sharing (TS) / starfish-sharing (Py) — a first-class API for public links to a plaintext (encryption: "none") collection, with optional expiry and an optional, server-enforced identity allow-list.

A public link is an audience cap-cert packed into a URL #fragment. It differs from a member cap in one key way: it binds no single subject. Instead, every redeemer signs requests with their own identity key and names that key in the X-Starfish-Pub header. Consequences:

  • No private key is embedded in the link — the fragment carries only the signed cap.
  • Writes are attributable per userauth.identity is the redeemer's own userId (sha256(pub)[0:32]), not a shared anonymous identity. For an open link this is only as strong as the identity itself — see Security considerations.
  • Redeemers must already hold a Starfish identity (an Ed25519/X25519 keypair).

API

TypeScriptPython
createPublicLink(opts){ fragment, cap }create_public_link(...)PublicLink(fragment, cap)
parsePublicLink(fragment){ cap }parse_public_link(fragment)ParsedPublicLink(cap)
redeemPublicLink(parsed, opts) → header setredeem_public_link(parsed, ...) → dict
mintAudienceCap(...) / assertAudienceCapShape(cert)mint_audience_cap(...) / assert_audience_cap_shape(cert)

parsePublicLink decodes + shape-checks the embedded cap; it does not verify the signature or expiry — that is the server's job at request time.

Open vs restricted

The allow-list is optional:

  • Omit allowedIdentities → an open link: any identity may redeem (each still signs as itself).
  • Provide allowedIdentities (a list of redeemer Ed25519 pubkeys, 64-char lowercase hex) → only those identities may redeem. The server checks the presenter's key against the cap's aud and returns 403 otherwise.

Expiry

Pass expiresAt (absolute unix seconds) or ttlSec (duration). expiresAt wins when both are given; both map to the cap's exp and are enforced by verifyCapCert (±300s clock skew). With neither set, the default 30-day TTL applies. (mintMemberCap accepts expiresAt too.)

Owner: mint and share

import { createPublicLink, scopes } from "@drakkar.software/starfish-sharing"

const link = await createPublicLink({
issEdPrivHex: owner.edPriv,
issEdPubHex: owner.edPub,
collection: "broadcast",
scope: scopes.readOnly("broadcast"), // or scopes.writer("broadcast")
allowedIdentities: [bob.edPub, carol.edPub], // optional; omit for "anyone"
ttlSec: 7 * 24 * 3600, // optional; or expiresAt
})
// Share: `https://app.example/#${link.fragment}`

The #fragment is never sent to the server, logged, or placed in Referer.

Redeemer: parse and sign as self

import { parsePublicLink, redeemPublicLink } from "@drakkar.software/starfish-sharing"

const parsed = parsePublicLink(fragment)
const headers = await redeemPublicLink(parsed, {
redeemerEdPrivHex: bob.edPriv,
redeemerEdPubHex: bob.edPub,
method: "GET",
pathAndQuery: "/pull/broadcast/post-1",
host: "api.example.com",
})
// headers: { Authorization: "Cap …", "X-Starfish-Sig", "X-Starfish-Ts",
// "X-Starfish-Nonce", "X-Starfish-Pub" }

Using StarfishClient directly? Have your capProvider.getCap() return pubHex (the redeemer's own pubkey) alongside cap + devEdPrivHex; the client then emits X-Starfish-Pub automatically.

Server wiring

The resolver accepts audience caps only when sharingServerPlugin is installed (strict-kind dispatch rejects an unregistered kind):

createCapCertRoleResolver({ nonceCache, revocationStore, plugins: [sharingServerPlugin] })

At request time the resolver: verifies the per-request signature against X-Starfish-Pub; if the cap carries aud, requires the presenter to be a member (403 otherwise); keys replay protection by the presenter pubkey; binds auth.identity to the presenter's userId; and synthesizes cap:<op>:<col> plus delegated:<issUserId>:<col> roles.

Browser/CORS: X-Starfish-Pub is a new request header. If a browser redeems public links cross-origin, add it to your server's CORS Access-Control-Allow-Headers alongside the existing X-Starfish-Sig / X-Starfish-Ts / X-Starfish-Nonce (or use a wildcard, as the example app does). The built-in CORS middleware's default header list does not include any X-Starfish-* header, so apps with browser clients already configure this.

Revocation

An audience cap has no single subject, so revocation is whole-link: post a signed RevocationList entry keyed on the cap's nonce with sub: "" (buildRevocationList / build_revocation_list), or re-mint the link with a trimmed allowedIdentities. The _members directory, publishMemberCap, and evictMember apply to single-subject member caps only — not audience caps.

Use sub: "" only in the per-nonce revoked list. Putting sub: "" in revokedSubjects (subject-wide revoke) would revoke every audience cap from that issuer at once — almost never what you want.

Security considerations

A public link removes the embedded-key risk of a shared credential, but it is still a grant anyone holding the link can present. Keep these in mind:

  • An open writer link grants collection-wide writes. Authorization is confined only by the cap's scope.paths, and scopes.writer(col) allows all of <col>/** (minus _keyring / _members). With no allowedIdentities, any identity can therefore write anywhere in the collection. To confine writers, either set allowedIdentities, or pass an {identity}-templated scope (paths like <col>/{identity}/**) so each redeemer is limited to their own subtree — the resolver substitutes {identity} with the redeemer's own userId. Prefer read-only (scopes.readOnly) for open links.

  • Attribution is only as strong as the identity. auth.identity is the redeemer's own key, but anyone can generate a fresh keypair, so an open link's writes are attributable only to a self-asserted, possibly throwaway identity. Attribution — and any per-identity rate limiting — is meaningful only for restricted links, where allowedIdentities lists keys you already trust.

  • A cap grants authority, not decryption. Public links target encryption: "none" collections. Pointing one at an encryption: "delegated" collection authorizes pulling ciphertext (and its metadata — timestamps, hashes, document structure) but hands over no content key, so redeemers still can't read the plaintext. A public link is not a substitute for adding a keyring recipient (addRecipient).

  • Use short TTLs. The 30-day default suits a member cap, not an "anyone" link. Set a tight ttlSec / expiresAt and revoke when done. Revocation is whole-link and depends on the signed RevocationList reaching the server; until then a cap is honored to exp (+300s skew).

Cross-language guarantees

Audience cap canonicalization, signatures, link fragments, and the redeemed request signature are byte-for-byte identical across TypeScript and Python. The audienceCapOpen / audienceCapRestricted cases in tests/test-vectors/cap-cert.json lock the canonical signing input and signature.