Skip to content
Concepts

Concepts

Worker-safe bearer-key generation and hashing primitives

Ear Tag is an independent, modular key server for TypeScript hosts. Its base entrypoint creates cryptographically random bearer keys and SHA-256 digests, so a host can issue a secret like an invite code or an API key without inventing its own random-string and hashing logic. ./grants builds a typed capability-grant vocabulary, and ./keys runs a tenant-scoped key store on top of it: issuance, verification, compare-and-swap grant edits, revocation, optional expiry, owner suspension and a per-key rate limit. The owner decides scope, storage and policy; Ear Tag owns the mechanics.

generateKey(options) returns a random bearer string. An optional prefix adds <prefix>_ before the random portion. length controls the random portion’s length. hashKey(key) returns its SHA-256 digest as lowercase hexadecimal. generateKeyAndHash(options) creates both values and returns { key, digest }.

./grants declares one host’s capability vocabulary and returns the schemas and operations closed over it:

TS
import { defineGrants } from "@fungi.computer/ear-tag/grants";
const vocabulary = defineGrants({
capabilities: ["files.read", "files.write"] as const,
});

./keys runs a tenant-scoped key store over that vocabulary. sql is the exec(statement, ...bindings) -> { toArray() } shape Cloudflare Durable Object SQLite already provides; ./keys/node’s fromNodeSqlite adapts a node:sqlite database for hosts that run outside a Durable Object:

TS
import { createKeyStore } from "@fungi.computer/ear-tag/keys";
import { fromNodeSqlite } from "@fungi.computer/ear-tag/keys/node";
import { DatabaseSync } from "node:sqlite";
const store = createKeyStore({
sql: fromNodeSqlite(new DatabaseSync(":memory:")),
commit: (body) => Promise.resolve(body()),
vocabulary,
prefix: "demo",
authorizeIssuer: async () => ({ kind: "unrestricted" }),
});
const issued = await store.issue("owner-1", {
name: "ci bot",
grants: [{ capability: "files.read", targets: { kind: "all" } }],
});

Every operation returns a KeyOutcome: { kind: "ok", value } or { kind: "error", error: { code, retryAfterSeconds? } }, with code one of invalid (the caller’s own request failed validation), denied (well-formed but refused on its merits), conflict, expired, rate_limited or unavailable (the store’s own state could not be read back; always logged). One store instance is one tenant; namespacing between tenants is the host’s own table or storage boundary.

authorizeIssuer’s "bounded" only narrows which grants an actor may assign through issue or updateGrants. It does not narrow which keys that actor may list, revoke or edit: any actor it resolves to a non-null authority for may manage every key in the tenant, including keys another actor issued. This matches Hub’s own authorizeManage. implies is one level deep, not transitive; both grantsAllow and grantsList honor it without widening a selected grant’s targets.

Hosts may enable delegated issuance with delegation: { manage }. Passing a parent principal to issue or updateGrants requires that key to be live, owned by the same human, hold the management capability, and cover every grant being assigned. A child cannot outlive its parent at issuance. Revoking the parent later does not revoke an already issued child; the human’s suspension still applies to both.

live({ keyId, grantVersion? }) rechecks the human issuer and the durable key row without spending a rate slot. A version pin refuses a stale grant set. current(principal) is synchronous for use inside a host resource transaction; it checks the row and version, not asynchronous human membership. verify(secret) consumes a rate slot; admitUse rechecks live authority and records an exact use receipt. Metadata reads never select the digest.

An optional epoch: { begin, finish, abort } fences grant edits and revokes against an external admission owner. Each method receives the same decision id. A local durable receipt distinguishes an aborted commit from a lost commit acknowledgement; exact retries finish the committed decision without mutating again. Aborted decisions are terminal: retrying an aborted request does not reopen it. Recovery after an abort requires a new requestId, which names a new epoch decision. A failed finish of a committed request instead retries with the original requestId and only finishes that decision. onAuthorityChange runs inside the local commit and carries the transition kind and throughGrantVersion: the replaced version for a grant edit, the current version for revocation, suspension or closure.

close(closedAt) is synchronous and runs in the host’s own transaction. It permanently closes the tenant and revokes its keys; reopening the store does not permit later issuance or grant edits. Hosts must check its outcome before committing their enclosing transaction.

keyWire(vocabulary) provides the shared issue, updateGrants and revoke request decoders plus toSummary(record). Summaries contain grants and lifecycle metadata, never a digest or human owner id. Hosts retain their name, identity, expiry, rate-policy and transport authorization rules.