@coffre/core 0.0.0 → 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 +10 -3
- package/dist/access.d.ts +100 -0
- package/dist/access.js +109 -0
- package/dist/audit.d.ts +119 -0
- package/dist/audit.js +160 -0
- package/dist/dotenv.d.ts +45 -0
- package/dist/dotenv.js +194 -0
- package/dist/envelope.d.ts +50 -0
- package/dist/envelope.js +83 -0
- package/dist/identity.d.ts +433 -0
- package/dist/identity.js +691 -0
- package/dist/kek.d.ts +72 -0
- package/dist/kek.js +486 -0
- package/dist/registry-C0NGpxcc.d.ts +126 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +13 -0
- package/dist/types-Mi7-Tzj9.js +57 -0
- package/dist/vault.d.ts +375 -0
- package/dist/vault.js +45 -0
- package/package.json +75 -5
- package/src/access-fault.ts +61 -0
- package/src/access.ts +152 -0
- package/src/audit/chain.ts +207 -0
- package/src/checkpoint.ts +13 -0
- package/src/context.ts +54 -0
- package/src/dotenv.ts +242 -0
- package/src/envelope.ts +127 -0
- package/src/identity/auth.ts +124 -0
- package/src/identity/index.ts +6 -0
- package/src/identity/signin/config.ts +301 -0
- package/src/identity/signin/github.ts +191 -0
- package/src/identity/signin/index.ts +5 -0
- package/src/identity/signin/oidc.ts +199 -0
- package/src/identity/signin/sealed.ts +55 -0
- package/src/identity/signin/types.ts +94 -0
- package/src/identity/tokens.ts +45 -0
- package/src/identity/types.ts +51 -0
- package/src/identity/verifier.ts +86 -0
- package/src/kek/aws-kms.ts +279 -0
- package/src/kek/cancellation.ts +56 -0
- package/src/kek/index.ts +6 -0
- package/src/kek/local.ts +122 -0
- package/src/kek/registry.ts +63 -0
- package/src/kek/sigv4.ts +75 -0
- package/src/kek/types.ts +79 -0
- package/src/schemas.ts +11 -0
- package/src/vault.ts +276 -0
package/src/kek/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SecretContext } from '../context.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A data encryption key wrapped by a key encryption key.
|
|
5
|
+
*
|
|
6
|
+
* The provider metadata travels with every wrapped DEK and is stored per row,
|
|
7
|
+
* never as a single global key id. Rotation and provider migration are then the
|
|
8
|
+
* same code path: write new rows under the new KEK, keep unwrapping old rows
|
|
9
|
+
* under the old one.
|
|
10
|
+
*/
|
|
11
|
+
export type WrappedDek = {
|
|
12
|
+
kekProvider: string;
|
|
13
|
+
kekId: string;
|
|
14
|
+
kekVersion: string;
|
|
15
|
+
bytes: Buffer;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** One deadline for a batch. Providers must abort and settle their work when its signal fires. */
|
|
19
|
+
export type KeyOperation = { deadline: number; signal: AbortSignal };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The only interface any key encryption key backend must satisfy.
|
|
23
|
+
*
|
|
24
|
+
* Two methods. We generate the DEK ourselves with a CSPRNG and use the backend
|
|
25
|
+
* purely to wrap it, rather than depending on a provider `GenerateDataKey`
|
|
26
|
+
* primitive -- GCP KMS has no such call, and staying off it is what keeps the
|
|
27
|
+
* exit plan cheap.
|
|
28
|
+
*
|
|
29
|
+
* `ctx` is passed through to the provider deliberately. A remote KEK service
|
|
30
|
+
* cannot otherwise record *which* secret an unwrap was for, only that some
|
|
31
|
+
* opaque DEK was unwrapped. AWS KMS logs it in CloudTrail as the encryption
|
|
32
|
+
* context (`aws-kms.ts`). Scaleway's Audit Trail does not log Key Manager
|
|
33
|
+
* Decrypt at all, which is why AWS came first.
|
|
34
|
+
*
|
|
35
|
+
* `unwrap` throws `KekUnavailableError` when the service cannot answer, and
|
|
36
|
+
* `KekBadClaimError` when the wrapped DEK does not open under `ctx`. Other
|
|
37
|
+
* errors are unexpected faults; the vault records them and fails the call.
|
|
38
|
+
*/
|
|
39
|
+
export interface KekProvider {
|
|
40
|
+
readonly provider: string;
|
|
41
|
+
readonly keyId: string;
|
|
42
|
+
readonly keyVersion: string;
|
|
43
|
+
|
|
44
|
+
wrap(dek: Buffer, ctx: SecretContext, operation?: KeyOperation): Promise<WrappedDek>;
|
|
45
|
+
unwrap(wrapped: WrappedDek, ctx: SecretContext, operation?: KeyOperation): Promise<Buffer>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Length of the data encryption keys we generate, in bytes (AES-256). */
|
|
49
|
+
export const DEK_BYTES = 32;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The key service could not answer: it is down, throttling past the
|
|
53
|
+
* retries, or refusing coffre's own credentials. Nothing is known about the
|
|
54
|
+
* wrapped key, so the vault fails the call instead of refusing it.
|
|
55
|
+
*/
|
|
56
|
+
export class KekUnavailableError extends Error {
|
|
57
|
+
override readonly name: string = 'KekUnavailableError';
|
|
58
|
+
|
|
59
|
+
readonly uncertain: boolean;
|
|
60
|
+
|
|
61
|
+
constructor(message: string, uncertain = false) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.uncertain = uncertain;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** No more work may start. A request already sent may have been accepted by KMS. */
|
|
68
|
+
export class KekCancelledError extends KekUnavailableError {
|
|
69
|
+
override readonly name = 'KekCancelledError';
|
|
70
|
+
|
|
71
|
+
constructor(uncertain = false) {
|
|
72
|
+
super('key operation was cancelled', uncertain);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The ciphertext does not open under the claimed key and context. */
|
|
77
|
+
export class KekBadClaimError extends Error {
|
|
78
|
+
override readonly name = 'KekBadClaimError';
|
|
79
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
4
|
+
export const displayName = z.string().trim().min(1).max(120);
|
|
5
|
+
export const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
|
|
6
|
+
export const principalType = z.enum(['user', 'service']);
|
|
7
|
+
export const principalId = z.string().trim().min(1).max(320);
|
|
8
|
+
export const instanceRole = z.enum(['user', 'owner']);
|
|
9
|
+
export const grantId = z.string().uuid();
|
|
10
|
+
export const emailAddress = z.string().email().max(320);
|
|
11
|
+
export const syncId = z.string().uuid();
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { AccessFault } from './access-fault.ts';
|
|
2
|
+
import type { Role } from './access.ts';
|
|
3
|
+
|
|
4
|
+
export { checkpointMessage, verifyCheckpoint } from './checkpoint.ts';
|
|
5
|
+
export { describeAccessFault, type AccessFault, type FaultGrant, type FaultNames } from './access-fault.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The vault as the app sees it: the contract between `@coffre/server` and
|
|
9
|
+
* `@coffre/vault`, kept in core so that neither side owns it. Every argument
|
|
10
|
+
* and result is plain JSON: strings, numbers, booleans, arrays and objects,
|
|
11
|
+
* never a Buffer, a Date, a class or a function. So it crosses a Workers RPC
|
|
12
|
+
* boundary, or a process boundary later, as it is, and the in-process
|
|
13
|
+
* transport proves it by round-tripping everything through JSON.
|
|
14
|
+
*
|
|
15
|
+
* The app says who is asking and the vault decides. The app never holds a
|
|
16
|
+
* key: it encrypts a value under a fresh data key, has the vault wrap that
|
|
17
|
+
* key, and stores the result; to read, it names stored versions and the
|
|
18
|
+
* vault loads their keys itself.
|
|
19
|
+
*
|
|
20
|
+
* Decisions come back as values: `{ ok: false, refusal }` for a refusal, which
|
|
21
|
+
* the vault has already logged. Only a fault (a bug, the store down) throws.
|
|
22
|
+
*/
|
|
23
|
+
export interface Vault {
|
|
24
|
+
/** Unwrap data keys for `principal`: all of them, or none. */
|
|
25
|
+
unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>>;
|
|
26
|
+
/**
|
|
27
|
+
* Wrap fresh data keys for new versions `principal` writes: all, or none.
|
|
28
|
+
* `seqs` are the `key.wrap` entries, one per key, which the app's
|
|
29
|
+
* `secret.write` entries name as related.
|
|
30
|
+
*/
|
|
31
|
+
wrap(input: WrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>>;
|
|
32
|
+
/**
|
|
33
|
+
* Wrap existing data keys again for new versions of the same secrets,
|
|
34
|
+
* under the current key: restoring an old value. It needs a write grant,
|
|
35
|
+
* not a read one, since the key never leaves the vault.
|
|
36
|
+
*/
|
|
37
|
+
rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What one principal holds right now: the app asks once per request. A
|
|
41
|
+
* decision, its rows checked: lists of who holds what read the rows
|
|
42
|
+
* themselves, and need not ask.
|
|
43
|
+
*/
|
|
44
|
+
access(principal: string): Promise<Access>;
|
|
45
|
+
/** Set roles at several places for one principal: all of it, or none. */
|
|
46
|
+
setAccess(input: SetAccessInput): Promise<Outcome<{ changes: AccessChange[] }>>;
|
|
47
|
+
/**
|
|
48
|
+
* Add a member, bring back a removed one, or change whether they are an
|
|
49
|
+
* owner. `generation` is theirs now: sessions and tokens of an earlier
|
|
50
|
+
* one are dead.
|
|
51
|
+
*/
|
|
52
|
+
admit(input: AdmitInput): Promise<Outcome<{ created: boolean; owner: boolean; generation: number }>>;
|
|
53
|
+
/** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
|
|
54
|
+
remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sign the log up to its last entry, in an `audit.checkpoint` entry of
|
|
58
|
+
* the vault's, if the whole chain recomputes from its first entry, the
|
|
59
|
+
* vault's entries by their MACs, and the prefix the last checkpoint signed
|
|
60
|
+
* is still there. The vault reads the log itself: it takes nobody's word
|
|
61
|
+
* for where it ends, or for what came before. It checks every
|
|
62
|
+
* member's row too, as `access` would, and logs a `vault.tampered` for
|
|
63
|
+
* each one changed around it: lists read rows without asking the vault.
|
|
64
|
+
*/
|
|
65
|
+
checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>>;
|
|
66
|
+
/**
|
|
67
|
+
* What only the vault's configuration says, which the app shows: the key
|
|
68
|
+
* its checkpoints verify under, and the root admins, as principals.
|
|
69
|
+
*/
|
|
70
|
+
about(): Promise<{ publicKey: string; rootAdmins: string[] }>;
|
|
71
|
+
/**
|
|
72
|
+
* Check the whole log: rehash it from the first entry, the vault's
|
|
73
|
+
* entries by their MACs, every checkpoint against the prefix it signed,
|
|
74
|
+
* and replay it to see that who is a member, and what they hold, follow
|
|
75
|
+
* from it. A verdict and nothing else, so anyone may ask; the app asks
|
|
76
|
+
* for owners.
|
|
77
|
+
*/
|
|
78
|
+
verifyLog(input: VerifyLogInput): Promise<LogVerification>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The vault's entries that change what a member holds: what their row's
|
|
83
|
+
* `access_seq` names, and what a finding about them is newer than while it
|
|
84
|
+
* stands.
|
|
85
|
+
*/
|
|
86
|
+
export const ACCESS_ACTIONS = ['member.add', 'member.restore', 'member.owner', 'member.remove', 'access.grant', 'access.revoke'] as const;
|
|
87
|
+
|
|
88
|
+
/** A refusal, already logged by the time the app sees it. */
|
|
89
|
+
export type Refusal = { code: RefusalCode; message: string };
|
|
90
|
+
|
|
91
|
+
export type RefusalCode =
|
|
92
|
+
/** The principal was removed; only `admit` brings them back. */
|
|
93
|
+
| 'removed'
|
|
94
|
+
/** The principal was never admitted. */
|
|
95
|
+
| 'not_a_member'
|
|
96
|
+
/** No grant covers the place for what was asked. */
|
|
97
|
+
| 'no_grant'
|
|
98
|
+
/** A grant covered it, but has expired. */
|
|
99
|
+
| 'expired'
|
|
100
|
+
/** Too many unwraps in the window; see `BulkLimit`. */
|
|
101
|
+
| 'bulk_limit'
|
|
102
|
+
/** The wrapped key does not belong to the secret it was presented as. */
|
|
103
|
+
| 'bad_claim'
|
|
104
|
+
/** Not allowed to manage access, members or the log. */
|
|
105
|
+
| 'not_allowed'
|
|
106
|
+
/** Root admins come from the vault's configuration and cannot be changed. */
|
|
107
|
+
| 'root_admin'
|
|
108
|
+
/** Well-formed, but not something the rules allow: a project role on an environment, an owner token. */
|
|
109
|
+
| 'invalid'
|
|
110
|
+
/** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
|
|
111
|
+
| 'log_broken'
|
|
112
|
+
/**
|
|
113
|
+
* A KEK the vault is given does not open what it wrapped: a restore with
|
|
114
|
+
* the wrong key, or a key mistyped under the right id. Every key
|
|
115
|
+
* operation is refused until the vault restarts with the right one.
|
|
116
|
+
*/
|
|
117
|
+
| 'wrong_kek'
|
|
118
|
+
/**
|
|
119
|
+
* The member's row or grants were changed outside the vault, or put back
|
|
120
|
+
* from before a later change: it refuses them until an owner removes them,
|
|
121
|
+
* which starts their access over from the log.
|
|
122
|
+
*/
|
|
123
|
+
| 'tampered';
|
|
124
|
+
|
|
125
|
+
export type Outcome<T> = ({ ok: true } & T) | { ok: false; refusal: Refusal };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A proposed version the app will store. The ids bind its key, and `path`
|
|
129
|
+
* is the app's label for the write's log. Reads name stored version ids
|
|
130
|
+
* instead, so the vault reads the binding and wrapped key itself.
|
|
131
|
+
*/
|
|
132
|
+
export type SecretRef = {
|
|
133
|
+
projectId: string;
|
|
134
|
+
environmentId: string;
|
|
135
|
+
secretId: string;
|
|
136
|
+
version: number;
|
|
137
|
+
/** `market/prod/DATABASE_URL`. */
|
|
138
|
+
path: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** A wrapped data key and the key encryption key that wrapped it; `bytes` is base64. */
|
|
142
|
+
export type WrappedKey = { kekProvider: string; kekId: string; kekVersion: string; bytes: string };
|
|
143
|
+
|
|
144
|
+
/** Why someone reads: shown in the log, and the same rules apply to each. */
|
|
145
|
+
export type Purpose = 'reveal' | 'run' | 'compare' | 'sync';
|
|
146
|
+
|
|
147
|
+
/** Ties the vault's entries to the app's request and audit rows. */
|
|
148
|
+
type Correlation = {
|
|
149
|
+
requestId?: string | null;
|
|
150
|
+
/**
|
|
151
|
+
* The one action the call is part of, the app's id for it: a reveal's
|
|
152
|
+
* reads, a write's versions, an access change. Its entries share it.
|
|
153
|
+
*/
|
|
154
|
+
operationId?: string | null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type UnwrapInput = Correlation & {
|
|
158
|
+
principal: string;
|
|
159
|
+
purpose: Purpose;
|
|
160
|
+
items: { secretVersionId: string }[];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type WrapInput = Correlation & {
|
|
164
|
+
principal: string;
|
|
165
|
+
/** `key` is a fresh 32-byte data key, base64. */
|
|
166
|
+
items: { secret: SecretRef; key: string }[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type RewrapInput = Correlation & {
|
|
170
|
+
principal: string;
|
|
171
|
+
/** `secret` is the proposed new version; `secretVersionId` is the stored source. */
|
|
172
|
+
items: { secret: SecretRef; secretVersionId: string }[];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type Grant = {
|
|
176
|
+
projectId: string;
|
|
177
|
+
/** Null for the whole project. */
|
|
178
|
+
environmentId: string | null;
|
|
179
|
+
role: Role;
|
|
180
|
+
/** ISO 8601, or null for no end. */
|
|
181
|
+
expiresAt: string | null;
|
|
182
|
+
grantedAt: string;
|
|
183
|
+
grantedBy: string;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export type Access = {
|
|
187
|
+
principal: string;
|
|
188
|
+
/**
|
|
189
|
+
* `unknown` for someone never admitted, `tampered` for a member whose row
|
|
190
|
+
* fails the vault's integrity check: refused everything, as `removed` is.
|
|
191
|
+
* Root admins are always active.
|
|
192
|
+
*/
|
|
193
|
+
status: 'active' | 'removed' | 'unknown' | 'tampered';
|
|
194
|
+
/** Advanced by removal, even if the app cannot commit its credential revocations. */
|
|
195
|
+
generation: number;
|
|
196
|
+
isRootAdmin: boolean;
|
|
197
|
+
/** Root admins and users admitted as owners, while active. */
|
|
198
|
+
isOwner: boolean;
|
|
199
|
+
/** Live grants only, and none unless active. */
|
|
200
|
+
grants: Grant[];
|
|
201
|
+
/** When the status last changed, and who changed it; null for root admins and strangers. */
|
|
202
|
+
since: string | null;
|
|
203
|
+
by: string | null;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** One place: a role to hold there, with an optional end, or null for none. */
|
|
207
|
+
export type GrantChange = {
|
|
208
|
+
projectId: string;
|
|
209
|
+
environmentId: string | null;
|
|
210
|
+
role: Role | null;
|
|
211
|
+
expiresAt: string | null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export type AccessChange = 'created' | 'updated' | 'revoked' | 'unchanged';
|
|
215
|
+
|
|
216
|
+
export type SetAccessInput = Correlation & {
|
|
217
|
+
actor: string;
|
|
218
|
+
principal: string;
|
|
219
|
+
changes: GrantChange[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export type AdmitInput = Correlation & {
|
|
223
|
+
actor: string;
|
|
224
|
+
principal: string;
|
|
225
|
+
/** Left out, an existing member keeps their role and a new one is not an owner. */
|
|
226
|
+
owner?: boolean;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export type RemoveInput = Correlation & {
|
|
230
|
+
actor: string;
|
|
231
|
+
principal: string;
|
|
232
|
+
/**
|
|
233
|
+
* For a sync: the environment it pushes from. Whoever may manage it there
|
|
234
|
+
* may stop the sync, also once its grant there is revoked and only the
|
|
235
|
+
* one on its credential is left.
|
|
236
|
+
*/
|
|
237
|
+
source?: { projectId: string; environmentId: string };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A prefix of the log the vault signed: every entry up to `seq`, the last
|
|
242
|
+
* of which has `hash`, which pins every byte before it. Kept as the vault's
|
|
243
|
+
* `audit.checkpoint` entry, just after the prefix it signs.
|
|
244
|
+
*/
|
|
245
|
+
export type Checkpoint = {
|
|
246
|
+
seq: number;
|
|
247
|
+
/** The hash of entry `seq`, hex. */
|
|
248
|
+
hash: string;
|
|
249
|
+
signedAt: string;
|
|
250
|
+
keyId: string;
|
|
251
|
+
/** Ed25519 over `checkpointMessage(...)`, base64. */
|
|
252
|
+
signature: string;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** An entry of the vault's log, and so the chain up to it. */
|
|
256
|
+
export type LogHead = { seq: number; hash: string };
|
|
257
|
+
|
|
258
|
+
export type VerifyLogInput = {
|
|
259
|
+
/**
|
|
260
|
+
* The last entry the app verified, its own entries by its key: the vault
|
|
261
|
+
* checks the log still holds it, so between them both authors' entries
|
|
262
|
+
* are authenticated over the same prefix.
|
|
263
|
+
*/
|
|
264
|
+
upTo?: LogHead | null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether the vault's log holds, with the number of entries in it. On a
|
|
269
|
+
* failure, the entry where it breaks, or null when the chain holds but the
|
|
270
|
+
* members and grants do not follow from it. Then `fault` says which, as
|
|
271
|
+
* facts the app can word with names: `reason` has only ids.
|
|
272
|
+
* Full checks report live key batches in `pending`; overdue ones fail.
|
|
273
|
+
*/
|
|
274
|
+
export type LogVerification =
|
|
275
|
+
| { ok: true; entries: number; pending?: number }
|
|
276
|
+
| { ok: false; failedAtSeq: number | null; reason: string; fault?: AccessFault };
|