@marktiderman/genesis-connect 0.0.0-stage → 0.1.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 +228 -2
- package/dist/chunk-D3SZFWBJ.js +289 -0
- package/dist/chunk-D3SZFWBJ.js.map +1 -0
- package/dist/chunk-EUEVGKB6.cjs +304 -0
- package/dist/chunk-EUEVGKB6.cjs.map +1 -0
- package/dist/index.cjs +847 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +307 -0
- package/dist/index.d.ts +307 -0
- package/dist/index.js +803 -0
- package/dist/index.js.map +1 -0
- package/dist/secrets.cjs +44 -0
- package/dist/secrets.cjs.map +1 -0
- package/dist/secrets.d.cts +142 -0
- package/dist/secrets.d.ts +142 -0
- package/dist/secrets.js +3 -0
- package/dist/secrets.js.map +1 -0
- package/package.json +60 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Tiderman
|
|
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,229 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @marktiderman/genesis-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect an outside account, server side, in a few lines. Two primitives:
|
|
4
|
+
|
|
5
|
+
- **Secrets with key versioning**: AES-256-GCM, a key per purpose (HKDF-SHA-256), each
|
|
6
|
+
secret bound to its row, and a keyring so keys can be rotated without a migration.
|
|
7
|
+
- **An OAuth connector kit**: the authorization-code flow with PKCE on Web `Request` and
|
|
8
|
+
`Response`, Google built in, any other provider as a plain object. The app keeps the
|
|
9
|
+
rows; the kit does the flow, seals the refresh token and hands it over.
|
|
10
|
+
|
|
11
|
+
fetch and WebCrypto only, no dependencies: Node 20+, edge runtimes and Cloudflare Workers.
|
|
12
|
+
Server only: never import it into a browser bundle.
|
|
13
|
+
|
|
14
|
+
## Connect Google Drive
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// connect.server.ts: create it once, at module scope.
|
|
18
|
+
import {
|
|
19
|
+
createConnector,
|
|
20
|
+
createSecrets,
|
|
21
|
+
googleProvider,
|
|
22
|
+
GOOGLE_SCOPES,
|
|
23
|
+
} from "@marktiderman/genesis-connect";
|
|
24
|
+
|
|
25
|
+
export const google = createConnector({
|
|
26
|
+
provider: googleProvider,
|
|
27
|
+
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
|
|
28
|
+
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
|
|
29
|
+
stateSecret: process.env.CONNECT_STATE_SECRET!, // openssl rand -base64 32
|
|
30
|
+
secrets: createSecrets(process.env.CONNECT_KEYRING!, {
|
|
31
|
+
purpose: "google-refresh-token",
|
|
32
|
+
}),
|
|
33
|
+
callbackPath: "/api/connect/google/callback",
|
|
34
|
+
scopes: [GOOGLE_SCOPES.driveFile],
|
|
35
|
+
store: async (account) => {
|
|
36
|
+
// Your table: id, user, sealed refresh token, scope. Never the plaintext.
|
|
37
|
+
// The user is account.subject (from start), never a session read from the callback.
|
|
38
|
+
await db.insert("google_grants", {
|
|
39
|
+
id: account.id,
|
|
40
|
+
user_id: account.subject,
|
|
41
|
+
refresh_token: account.sealedRefreshToken,
|
|
42
|
+
scope: account.scope,
|
|
43
|
+
});
|
|
44
|
+
return { grant: account.id }; // added to the way back: ?connect=connected&grant=…
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// POST /api/connect/google/start (after your own sign-in check)
|
|
49
|
+
export const start = async (request: Request) =>
|
|
50
|
+
(
|
|
51
|
+
await google.start(request, {
|
|
52
|
+
subject: await userId(request),
|
|
53
|
+
returnTo: "/settings/integrations",
|
|
54
|
+
})
|
|
55
|
+
).redirect();
|
|
56
|
+
|
|
57
|
+
// GET /api/connect/google/callback
|
|
58
|
+
export const callback = (request: Request) => google.callback(request);
|
|
59
|
+
|
|
60
|
+
// Later, anywhere on the server:
|
|
61
|
+
const grant = await db.get("google_grants", grantId);
|
|
62
|
+
const token = await google.accessToken(
|
|
63
|
+
grant.refresh_token,
|
|
64
|
+
`google-grant:${grant.id}`,
|
|
65
|
+
);
|
|
66
|
+
await fetch("https://www.googleapis.com/drive/v3/files", {
|
|
67
|
+
headers: { authorization: `Bearer ${token}` },
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
On the way back the page reads `?connect=`: `connected`, `expired` (start again), `browser`
|
|
72
|
+
(finished in another browser), `denied` (the person said no), `scope` (they unticked a scope
|
|
73
|
+
the app needs) or `failed`. Register `https://<your origin>/api/connect/google/callback` as an
|
|
74
|
+
authorized redirect URI in the Google Cloud console.
|
|
75
|
+
|
|
76
|
+
## Secrets and key rotation
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { createSecrets } from "@marktiderman/genesis-connect/secrets";
|
|
80
|
+
|
|
81
|
+
// CONNECT_KEYRING = {"active":"k2","keys":{"k1":"<32 bytes base64>","k2":"<32 bytes base64>"}}
|
|
82
|
+
const secrets = createSecrets(process.env.CONNECT_KEYRING!, {
|
|
83
|
+
purpose: "webhook-url",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const sealed = await secrets.seal(url, `destination:${row.id}`); // v2.k2.<iv>.<ciphertext>
|
|
87
|
+
const plain = await secrets.open(row.sealed, `destination:${row.id}`);
|
|
88
|
+
|
|
89
|
+
// Rotate: add k3 to the keyring, make it active, deploy. Old secrets still open.
|
|
90
|
+
// Then re-seal lazily on read, or in a job:
|
|
91
|
+
if (secrets.needsRotation(row.sealed))
|
|
92
|
+
await save(row.id, await secrets.reseal(row.sealed, `destination:${row.id}`));
|
|
93
|
+
// When no row names k2 any more, drop it from the keyring.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Associated data is required.** Use the row's id (`grant:<id>`): a secret copied onto
|
|
97
|
+
another row then refuses to open.
|
|
98
|
+
- **One purpose per kind of secret.** It is the HKDF `info`; keep it (and an optional
|
|
99
|
+
`salt`) fixed once anything is sealed.
|
|
100
|
+
- **A bare key is a keyring of one**, `k1`. That is how a single-key app moves on.
|
|
101
|
+
- **Legacy `v1.` secrets** (genesis-forms' `sealSecret`) open with the key named by
|
|
102
|
+
`legacyKeyId` (default `"k1"`), with the same purpose and salt; `reseal` turns them into `v2.`.
|
|
103
|
+
- **`v2` keys are derived apart from `v1`** (HKDF info `genesis-connect/v2:<purpose>`), so one
|
|
104
|
+
key and purpose never seal both formats under the same AES key. `v1` is unchanged.
|
|
105
|
+
- **Errors are `SecretError`s** with a `code` (`key`, `format`, `unknown-key`, `decrypt`,
|
|
106
|
+
`usage`) and a message that never holds a key, a key id, a plaintext or a ciphertext. Check
|
|
107
|
+
them with `isSecretError(e)` rather than `instanceof`: it also matches the copy genesis-forms
|
|
108
|
+
bundles, and the ESM and CJS builds.
|
|
109
|
+
|
|
110
|
+
## The OAuth connector
|
|
111
|
+
|
|
112
|
+
`createConnector(config)` checks its config at boot and returns:
|
|
113
|
+
|
|
114
|
+
| Call | What it does |
|
|
115
|
+
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `start(request, { subject, intent?, returnTo, scopes?, data?, params? })` | The provider's sign-in URL and a `Set-Cookie`. `.redirect()` answers a 303; `.json()` answers `{ url }` for a page that navigates itself. |
|
|
117
|
+
| `callback(request)` | Checks the signed state (signature, age), that the cookie's nonce matches it, handles `error`, exchanges the code with the PKCE verifier, checks every scope asked for was granted, seals the refresh token and calls `store`. Always a 303 back to `returnTo` with `?connect=<result>`, clearing the cookie. |
|
|
118
|
+
| `accessToken(sealed, aad)` | A fresh access token, cached in memory until a minute before it expires. |
|
|
119
|
+
| `refresh(sealed, aad, { onRotate })` | Uncached; when the provider rotates the refresh token, the new one comes back sealed. |
|
|
120
|
+
| `revoke(sealed, aad)` | Revokes at the provider and forgets the cached token. True when it is gone. Google ends every token of the person's grant to your client, so only revoke the last connection that uses it. |
|
|
121
|
+
|
|
122
|
+
`accessToken`, `refresh` and `revoke` throw a `ConnectError` (check with `isConnectError(e)`)
|
|
123
|
+
with a `code`: `revoked` (the grant is gone, `invalid_grant`: connect again), `unavailable`
|
|
124
|
+
(retryable: 429, 5xx, a timeout, an abort), `failed`, or a `SecretError` when the sealed token
|
|
125
|
+
doesn't open for that row.
|
|
126
|
+
|
|
127
|
+
#### Providers that rotate refresh tokens
|
|
128
|
+
|
|
129
|
+
Some providers send a new refresh token on every refresh (Google does not). The kit never drops
|
|
130
|
+
one:
|
|
131
|
+
|
|
132
|
+
- `accessToken(sealed, aad, { onRotate })` needs `onRotate` for such a provider. A rotation
|
|
133
|
+
without it throws a `usage` `ConnectError` whose `sealedRefreshToken` holds the new token;
|
|
134
|
+
store it in place of the old one.
|
|
135
|
+
- The access token is cached (under the old and the new refresh token) before `onRotate` runs.
|
|
136
|
+
If `onRotate` throws, a `failed` `ConnectError` carries the new token the same way.
|
|
137
|
+
- `refresh()` returns it as `sealedRefreshToken` (and calls `onRotate` when given).
|
|
138
|
+
- Concurrent `accessToken` calls for one grant share one refresh, so a cold start never
|
|
139
|
+
rotates twice. The first caller's `signal` aborts the shared refresh.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const token = await connector.accessToken(row.refresh_token, aad, {
|
|
143
|
+
onRotate: (sealed) => db.update("grants", row.id, { refresh_token: sealed }),
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### What it guarantees
|
|
148
|
+
|
|
149
|
+
- **Only the browser that started can finish.** The state carries a nonce; the cookie
|
|
150
|
+
(HttpOnly, SameSite=Lax, Secure everywhere but localhost, scoped to the callback path)
|
|
151
|
+
carries the same nonce and the PKCE verifier. No cookie, or another attempt's, ends as
|
|
152
|
+
`browser`. Both cookie variants (host-only and shared-domain) are cleared on the way back.
|
|
153
|
+
- **https only**, except on `localhost`, `127.0.0.1` and `[::1]`: `start` on plain http
|
|
154
|
+
anywhere else throws `config`. Behind a proxy that ends TLS, `request.url` may read `http://`:
|
|
155
|
+
pass a `Request` built with the public https URL (from `X-Forwarded-Proto` and `Host`, if
|
|
156
|
+
your proxy sets them and you trust it).
|
|
157
|
+
- **The state is signed and short-lived** (HMAC-SHA-256 with a key derived from
|
|
158
|
+
`stateSecret`, 10 minutes by default). A forged, edited or old state ends as `expired`. It is
|
|
159
|
+
signed, not encrypted: `data` is for ids, never secrets, and `subject` is readable in the
|
|
160
|
+
sign-in URL (and by the provider), so pass an opaque user id, not an email address.
|
|
161
|
+
- **No secret in a URL or a log.** Token requests are POSTs that never follow a redirect
|
|
162
|
+
(`redirect: "error"`) and time out after 10 seconds. `log` gets safe lines only: a step, a
|
|
163
|
+
result and a reason such as `token endpoint answered 400 (invalid_grant)`.
|
|
164
|
+
- **`returnTo` is a path**, never a full URL, so the way back can't be pointed elsewhere.
|
|
165
|
+
|
|
166
|
+
### One callback origin for many addresses
|
|
167
|
+
|
|
168
|
+
When an app answers on many addresses (`team-a.example.com`, `team-b.example.com`) but
|
|
169
|
+
registers one redirect URI with the provider:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
createConnector({
|
|
173
|
+
...config,
|
|
174
|
+
redirectOrigin: "https://app.example.com",
|
|
175
|
+
sharedDomain: "example.com",
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The cookie is scoped to `example.com`, the provider sends everyone to
|
|
180
|
+
`https://app.example.com/api/connect/google/callback`, and the browser goes back to the address
|
|
181
|
+
it started on, but only when that is exactly an https origin on `example.com` on the default
|
|
182
|
+
port; anything else
|
|
183
|
+
goes back to the callback's own origin. A start on an address the cookie can't carry to the
|
|
184
|
+
pinned origin (a preview on `*.vercel.app`) throws a `config` error rather than failing later.
|
|
185
|
+
A public suffix is refused as `sharedDomain`. The built-in list only knows common hosting
|
|
186
|
+
suffixes (`vercel.app`, `netlify.app`, `pages.dev`, `workers.dev`, `ngrok-free.app`,
|
|
187
|
+
`cloudfront.net`, `up.railway.app`, `co.uk`, `com.au`, …); pass `isPublicSuffix(domain)` (a
|
|
188
|
+
Public Suffix List lookup) to cover every one.
|
|
189
|
+
|
|
190
|
+
Google answers some short scopes with their full names (`email` is granted as
|
|
191
|
+
`https://www.googleapis.com/auth/userinfo.email`); `googleProvider.scopeAliases` maps them so
|
|
192
|
+
the granted-scope check compares like with like. A provider of your own can set
|
|
193
|
+
`scopeAliases` too.
|
|
194
|
+
|
|
195
|
+
### Another provider
|
|
196
|
+
|
|
197
|
+
A provider is a plain object:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const acme: OAuthProvider = {
|
|
201
|
+
id: "acme",
|
|
202
|
+
authorizeUrl: "https://auth.acme.example/authorize",
|
|
203
|
+
tokenUrl: "https://auth.acme.example/token",
|
|
204
|
+
revokeUrl: "https://auth.acme.example/revoke",
|
|
205
|
+
defaultScopes: ["read"],
|
|
206
|
+
tokenAuth: "client_secret_basic", // or "client_secret_post", the default
|
|
207
|
+
};
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Config
|
|
211
|
+
|
|
212
|
+
| Option | Default | |
|
|
213
|
+
| ---------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
214
|
+
| `provider`, `clientId`, `clientSecret` | | The provider and your OAuth client. |
|
|
215
|
+
| `stateSecret` | | 32 bytes, base64. Signs the state. |
|
|
216
|
+
| `secrets` | | `createSecrets(keyring, { purpose })`: seals refresh tokens. |
|
|
217
|
+
| `callbackPath` | | The callback route's path. |
|
|
218
|
+
| `store(account, { request })` | | Keeps the account. May return query parameters for the way back. Throwing ends as `failed`. |
|
|
219
|
+
| `scopes` | `provider.defaultScopes` | Scopes asked for when `start` names none. |
|
|
220
|
+
| `redirectOrigin`, `sharedDomain` | | See above. |
|
|
221
|
+
| `cookieName` | `genesis_connect_<provider id>` | |
|
|
222
|
+
| `cookiePath` | `callbackPath` | `callbackPath` or a parent directory of it (RFC 6265 path-match). |
|
|
223
|
+
| `stateTtlSeconds` | 600 | 60 to 3600. |
|
|
224
|
+
| `resultParam` | `connect` | The query parameter the result comes back in. |
|
|
225
|
+
| `fallbackPath` | `/` | Where `expired` goes when there is no state to read `returnTo` from. |
|
|
226
|
+
| `aad(id)` | `<provider id>-grant:<id>` | The associated data a grant's refresh token is sealed to. |
|
|
227
|
+
| `requireRefreshToken` | true | Fail when the provider sends none. |
|
|
228
|
+
| `isPublicSuffix(domain)` | | A Public Suffix List lookup for the `sharedDomain` check. |
|
|
229
|
+
| `fetch`, `timeoutMs`, `now`, `randomId`, `log` | | For tests and logging. |
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// src/encoding.ts
|
|
2
|
+
var utf8 = new TextEncoder();
|
|
3
|
+
function base64url(bytes) {
|
|
4
|
+
const raw = typeof bytes === "string" ? utf8.encode(bytes) : bytes;
|
|
5
|
+
let s = "";
|
|
6
|
+
for (let i = 0; i < raw.length; i++) s += String.fromCharCode(raw[i]);
|
|
7
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
function fromBase64(text) {
|
|
10
|
+
const plain = text.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
|
|
11
|
+
const bin = atob(plain + "=".repeat((4 - plain.length % 4) % 4));
|
|
12
|
+
const out = new Uint8Array(bin.length);
|
|
13
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function randomToken(bytes = 32) {
|
|
17
|
+
return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
18
|
+
}
|
|
19
|
+
async function sha256Hex(text) {
|
|
20
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8.encode(text));
|
|
21
|
+
return Array.from(
|
|
22
|
+
new Uint8Array(digest),
|
|
23
|
+
(b) => b.toString(16).padStart(2, "0")
|
|
24
|
+
).join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/secrets.ts
|
|
28
|
+
var SecretError = class extends Error {
|
|
29
|
+
name = "SecretError";
|
|
30
|
+
code;
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var SECRET_ERROR_CODES = [
|
|
37
|
+
"key",
|
|
38
|
+
"format",
|
|
39
|
+
"unknown-key",
|
|
40
|
+
"decrypt",
|
|
41
|
+
"usage"
|
|
42
|
+
];
|
|
43
|
+
function isSecretError(error) {
|
|
44
|
+
return error instanceof Error && error.name === "SecretError" && SECRET_ERROR_CODES.includes(error.code);
|
|
45
|
+
}
|
|
46
|
+
function secretKeyBytes(key) {
|
|
47
|
+
if (typeof key !== "string") {
|
|
48
|
+
if (key.length !== 32)
|
|
49
|
+
throw new SecretError("key", "The encryption key must be 32 bytes");
|
|
50
|
+
return new Uint8Array(key);
|
|
51
|
+
}
|
|
52
|
+
const text = key.trim();
|
|
53
|
+
if (!text) throw new SecretError("key", "The encryption key is not set");
|
|
54
|
+
let bytes;
|
|
55
|
+
try {
|
|
56
|
+
bytes = fromBase64(text);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new SecretError("key", "The encryption key is not base64");
|
|
59
|
+
}
|
|
60
|
+
if (bytes.length !== 32)
|
|
61
|
+
throw new SecretError("key", "The encryption key must be 32 bytes");
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
async function deriveSecretKey(key, info, options = {}) {
|
|
65
|
+
if (!info)
|
|
66
|
+
throw new SecretError("usage", "A sealing key needs its purpose (info)");
|
|
67
|
+
const salt = typeof options.salt === "string" ? utf8.encode(options.salt) : new Uint8Array(options.salt ?? new Uint8Array(0));
|
|
68
|
+
const base = await crypto.subtle.importKey(
|
|
69
|
+
"raw",
|
|
70
|
+
secretKeyBytes(key),
|
|
71
|
+
"HKDF",
|
|
72
|
+
false,
|
|
73
|
+
["deriveKey"]
|
|
74
|
+
);
|
|
75
|
+
return crypto.subtle.deriveKey(
|
|
76
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: utf8.encode(info) },
|
|
77
|
+
base,
|
|
78
|
+
{ name: "AES-GCM", length: 256 },
|
|
79
|
+
false,
|
|
80
|
+
["encrypt", "decrypt"]
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function requireAad(aad) {
|
|
84
|
+
if (typeof aad !== "string" || !aad) {
|
|
85
|
+
throw new SecretError(
|
|
86
|
+
"usage",
|
|
87
|
+
"Associated data (the row the secret belongs to) is required"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function encrypt(key, plaintext, additionalData) {
|
|
92
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
93
|
+
const sealed = await crypto.subtle.encrypt(
|
|
94
|
+
{ name: "AES-GCM", iv, additionalData: utf8.encode(additionalData) },
|
|
95
|
+
key,
|
|
96
|
+
utf8.encode(plaintext)
|
|
97
|
+
);
|
|
98
|
+
return { iv: base64url(iv), body: base64url(new Uint8Array(sealed)) };
|
|
99
|
+
}
|
|
100
|
+
async function decrypt(key, iv, body, additionalData) {
|
|
101
|
+
try {
|
|
102
|
+
const plain = await crypto.subtle.decrypt(
|
|
103
|
+
{
|
|
104
|
+
name: "AES-GCM",
|
|
105
|
+
iv: fromBase64(iv),
|
|
106
|
+
additionalData: utf8.encode(additionalData)
|
|
107
|
+
},
|
|
108
|
+
key,
|
|
109
|
+
fromBase64(body)
|
|
110
|
+
);
|
|
111
|
+
return new TextDecoder().decode(plain);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new SecretError(
|
|
114
|
+
"decrypt",
|
|
115
|
+
"The stored secret could not be decrypted"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function sealWithKey(key, plaintext, aad) {
|
|
120
|
+
const { iv, body } = await encrypt(key, plaintext, aad);
|
|
121
|
+
return `v1.${iv}.${body}`;
|
|
122
|
+
}
|
|
123
|
+
async function openWithKey(key, sealed, aad) {
|
|
124
|
+
const parsed = parseSealed(sealed);
|
|
125
|
+
if (!parsed || parsed.version !== "v1")
|
|
126
|
+
throw new SecretError(
|
|
127
|
+
"format",
|
|
128
|
+
"The stored secret is not in a known format"
|
|
129
|
+
);
|
|
130
|
+
return decrypt(key, parsed.iv, parsed.body, aad);
|
|
131
|
+
}
|
|
132
|
+
var KEY_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
133
|
+
function parseKeyring(value) {
|
|
134
|
+
let input = value;
|
|
135
|
+
if (typeof value === "string") {
|
|
136
|
+
const text = value.trim();
|
|
137
|
+
if (!text) throw new SecretError("key", "The keyring is not set");
|
|
138
|
+
if (text.startsWith("{")) {
|
|
139
|
+
try {
|
|
140
|
+
input = JSON.parse(text);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new SecretError("key", "The keyring is not valid JSON");
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
return { active: "k1", keys: /* @__PURE__ */ new Map([["k1", secretKeyBytes(text)]]) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!input || typeof input !== "object")
|
|
149
|
+
throw new SecretError(
|
|
150
|
+
"key",
|
|
151
|
+
"The keyring must be an object with active and keys"
|
|
152
|
+
);
|
|
153
|
+
const { active, keys } = input;
|
|
154
|
+
if (typeof active !== "string" || !KEY_ID.test(active))
|
|
155
|
+
throw new SecretError(
|
|
156
|
+
"key",
|
|
157
|
+
"The keyring's active key id is missing or malformed"
|
|
158
|
+
);
|
|
159
|
+
const entries = keys instanceof Map ? [...keys.entries()] : keys && typeof keys === "object" ? Object.entries(keys) : [];
|
|
160
|
+
if (entries.length === 0)
|
|
161
|
+
throw new SecretError("key", "The keyring has no keys");
|
|
162
|
+
const out = /* @__PURE__ */ new Map();
|
|
163
|
+
for (const [index, [id, key]] of entries.entries()) {
|
|
164
|
+
const which = `A key in the keyring (#${index + 1})`;
|
|
165
|
+
if (!KEY_ID.test(id))
|
|
166
|
+
throw new SecretError(
|
|
167
|
+
"key",
|
|
168
|
+
`${which} has a malformed id (letters, digits, _ and -, at most 32)`
|
|
169
|
+
);
|
|
170
|
+
if (typeof key !== "string" && !(key instanceof Uint8Array))
|
|
171
|
+
throw new SecretError("key", `${which} is not a base64 string`);
|
|
172
|
+
try {
|
|
173
|
+
out.set(id, secretKeyBytes(key));
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new SecretError(
|
|
176
|
+
"key",
|
|
177
|
+
`${which}: ${error.message.replace(/^The encryption key /, "")}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!out.has(active))
|
|
182
|
+
throw new SecretError(
|
|
183
|
+
"key",
|
|
184
|
+
"The keyring's active key id is not among its keys"
|
|
185
|
+
);
|
|
186
|
+
return { active, keys: out };
|
|
187
|
+
}
|
|
188
|
+
var B64URL = /^[A-Za-z0-9_-]+$/;
|
|
189
|
+
function parseSealed(sealed) {
|
|
190
|
+
if (typeof sealed !== "string") return null;
|
|
191
|
+
const parts = sealed.split(".");
|
|
192
|
+
if (parts[0] === "v1" && parts.length === 3) {
|
|
193
|
+
const [, iv, body] = parts;
|
|
194
|
+
return B64URL.test(iv) && B64URL.test(body) ? { version: "v1", keyId: null, iv, body } : null;
|
|
195
|
+
}
|
|
196
|
+
if (parts[0] === "v2" && parts.length === 4) {
|
|
197
|
+
const [, keyId, iv, body] = parts;
|
|
198
|
+
return KEY_ID.test(keyId) && B64URL.test(iv) && B64URL.test(body) ? { version: "v2", keyId, iv, body } : null;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
var v2Aad = (keyId, aad) => `v2.${keyId}.${aad}`;
|
|
203
|
+
var v2Info = (purpose) => `genesis-connect/v2:${purpose}`;
|
|
204
|
+
function createSecrets(keyring, options) {
|
|
205
|
+
const ring = parseKeyring(keyring);
|
|
206
|
+
const { purpose, salt } = options;
|
|
207
|
+
if (!purpose)
|
|
208
|
+
throw new SecretError("usage", "A sealing key needs its purpose (info)");
|
|
209
|
+
const legacyKeyId = options.legacyKeyId ?? "k1";
|
|
210
|
+
const derived = /* @__PURE__ */ new Map();
|
|
211
|
+
const keyFor = (keyId, version) => {
|
|
212
|
+
const bytes = ring.keys.get(keyId);
|
|
213
|
+
if (!bytes)
|
|
214
|
+
throw new SecretError(
|
|
215
|
+
"unknown-key",
|
|
216
|
+
"The stored secret was sealed with a key the keyring no longer has"
|
|
217
|
+
);
|
|
218
|
+
const cacheKey = `${version}:${keyId}`;
|
|
219
|
+
let key = derived.get(cacheKey);
|
|
220
|
+
if (!key) {
|
|
221
|
+
key = deriveSecretKey(
|
|
222
|
+
bytes,
|
|
223
|
+
version === "v1" ? purpose : v2Info(purpose),
|
|
224
|
+
{ salt }
|
|
225
|
+
);
|
|
226
|
+
derived.set(cacheKey, key);
|
|
227
|
+
}
|
|
228
|
+
return key;
|
|
229
|
+
};
|
|
230
|
+
const secrets = {
|
|
231
|
+
purpose,
|
|
232
|
+
activeKeyId: ring.active,
|
|
233
|
+
async seal(plaintext, aad) {
|
|
234
|
+
requireAad(aad);
|
|
235
|
+
if (typeof plaintext !== "string")
|
|
236
|
+
throw new SecretError("usage", "Only text can be sealed");
|
|
237
|
+
const { iv, body } = await encrypt(
|
|
238
|
+
await keyFor(ring.active, "v2"),
|
|
239
|
+
plaintext,
|
|
240
|
+
v2Aad(ring.active, aad)
|
|
241
|
+
);
|
|
242
|
+
return `v2.${ring.active}.${iv}.${body}`;
|
|
243
|
+
},
|
|
244
|
+
async open(sealed, aad) {
|
|
245
|
+
requireAad(aad);
|
|
246
|
+
const parsed = parseSealed(sealed);
|
|
247
|
+
if (!parsed)
|
|
248
|
+
throw new SecretError(
|
|
249
|
+
"format",
|
|
250
|
+
"The stored secret is not in a known format"
|
|
251
|
+
);
|
|
252
|
+
if (parsed.version === "v1")
|
|
253
|
+
return decrypt(
|
|
254
|
+
await keyFor(legacyKeyId, "v1"),
|
|
255
|
+
parsed.iv,
|
|
256
|
+
parsed.body,
|
|
257
|
+
aad
|
|
258
|
+
);
|
|
259
|
+
const keyId = parsed.keyId;
|
|
260
|
+
return decrypt(
|
|
261
|
+
await keyFor(keyId, "v2"),
|
|
262
|
+
parsed.iv,
|
|
263
|
+
parsed.body,
|
|
264
|
+
v2Aad(keyId, aad)
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
keyIdOf(sealed) {
|
|
268
|
+
const parsed = parseSealed(sealed);
|
|
269
|
+
if (!parsed) return null;
|
|
270
|
+
return parsed.version === "v1" ? legacyKeyId : parsed.keyId;
|
|
271
|
+
},
|
|
272
|
+
needsRotation(sealed) {
|
|
273
|
+
const parsed = parseSealed(sealed);
|
|
274
|
+
return !(parsed && parsed.version === "v2" && parsed.keyId === ring.active);
|
|
275
|
+
},
|
|
276
|
+
async reseal(sealed, aad) {
|
|
277
|
+
if (!secrets.needsRotation(sealed)) {
|
|
278
|
+
await secrets.open(sealed, aad);
|
|
279
|
+
return sealed;
|
|
280
|
+
}
|
|
281
|
+
return secrets.seal(await secrets.open(sealed, aad), aad);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
return secrets;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { KEY_ID, SecretError, base64url, createSecrets, deriveSecretKey, fromBase64, isSecretError, openWithKey, parseKeyring, randomToken, sealWithKey, secretKeyBytes, sha256Hex, utf8 };
|
|
288
|
+
//# sourceMappingURL=chunk-D3SZFWBJ.js.map
|
|
289
|
+
//# sourceMappingURL=chunk-D3SZFWBJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/encoding.ts","../src/secrets.ts"],"names":[],"mappings":"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","file":"chunk-D3SZFWBJ.js","sourcesContent":["/** Small byte helpers on web APIs only (no Buffer), so the package runs on Node, edge and Workers. */\n\nexport const utf8 = new TextEncoder();\n\n/** Bytes (or UTF-8 text) as base64url, no padding. */\nexport function base64url(bytes: Uint8Array | string): string {\n const raw = typeof bytes === \"string\" ? utf8.encode(bytes) : bytes;\n let s = \"\";\n for (let i = 0; i < raw.length; i++) s += String.fromCharCode(raw[i]!);\n return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Standard base64 or base64url, padded or not, as bytes. Throws on anything else. */\nexport function fromBase64(text: string): Uint8Array<ArrayBuffer> {\n const plain = text.replace(/-/g, \"+\").replace(/_/g, \"/\").replace(/=+$/, \"\");\n const bin = atob(plain + \"=\".repeat((4 - (plain.length % 4)) % 4));\n const out = new Uint8Array(bin.length);\n for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);\n return out;\n}\n\n/** `bytes` random bytes as base64url. */\nexport function randomToken(bytes = 32): string {\n return base64url(crypto.getRandomValues(new Uint8Array(bytes)));\n}\n\n/** SHA-256 of a text, as lowercase hex. */\nexport async function sha256Hex(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", utf8.encode(text));\n return Array.from(new Uint8Array(digest), (b) =>\n b.toString(16).padStart(2, \"0\"),\n ).join(\"\");\n}\n\n/** WebCrypto's key type, named without depending on the DOM lib. */\nexport type WebCryptoKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>;\n","/**\n * Secrets at rest, with key versioning: a refresh token, a webhook URL, a\n * signing secret. AES-256-GCM (WebCrypto) under a key derived with\n * HKDF-SHA-256 from a 32-byte key and a purpose string, so no two purposes\n * share a key. Every ciphertext is bound to associated data (the row it\n * belongs to), so one copied onto another row does not decrypt.\n *\n * Keys live in a keyring, `{ active: \"k2\", keys: { k1: \"<32 bytes, base64>\", k2: \"…\" } }`,\n * usually one environment variable. New secrets are sealed with the active\n * key and name it: `v2.<key id>.<iv>.<ciphertext and tag>`, base64url. Any\n * key still listed opens what it sealed, so rotation is: add a key, make it\n * active, re-seal rows lazily (`needsRotation`, `reseal`) or in a job, then\n * drop the old key.\n *\n * The older single-key form, `v1.<iv>.<ciphertext>` (genesis-forms'\n * `sealSecret`), still opens, with the keyring key named by `legacyKeyId`.\n *\n * Errors are `SecretError`s whose messages never hold a key, a plaintext or\n * a ciphertext.\n */\nimport { base64url, fromBase64, utf8, type WebCryptoKey } from \"./encoding\";\n\nexport type { WebCryptoKey } from \"./encoding\";\n\n/** What went wrong, for code to branch on. */\nexport type SecretErrorCode =\n /** A key is missing, not base64 or not 32 bytes, or the keyring is malformed. */\n | \"key\"\n /** The sealed text is not `v1.` or `v2.` in shape. */\n | \"format\"\n /** The sealed text names a key the keyring no longer has. */\n | \"unknown-key\"\n /** Wrong key, wrong associated data (another row's secret) or a tampered ciphertext. */\n | \"decrypt\"\n /** A required argument (purpose, associated data) is missing. */\n | \"usage\";\n\n/** A failure whose message is safe to log: never a key, a plaintext or a ciphertext. */\nexport class SecretError extends Error {\n override name = \"SecretError\";\n readonly code: SecretErrorCode;\n constructor(code: SecretErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n}\n\nconst SECRET_ERROR_CODES: readonly string[] = [\n \"key\",\n \"format\",\n \"unknown-key\",\n \"decrypt\",\n \"usage\",\n];\n\n/**\n * Whether `error` is a `SecretError`, by its name and code rather than\n * `instanceof`, so it also matches a copy bundled into another package\n * (genesis-forms bundles one).\n */\nexport function isSecretError(error: unknown): error is SecretError {\n return (\n error instanceof Error &&\n error.name === \"SecretError\" &&\n SECRET_ERROR_CODES.includes((error as { code?: unknown }).code as string)\n );\n}\n\n// Keys ---------------------------------------------------------------------------------------------\n\n/**\n * The 32 key bytes from base64 or base64url text (as `openssl rand -base64 32`\n * prints), or the bytes themselves.\n */\nexport function secretKeyBytes(\n key: string | Uint8Array,\n): Uint8Array<ArrayBuffer> {\n if (typeof key !== \"string\") {\n if (key.length !== 32)\n throw new SecretError(\"key\", \"The encryption key must be 32 bytes\");\n return new Uint8Array(key);\n }\n const text = key.trim();\n if (!text) throw new SecretError(\"key\", \"The encryption key is not set\");\n let bytes: Uint8Array<ArrayBuffer>;\n try {\n bytes = fromBase64(text);\n } catch {\n throw new SecretError(\"key\", \"The encryption key is not base64\");\n }\n if (bytes.length !== 32)\n throw new SecretError(\"key\", \"The encryption key must be 32 bytes\");\n return bytes;\n}\n\n/** The HKDF salt: bytes, or text taken as its UTF-8 bytes. */\nexport type Salt = string | Uint8Array;\n\n/** Options for `deriveSecretKey`. */\nexport interface DeriveSecretKeyOptions {\n /**\n * The HKDF salt. Default empty. A fixed, app-wide random value is\n * recommended for new keys (RFC 5869); it need not be secret, but must\n * never change once anything is sealed with it.\n */\n salt?: Salt | undefined;\n}\n\n/**\n * An AES-GCM key for one purpose, derived from a 32-byte key with\n * HKDF-SHA-256 (`info` naming the purpose; the salt empty unless given).\n * Keep `info` and the salt fixed once anything is sealed with them.\n */\nexport async function deriveSecretKey(\n key: string | Uint8Array,\n info: string,\n options: DeriveSecretKeyOptions = {},\n): Promise<WebCryptoKey> {\n if (!info)\n throw new SecretError(\"usage\", \"A sealing key needs its purpose (info)\");\n const salt =\n typeof options.salt === \"string\"\n ? utf8.encode(options.salt)\n : new Uint8Array(options.salt ?? new Uint8Array(0));\n const base = await crypto.subtle.importKey(\n \"raw\",\n secretKeyBytes(key),\n \"HKDF\",\n false,\n [\"deriveKey\"],\n );\n return crypto.subtle.deriveKey(\n { name: \"HKDF\", hash: \"SHA-256\", salt, info: utf8.encode(info) },\n base,\n { name: \"AES-GCM\", length: 256 },\n false,\n [\"encrypt\", \"decrypt\"],\n );\n}\n\n// The single-key v1 format -------------------------------------------------------------------------\n\nfunction requireAad(aad: string): void {\n if (typeof aad !== \"string\" || !aad) {\n throw new SecretError(\n \"usage\",\n \"Associated data (the row the secret belongs to) is required\",\n );\n }\n}\n\nasync function encrypt(\n key: WebCryptoKey,\n plaintext: string,\n additionalData: string,\n): Promise<{ iv: string; body: string }> {\n const iv = crypto.getRandomValues(new Uint8Array(12));\n const sealed = await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv, additionalData: utf8.encode(additionalData) },\n key,\n utf8.encode(plaintext),\n );\n return { iv: base64url(iv), body: base64url(new Uint8Array(sealed)) };\n}\n\nasync function decrypt(\n key: WebCryptoKey,\n iv: string,\n body: string,\n additionalData: string,\n): Promise<string> {\n try {\n const plain = await crypto.subtle.decrypt(\n {\n name: \"AES-GCM\",\n iv: fromBase64(iv),\n additionalData: utf8.encode(additionalData),\n },\n key,\n fromBase64(body),\n );\n return new TextDecoder().decode(plain);\n } catch {\n throw new SecretError(\n \"decrypt\",\n \"The stored secret could not be decrypted\",\n );\n }\n}\n\n/**\n * Seals with one derived key in the single-key format, `v1.<iv>.<ciphertext>`.\n * Kept for genesis-forms' `sealSecret`; new code uses `createSecrets`, whose\n * `v2.` form names its key so the key can be rotated.\n */\nexport async function sealWithKey(\n key: WebCryptoKey,\n plaintext: string,\n aad: string,\n): Promise<string> {\n const { iv, body } = await encrypt(key, plaintext, aad);\n return `v1.${iv}.${body}`;\n}\n\n/** Opens what `sealWithKey` sealed with the same key and `aad`. */\nexport async function openWithKey(\n key: WebCryptoKey,\n sealed: string,\n aad: string,\n): Promise<string> {\n const parsed = parseSealed(sealed);\n if (!parsed || parsed.version !== \"v1\")\n throw new SecretError(\n \"format\",\n \"The stored secret is not in a known format\",\n );\n return decrypt(key, parsed.iv, parsed.body, aad);\n}\n\n// The keyring --------------------------------------------------------------------------------------\n\n/** A key id: letters, digits, `_` and `-`, at most 32 (it is written into every sealed secret). */\nexport const KEY_ID = /^[A-Za-z0-9_-]{1,32}$/;\n\n/** A keyring as config: the active key's id and every key that may still be needed to open. */\nexport interface KeyringInput {\n active: string;\n keys: Readonly<Record<string, string | Uint8Array>>;\n}\n\n/** A checked keyring: every key 32 bytes, the active one listed. */\nexport interface Keyring {\n readonly active: string;\n readonly keys: ReadonlyMap<string, Uint8Array<ArrayBuffer>>;\n}\n\n/**\n * A keyring from config: an object, its JSON text (one environment\n * variable: `{\"active\":\"k2\",\"keys\":{\"k1\":\"…\",\"k2\":\"…\"}}`), or a single bare\n * key, which becomes `{ active: \"k1\", keys: { k1 } }` (how a single-key\n * secret moves onto a keyring). Errors never quote the text.\n */\nexport function parseKeyring(value: string | KeyringInput | Keyring): Keyring {\n let input: unknown = value;\n if (typeof value === \"string\") {\n const text = value.trim();\n if (!text) throw new SecretError(\"key\", \"The keyring is not set\");\n if (text.startsWith(\"{\")) {\n try {\n input = JSON.parse(text) as unknown;\n } catch {\n // JSON.parse's own message quotes the text, which holds keys.\n throw new SecretError(\"key\", \"The keyring is not valid JSON\");\n }\n } else {\n return { active: \"k1\", keys: new Map([[\"k1\", secretKeyBytes(text)]]) };\n }\n }\n if (!input || typeof input !== \"object\")\n throw new SecretError(\n \"key\",\n \"The keyring must be an object with active and keys\",\n );\n const { active, keys } = input as { active?: unknown; keys?: unknown };\n if (typeof active !== \"string\" || !KEY_ID.test(active))\n throw new SecretError(\n \"key\",\n \"The keyring's active key id is missing or malformed\",\n );\n const entries: [string, unknown][] =\n keys instanceof Map\n ? [...(keys as Map<string, unknown>).entries()]\n : keys && typeof keys === \"object\"\n ? Object.entries(keys)\n : [];\n if (entries.length === 0)\n throw new SecretError(\"key\", \"The keyring has no keys\");\n const out = new Map<string, Uint8Array<ArrayBuffer>>();\n // Errors name a key by its position, never its id or its value.\n for (const [index, [id, key]] of entries.entries()) {\n const which = `A key in the keyring (#${index + 1})`;\n if (!KEY_ID.test(id))\n throw new SecretError(\n \"key\",\n `${which} has a malformed id (letters, digits, _ and -, at most 32)`,\n );\n if (typeof key !== \"string\" && !(key instanceof Uint8Array))\n throw new SecretError(\"key\", `${which} is not a base64 string`);\n try {\n out.set(id, secretKeyBytes(key));\n } catch (error) {\n throw new SecretError(\n \"key\",\n `${which}: ${(error as Error).message.replace(/^The encryption key /, \"\")}`,\n );\n }\n }\n if (!out.has(active))\n throw new SecretError(\n \"key\",\n \"The keyring's active key id is not among its keys\",\n );\n return { active, keys: out };\n}\n\n/** Options for `createSecrets`. */\nexport interface SecretsOptions {\n /**\n * What these secrets are, as the HKDF `info`: `\"google-refresh-token\"`,\n * `\"webhook-url\"`. One per kind of secret; fixed once anything is sealed.\n */\n purpose: string;\n /** The HKDF salt (see `DeriveSecretKeyOptions.salt`). Fixed once anything is sealed. */\n salt?: Salt | undefined;\n /**\n * The keyring key that sealed single-key `v1.` secrets (genesis-forms'\n * `sealSecret`, with the same purpose and salt). Default `\"k1\"`, the id a\n * bare key gets from `parseKeyring`.\n */\n legacyKeyId?: string | undefined;\n}\n\n/** Seals and opens one purpose's secrets with a keyring. */\nexport interface Secrets {\n readonly purpose: string;\n /** The key new secrets are sealed with. */\n readonly activeKeyId: string;\n /** `plaintext` sealed with the active key and bound to `aad`: `v2.<key id>.<iv>.<ciphertext>`. */\n seal(plaintext: string, aad: string): Promise<string>;\n /** Opens a `v2.` secret with the key it names, or a `v1.` one with `legacyKeyId`'s. Needs the same `aad`. */\n open(sealed: string, aad: string): Promise<string>;\n /** The key id a sealed secret names (`legacyKeyId` for `v1.`), or null when it is not one. */\n keyIdOf(sealed: string): string | null;\n /**\n * Whether a sealed secret is anything but `v2.` under the active key: a\n * `v1.` one, another key's, or unreadable (whose `reseal` then throws).\n */\n needsRotation(sealed: string): boolean;\n /**\n * The secret sealed again with the active key, or `sealed` itself when it\n * already is. Store the result in place of the old one.\n */\n reseal(sealed: string, aad: string): Promise<string>;\n}\n\ninterface ParsedSealed {\n version: \"v1\" | \"v2\";\n keyId: string | null;\n iv: string;\n body: string;\n}\n\nconst B64URL = /^[A-Za-z0-9_-]+$/;\n\nfunction parseSealed(sealed: unknown): ParsedSealed | null {\n if (typeof sealed !== \"string\") return null;\n const parts = sealed.split(\".\");\n if (parts[0] === \"v1\" && parts.length === 3) {\n const [, iv, body] = parts as [string, string, string];\n return B64URL.test(iv) && B64URL.test(body)\n ? { version: \"v1\", keyId: null, iv, body }\n : null;\n }\n if (parts[0] === \"v2\" && parts.length === 4) {\n const [, keyId, iv, body] = parts as [string, string, string, string];\n return KEY_ID.test(keyId) && B64URL.test(iv) && B64URL.test(body)\n ? { version: \"v2\", keyId, iv, body }\n : null;\n }\n return null;\n}\n\n/** The associated data a `v2.` ciphertext is bound to: its version and key id, then the app's. */\nconst v2Aad = (keyId: string, aad: string) => `v2.${keyId}.${aad}`;\n\n/**\n * The HKDF info for `v2.` keys: distinct from the bare purpose that `v1.`\n * (single-key) secrets use, so the same key and purpose never seal both\n * formats under one AES key.\n */\nconst v2Info = (purpose: string) => `genesis-connect/v2:${purpose}`;\n\n/**\n * Seals and opens secrets of one purpose with a keyring (see `parseKeyring`\n * for what it takes). Derived keys are cached on the returned object.\n */\nexport function createSecrets(\n keyring: string | KeyringInput | Keyring,\n options: SecretsOptions,\n): Secrets {\n const ring = parseKeyring(keyring);\n const { purpose, salt } = options;\n if (!purpose)\n throw new SecretError(\"usage\", \"A sealing key needs its purpose (info)\");\n const legacyKeyId = options.legacyKeyId ?? \"k1\";\n const derived = new Map<string, Promise<WebCryptoKey>>();\n\n const keyFor = (\n keyId: string,\n version: \"v1\" | \"v2\",\n ): Promise<WebCryptoKey> => {\n const bytes = ring.keys.get(keyId);\n if (!bytes)\n throw new SecretError(\n \"unknown-key\",\n \"The stored secret was sealed with a key the keyring no longer has\",\n );\n const cacheKey = `${version}:${keyId}`;\n let key = derived.get(cacheKey);\n if (!key) {\n key = deriveSecretKey(\n bytes,\n version === \"v1\" ? purpose : v2Info(purpose),\n { salt },\n );\n derived.set(cacheKey, key);\n }\n return key;\n };\n\n const secrets: Secrets = {\n purpose,\n activeKeyId: ring.active,\n async seal(plaintext, aad) {\n requireAad(aad);\n if (typeof plaintext !== \"string\")\n throw new SecretError(\"usage\", \"Only text can be sealed\");\n const { iv, body } = await encrypt(\n await keyFor(ring.active, \"v2\"),\n plaintext,\n v2Aad(ring.active, aad),\n );\n return `v2.${ring.active}.${iv}.${body}`;\n },\n async open(sealed, aad) {\n requireAad(aad);\n const parsed = parseSealed(sealed);\n if (!parsed)\n throw new SecretError(\n \"format\",\n \"The stored secret is not in a known format\",\n );\n if (parsed.version === \"v1\")\n return decrypt(\n await keyFor(legacyKeyId, \"v1\"),\n parsed.iv,\n parsed.body,\n aad,\n );\n const keyId = parsed.keyId!;\n return decrypt(\n await keyFor(keyId, \"v2\"),\n parsed.iv,\n parsed.body,\n v2Aad(keyId, aad),\n );\n },\n keyIdOf(sealed) {\n const parsed = parseSealed(sealed);\n if (!parsed) return null;\n return parsed.version === \"v1\" ? legacyKeyId : parsed.keyId;\n },\n needsRotation(sealed) {\n const parsed = parseSealed(sealed);\n return !(\n parsed &&\n parsed.version === \"v2\" &&\n parsed.keyId === ring.active\n );\n },\n async reseal(sealed, aad) {\n if (!secrets.needsRotation(sealed)) {\n // Still checks it opens, so a job never reports a broken row as done.\n await secrets.open(sealed, aad);\n return sealed;\n }\n return secrets.seal(await secrets.open(sealed, aad), aad);\n },\n };\n return secrets;\n}\n"]}
|