@fungi.computer/ear-tag 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/dist/grants.d.ts +74 -0
- package/dist/grants.js +113 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +17 -0
- package/dist/keys-node.d.ts +8 -0
- package/dist/keys-node.js +22 -0
- package/dist/keys.d.ts +242 -0
- package/dist/keys.js +567 -0
- package/docs/api/README.md +16 -0
- package/docs/api/grants.md +191 -0
- package/docs/api/index.md +100 -0
- package/docs/api/keys-node.md +31 -0
- package/docs/api/keys.md +406 -0
- package/package.json +75 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ear Tag (`@fungi.computer/ear-tag`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ear Tag is an independent, modular key server for TypeScript hosts. Its base
|
|
4
|
+
entrypoint creates cryptographically random bearer keys and SHA-256 digests, so
|
|
5
|
+
a host can issue a secret like an invite code or an API key without inventing
|
|
6
|
+
its own random-string and hashing logic. `./grants` builds a typed
|
|
7
|
+
capability-grant vocabulary, and `./keys` runs a tenant-scoped key store on top
|
|
8
|
+
of it: issuance, verification, compare-and-swap grant edits, revocation,
|
|
9
|
+
optional expiry, owner suspension and a per-key rate limit. The owner decides
|
|
10
|
+
scope, storage and policy; Ear Tag owns the mechanics.
|
|
11
|
+
|
|
12
|
+
## Install or set up
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @fungi.computer/ear-tag
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The package ships ESM only and depends on [Cairn](../cairn/README.md) for
|
|
19
|
+
identifiers and random strings, and on `zod` for its schemas. Hashing uses the
|
|
20
|
+
global Web Crypto API (`crypto.subtle.digest`), available in Node 19+ and in
|
|
21
|
+
Workers runtimes. It has four entrypoints: `@fungi.computer/ear-tag` (the crypto
|
|
22
|
+
primitives), `@fungi.computer/ear-tag/grants` (the grant vocabulary),
|
|
23
|
+
`@fungi.computer/ear-tag/keys` (the key store, free of any Node-specific type)
|
|
24
|
+
and `@fungi.computer/ear-tag/keys/node` (the `node:sqlite` adapter, so a Worker
|
|
25
|
+
consumer of `./keys` never needs `@types/node` to typecheck).
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
This helper creates a prefixed key, stores its digest, then hands the plaintext
|
|
30
|
+
to the host's authorized delivery function. The host supplies both operations;
|
|
31
|
+
neither the key nor the digest is logged:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { generateKeyAndHash } from "@fungi.computer/ear-tag";
|
|
35
|
+
|
|
36
|
+
export async function issueInvite(
|
|
37
|
+
storeDigest: (digest: string) => Promise<void>,
|
|
38
|
+
deliverKey: (key: string) => Promise<void>,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const { key, digest } = await generateKeyAndHash({ prefix: "invite" });
|
|
41
|
+
await storeDigest(digest);
|
|
42
|
+
await deliverKey(key);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The existing tests verify that a generated digest matches the plaintext key in
|
|
47
|
+
[`test/crypto.test.ts`](test/crypto.test.ts).
|
|
48
|
+
|
|
49
|
+
## Core concepts
|
|
50
|
+
|
|
51
|
+
`generateKey(options)` returns a random bearer string. An optional prefix adds
|
|
52
|
+
`<prefix>_` before the random portion. `length` controls the random portion's
|
|
53
|
+
length. `hashKey(key)` returns its SHA-256 digest as lowercase hexadecimal.
|
|
54
|
+
`generateKeyAndHash(options)` creates both values and returns `{ key, digest }`.
|
|
55
|
+
|
|
56
|
+
## Grants and the key store
|
|
57
|
+
|
|
58
|
+
`./grants` declares one host's capability vocabulary and returns the schemas and
|
|
59
|
+
operations closed over it:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { defineGrants } from "@fungi.computer/ear-tag/grants";
|
|
63
|
+
|
|
64
|
+
const vocabulary = defineGrants({
|
|
65
|
+
capabilities: ["files.read", "files.write"] as const,
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`./keys` runs a tenant-scoped key store over that vocabulary. `sql` is the
|
|
70
|
+
`exec(statement, ...bindings) -> { toArray() }` shape Cloudflare Durable Object
|
|
71
|
+
SQLite already provides; `./keys/node`'s `fromNodeSqlite` adapts a `node:sqlite`
|
|
72
|
+
database for hosts that run outside a Durable Object:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createKeyStore } from "@fungi.computer/ear-tag/keys";
|
|
76
|
+
import { fromNodeSqlite } from "@fungi.computer/ear-tag/keys/node";
|
|
77
|
+
import { DatabaseSync } from "node:sqlite";
|
|
78
|
+
|
|
79
|
+
const store = createKeyStore({
|
|
80
|
+
sql: fromNodeSqlite(new DatabaseSync(":memory:")),
|
|
81
|
+
commit: (body) => Promise.resolve(body()),
|
|
82
|
+
vocabulary,
|
|
83
|
+
prefix: "demo",
|
|
84
|
+
authorizeIssuer: async () => ({ kind: "unrestricted" }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const issued = await store.issue("owner-1", {
|
|
88
|
+
name: "ci bot",
|
|
89
|
+
grants: [{ capability: "files.read", targets: { kind: "all" } }],
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every operation returns a `KeyOutcome`: `{ kind: "ok", value }` or
|
|
94
|
+
`{ kind: "error", error: { code, retryAfterSeconds? } }`, with `code` one of
|
|
95
|
+
`invalid` (the caller's own request failed validation), `denied` (well-formed
|
|
96
|
+
but refused on its merits), `conflict`, `expired`, `rate_limited` or
|
|
97
|
+
`unavailable` (the store's own state could not be read back; always logged). One
|
|
98
|
+
store instance is one tenant; namespacing between tenants is the host's own
|
|
99
|
+
table or storage boundary.
|
|
100
|
+
|
|
101
|
+
`authorizeIssuer`'s `"bounded"` only narrows which grants an actor may assign
|
|
102
|
+
through `issue` or `updateGrants`. It does not narrow which keys that actor may
|
|
103
|
+
`list`, `revoke` or edit: any actor it resolves to a non-null authority for may
|
|
104
|
+
manage every key in the tenant, including keys another actor issued. This
|
|
105
|
+
matches Hub's own `authorizeManage`. `implies` is one level deep, not
|
|
106
|
+
transitive.
|
|
107
|
+
|
|
108
|
+
## API reference
|
|
109
|
+
|
|
110
|
+
See the generated API reference for [the crypto primitives](docs/api/index.md),
|
|
111
|
+
[`./grants`](docs/api/grants.md), [`./keys`](docs/api/keys.md) and
|
|
112
|
+
[`./keys/node`](docs/api/keys-node.md), or start from
|
|
113
|
+
[the index](docs/api/README.md).
|
|
114
|
+
|
|
115
|
+
## Examples and checks
|
|
116
|
+
|
|
117
|
+
The crypto tests cover hashing, prefixed generation, and the key/digest pair.
|
|
118
|
+
The grants and key-store tests cover issuance, verification, compare-and-swap
|
|
119
|
+
grant edits and replay, authority-change fencing, a stale captured principal,
|
|
120
|
+
revocation, optional expiry, owner suspension, the per-key rate limit, bounded
|
|
121
|
+
sub-issuance and bounded grant edits, and `admitUse` on an expired or suspended
|
|
122
|
+
key, run through the exact same assertions against two backends: a real
|
|
123
|
+
Cloudflare Durable Object (`pnpm run test:cloudflare`, real `state.storage.sql`
|
|
124
|
+
and `transactionSync`, not an adapter standing in for them) and the package's
|
|
125
|
+
own `node:sqlite` adapter (`pnpm run test:node`). Run everything with:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
pnpm --filter @fungi.computer/ear-tag test
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Limits and errors
|
|
132
|
+
|
|
133
|
+
A bearer key is a secret: anyone who obtains it can present it to the system
|
|
134
|
+
that accepts it. Do not log or persist plaintext keys. Retain the digest and
|
|
135
|
+
give the plaintext only to its intended recipient.
|
|
136
|
+
|
|
137
|
+
`hashKey` requires Web Crypto's `crypto.subtle.digest`. Key length follows
|
|
138
|
+
Cairn's raw `nanoid` sizing behavior. Callers should supply a bounded
|
|
139
|
+
nonnegative integer rather than untrusted sizing input.
|
|
140
|
+
|
|
141
|
+
The key store's rate limit is counted in memory, not persisted; it resets if the
|
|
142
|
+
host recreates the store (for example a Durable Object waking from eviction). A
|
|
143
|
+
`KeyPrincipal` returned by `verify` is a plain, unsealed data object: `current`
|
|
144
|
+
and `admitUse` accept one back to resume a session cheaply, but they re-read the
|
|
145
|
+
live key row and its grant version on every call, so a fabricated or stale
|
|
146
|
+
principal is refused rather than trusted.
|
|
147
|
+
|
|
148
|
+
## Contribute and license
|
|
149
|
+
|
|
150
|
+
Ear Tag is MIT licensed. See [`LICENSE`](LICENSE).
|
package/dist/grants.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** How a grant's capability reaches resources. */
|
|
3
|
+
export type GrantTargets = Readonly<{
|
|
4
|
+
/** The capability reaches every resource, now and later. */
|
|
5
|
+
kind: "all";
|
|
6
|
+
}> | Readonly<{
|
|
7
|
+
/** The capability reaches only these resources; a later resource never joins implicitly. */
|
|
8
|
+
kind: "selected";
|
|
9
|
+
/** Bounded, explicitly chosen resource identifiers this grant covers. */
|
|
10
|
+
ids: readonly string[];
|
|
11
|
+
}>;
|
|
12
|
+
/** One capability bound to the resources it may act on. */
|
|
13
|
+
export type Grant<Capability extends string = string> = Readonly<{
|
|
14
|
+
/** Capability name drawn from the host's declared vocabulary. */
|
|
15
|
+
capability: Capability;
|
|
16
|
+
/** Resources this grant reaches. */
|
|
17
|
+
targets: GrantTargets;
|
|
18
|
+
}>;
|
|
19
|
+
/** The bounded authority one key carries: 1 to 64 grants. */
|
|
20
|
+
export type Scope<Capability extends string = string> = Readonly<{
|
|
21
|
+
/** Every grant the key carries. */
|
|
22
|
+
grants: readonly Grant<Capability>[];
|
|
23
|
+
}>;
|
|
24
|
+
/** Zod schemas and grant operations closed over one host's capability vocabulary. */
|
|
25
|
+
export type GrantVocabulary<Capability extends string> = Readonly<{
|
|
26
|
+
/** Validates one capability name against the declared vocabulary. */
|
|
27
|
+
capabilityNameSchema: z.ZodType<Capability>;
|
|
28
|
+
/** Validates one grant: a vocabulary capability bound to its targets. */
|
|
29
|
+
grantSchema: z.ZodType<Grant<Capability>>;
|
|
30
|
+
/** Validates a bounded, 1-to-64-grant scope. */
|
|
31
|
+
scopeSchema: z.ZodType<Scope<Capability>>;
|
|
32
|
+
/** Dedupes and sorts grants into one canonical, order-independent form. */
|
|
33
|
+
canonicalGrants: (grants: readonly Grant<Capability>[]) => readonly Grant<Capability>[];
|
|
34
|
+
/** Whether some grant admits this capability against this optional resource. */
|
|
35
|
+
grantsAllow: (grants: readonly Grant<Capability>[], capability: Capability, resourceId?: string) => boolean;
|
|
36
|
+
/** Whether some grant admits this capability for at least one resource, without naming one. */
|
|
37
|
+
grantsList: (grants: readonly Grant<Capability>[], capability: Capability) => boolean;
|
|
38
|
+
/** Whether every grant in `child` is already admitted by `parent`. */
|
|
39
|
+
isSubset: (child: readonly Grant<Capability>[], parent: readonly Grant<Capability>[]) => boolean;
|
|
40
|
+
}>;
|
|
41
|
+
/** A host's capability vocabulary and its optional implied-capability classes. */
|
|
42
|
+
export type GrantsDefinition<Capability extends readonly [string, ...string[]]> = Readonly<{
|
|
43
|
+
/** Every capability name a grant may name; at least one. */
|
|
44
|
+
capabilities: Capability;
|
|
45
|
+
/**
|
|
46
|
+
* Extra capabilities a grant's capability also admits, for example a
|
|
47
|
+
* manage capability that also admits a narrower read capability. Every
|
|
48
|
+
* key and value must itself be a declared capability.
|
|
49
|
+
*
|
|
50
|
+
* One level deep, not transitive: if `a` implies `b` and `b` implies `c`,
|
|
51
|
+
* a grant of `a` does not reach `c` unless `a` names `c` directly too.
|
|
52
|
+
*/
|
|
53
|
+
implies?: Readonly<Partial<Record<Capability[number], readonly Capability[number][]>>>;
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* Rejected when a host's capability vocabulary violates `defineGrants`'
|
|
57
|
+
* invariants.
|
|
58
|
+
*
|
|
59
|
+
* This error retains neither the rejected definition nor a foreign cause.
|
|
60
|
+
*/
|
|
61
|
+
export declare class GrantsDefinitionError extends Error {
|
|
62
|
+
/** Stable public error name. */
|
|
63
|
+
readonly name = "GrantsDefinitionError";
|
|
64
|
+
/** Creates a fresh bounded definition error. */
|
|
65
|
+
constructor();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Declares one host's capability vocabulary and the zod schemas and grant
|
|
69
|
+
* operations closed over it.
|
|
70
|
+
*
|
|
71
|
+
* @throws {@link GrantsDefinitionError} When the vocabulary repeats a
|
|
72
|
+
* capability, or `implies` names a capability outside it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function defineGrants<const Capability extends readonly [string, ...string[]]>(definition: GrantsDefinition<Capability>): GrantVocabulary<Capability[number]>;
|
package/dist/grants.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Rejected when a host's capability vocabulary violates `defineGrants`'
|
|
4
|
+
* invariants.
|
|
5
|
+
*
|
|
6
|
+
* This error retains neither the rejected definition nor a foreign cause.
|
|
7
|
+
*/
|
|
8
|
+
export class GrantsDefinitionError extends Error {
|
|
9
|
+
/** Stable public error name. */
|
|
10
|
+
name = "GrantsDefinitionError";
|
|
11
|
+
/** Creates a fresh bounded definition error. */
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Ear Tag grants vocabulary definition is invalid");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const capabilitiesSchema = z.array(z.string().min(1).max(128)).min(1).max(256);
|
|
17
|
+
function parseImplies(definition) {
|
|
18
|
+
if (!capabilitiesSchema.safeParse(definition.capabilities).success)
|
|
19
|
+
throw new GrantsDefinitionError();
|
|
20
|
+
const capabilitySet = new Set(definition.capabilities);
|
|
21
|
+
if (capabilitySet.size !== definition.capabilities.length)
|
|
22
|
+
throw new GrantsDefinitionError();
|
|
23
|
+
// Object.entries widens the declared literal keys to `string`; every key
|
|
24
|
+
// and value is checked against `capabilitySet` below, and the returned map
|
|
25
|
+
// is itself keyed by `string`, so the vocabulary's literal union is never
|
|
26
|
+
// relied on again here.
|
|
27
|
+
const declaredImplies = definition.implies;
|
|
28
|
+
const impliesEntries = declaredImplies === undefined ? [] : Object.entries(declaredImplies);
|
|
29
|
+
for (const [capability, implied] of impliesEntries) {
|
|
30
|
+
if (!capabilitySet.has(capability))
|
|
31
|
+
throw new GrantsDefinitionError();
|
|
32
|
+
for (const impliedCapability of implied ?? [])
|
|
33
|
+
if (!capabilitySet.has(impliedCapability))
|
|
34
|
+
throw new GrantsDefinitionError();
|
|
35
|
+
}
|
|
36
|
+
return new Map(impliesEntries.map(([capability, implied]) => [capability, implied ?? []]));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Declares one host's capability vocabulary and the zod schemas and grant
|
|
40
|
+
* operations closed over it.
|
|
41
|
+
*
|
|
42
|
+
* @throws {@link GrantsDefinitionError} When the vocabulary repeats a
|
|
43
|
+
* capability, or `implies` names a capability outside it.
|
|
44
|
+
*/
|
|
45
|
+
export function defineGrants(definition) {
|
|
46
|
+
const implies = parseImplies(definition);
|
|
47
|
+
const capabilityNameSchema = z.enum(definition.capabilities);
|
|
48
|
+
const grantTargetsSchema = z.discriminatedUnion("kind", [
|
|
49
|
+
z.strictObject({ kind: z.literal("all") }),
|
|
50
|
+
z.strictObject({
|
|
51
|
+
kind: z.literal("selected"),
|
|
52
|
+
ids: z.array(z.string().min(1).max(256)).max(256),
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
const grantSchema = z.strictObject({
|
|
56
|
+
capability: capabilityNameSchema,
|
|
57
|
+
targets: grantTargetsSchema,
|
|
58
|
+
});
|
|
59
|
+
const scopeSchema = z.strictObject({
|
|
60
|
+
grants: z.array(grantSchema).min(1).max(64),
|
|
61
|
+
});
|
|
62
|
+
// Merges every grant sharing a capability before canonicalizing, so two
|
|
63
|
+
// scopes naming the same authority in a different order, or split across
|
|
64
|
+
// separate grants for the same capability, always produce one identical
|
|
65
|
+
// result: a reordered retry never lands a spurious replay `conflict`.
|
|
66
|
+
const canonicalGrants = (grants) => {
|
|
67
|
+
const merged = new Map();
|
|
68
|
+
for (const grant of grants) {
|
|
69
|
+
const existing = merged.get(grant.capability);
|
|
70
|
+
if (existing === undefined) {
|
|
71
|
+
merged.set(grant.capability, grant.targets.kind === "all"
|
|
72
|
+
? grant.targets
|
|
73
|
+
: { kind: "selected", ids: [...grant.targets.ids] });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
merged.set(grant.capability, existing.kind === "all" || grant.targets.kind === "all"
|
|
77
|
+
? { kind: "all" }
|
|
78
|
+
: {
|
|
79
|
+
kind: "selected",
|
|
80
|
+
ids: [...existing.ids, ...grant.targets.ids],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Plain code-unit order: stable and locale-independent, matching how
|
|
84
|
+
// `ids` below are already sorted.
|
|
85
|
+
const byCodeUnit = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
86
|
+
return [...merged.entries()]
|
|
87
|
+
.map(([capability, targets]) => ({
|
|
88
|
+
capability,
|
|
89
|
+
targets: targets.kind === "all"
|
|
90
|
+
? targets
|
|
91
|
+
: { kind: "selected", ids: [...new Set(targets.ids)].sort() },
|
|
92
|
+
}))
|
|
93
|
+
.sort((left, right) => byCodeUnit(left.capability, right.capability));
|
|
94
|
+
};
|
|
95
|
+
const grantsAllow = (grants, capability, resourceId) => grants.some((grant) => (grant.capability === capability ||
|
|
96
|
+
implies.get(grant.capability)?.includes(capability) === true) &&
|
|
97
|
+
(grant.targets.kind === "all" ||
|
|
98
|
+
(resourceId !== undefined && grant.targets.ids.includes(resourceId))));
|
|
99
|
+
const grantsList = (grants, capability) => grants.some((grant) => grant.capability === capability &&
|
|
100
|
+
(grant.targets.kind === "all" || grant.targets.ids.length > 0));
|
|
101
|
+
const isSubset = (child, parent) => child.every((grant) => grant.targets.kind === "all"
|
|
102
|
+
? grantsAllow(parent, grant.capability)
|
|
103
|
+
: grant.targets.ids.every((id) => grantsAllow(parent, grant.capability, id)));
|
|
104
|
+
return {
|
|
105
|
+
capabilityNameSchema,
|
|
106
|
+
grantSchema,
|
|
107
|
+
scopeSchema,
|
|
108
|
+
canonicalGrants,
|
|
109
|
+
grantsAllow,
|
|
110
|
+
grantsList,
|
|
111
|
+
isSubset,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Options for generating a bearer key. */
|
|
2
|
+
export type GenerateKeyOptions = Readonly<{
|
|
3
|
+
/** Number of random characters after the prefix. */
|
|
4
|
+
length?: number;
|
|
5
|
+
/** Public key-family marker, without the separating underscore. */
|
|
6
|
+
prefix?: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** A newly generated bearer key and the digest safe to retain in storage. */
|
|
9
|
+
export type GeneratedKey = Readonly<{
|
|
10
|
+
/** SHA-256 digest suitable for persistence instead of the bearer secret. */
|
|
11
|
+
digest: string;
|
|
12
|
+
/** Bearer secret to disclose once to its authorized recipient. */
|
|
13
|
+
key: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** Generate a cryptographically random bearer key. */
|
|
16
|
+
export declare function generateKey(options?: GenerateKeyOptions): string;
|
|
17
|
+
/** Hash a bearer key with Worker-compatible Web Crypto. */
|
|
18
|
+
export declare function hashKey(key: string): Promise<string>;
|
|
19
|
+
/** Generate a bearer key and the digest its owner should persist. */
|
|
20
|
+
export declare function generateKeyAndHash(options?: GenerateKeyOptions): Promise<GeneratedKey>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LONG, nanoid } from "@fungi.computer/cairn";
|
|
2
|
+
/** Generate a cryptographically random bearer key. */
|
|
3
|
+
export function generateKey(options = {}) {
|
|
4
|
+
const key = nanoid(options.length ?? LONG);
|
|
5
|
+
return options.prefix === undefined ? key : `${options.prefix}_${key}`;
|
|
6
|
+
}
|
|
7
|
+
/** Hash a bearer key with Worker-compatible Web Crypto. */
|
|
8
|
+
export async function hashKey(key) {
|
|
9
|
+
const bytes = new TextEncoder().encode(key);
|
|
10
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
11
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
12
|
+
}
|
|
13
|
+
/** Generate a bearer key and the digest its owner should persist. */
|
|
14
|
+
export async function generateKeyAndHash(options = {}) {
|
|
15
|
+
const key = generateKey(options);
|
|
16
|
+
return { digest: await hashKey(key), key };
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import type { KeySql } from "./keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* Adapt a caller-owned `node:sqlite` database to the `KeySql` port
|
|
5
|
+
* `createKeyStore` expects, for hosts that run outside a Durable Object. The
|
|
6
|
+
* caller retains the database's lifetime.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fromNodeSqlite(database: DatabaseSync): KeySql;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const nodeSqliteRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.null()]));
|
|
3
|
+
/**
|
|
4
|
+
* Adapt a caller-owned `node:sqlite` database to the `KeySql` port
|
|
5
|
+
* `createKeyStore` expects, for hosts that run outside a Durable Object. The
|
|
6
|
+
* caller retains the database's lifetime.
|
|
7
|
+
*/
|
|
8
|
+
export function fromNodeSqlite(database) {
|
|
9
|
+
return {
|
|
10
|
+
exec: (statement, ...bindings) => {
|
|
11
|
+
const prepared = database.prepare(statement);
|
|
12
|
+
if (prepared.columns().length === 0) {
|
|
13
|
+
prepared.run(...bindings);
|
|
14
|
+
return { toArray: () => [] };
|
|
15
|
+
}
|
|
16
|
+
const rows = prepared
|
|
17
|
+
.all(...bindings)
|
|
18
|
+
.map((row) => nodeSqliteRowSchema.parse(row));
|
|
19
|
+
return { toArray: () => rows };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|