@coffre/core 0.0.0 → 0.1.0
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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/schemas.d.ts
|
|
3
|
+
export declare const slug: z.ZodString;
|
|
4
|
+
export declare const displayName: z.ZodString;
|
|
5
|
+
export declare const secretKey: z.ZodString;
|
|
6
|
+
export declare const principalType: z.ZodEnum<{
|
|
7
|
+
user: "user";
|
|
8
|
+
service: "service";
|
|
9
|
+
}>;
|
|
10
|
+
export declare const principalId: z.ZodString;
|
|
11
|
+
export declare const instanceRole: z.ZodEnum<{
|
|
12
|
+
user: "user";
|
|
13
|
+
owner: "owner";
|
|
14
|
+
}>;
|
|
15
|
+
export declare const grantId: z.ZodString;
|
|
16
|
+
export declare const emailAddress: z.ZodString;
|
|
17
|
+
export declare const syncId: z.ZodString;
|
|
18
|
+
//#endregion
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/schemas.ts
|
|
3
|
+
const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
4
|
+
const displayName = z.string().trim().min(1).max(120);
|
|
5
|
+
const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
|
|
6
|
+
const principalType = z.enum(["user", "service"]);
|
|
7
|
+
const principalId = z.string().trim().min(1).max(320);
|
|
8
|
+
const instanceRole = z.enum(["user", "owner"]);
|
|
9
|
+
const grantId = z.string().uuid();
|
|
10
|
+
const emailAddress = z.string().email().max(320);
|
|
11
|
+
const syncId = z.string().uuid();
|
|
12
|
+
//#endregion
|
|
13
|
+
export { displayName, emailAddress, grantId, instanceRole, principalId, principalType, secretKey, slug, syncId };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/context.ts
|
|
2
|
+
/**
|
|
3
|
+
* Version prefix for the AAD encoding. If the encoding ever changes, this
|
|
4
|
+
* changes with it, and old ciphertexts keep decrypting under the old rule.
|
|
5
|
+
*/
|
|
6
|
+
const AAD_VERSION = "coffre.aad.v1";
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
8
|
+
function assertUuid(value, field) {
|
|
9
|
+
if (typeof value !== "string" || !UUID_RE.test(value)) throw new Error(`context.${field} must be a lowercase UUID, got: ${String(value)}`);
|
|
10
|
+
}
|
|
11
|
+
/** Throw unless every field is a lowercase UUID. */
|
|
12
|
+
function checkContext(ctx) {
|
|
13
|
+
assertUuid(ctx.projectId, "projectId");
|
|
14
|
+
assertUuid(ctx.environmentId, "environmentId");
|
|
15
|
+
assertUuid(ctx.secretId, "secretId");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Encode a context into AAD bytes.
|
|
19
|
+
*
|
|
20
|
+
* Every field is validated as a UUID first, which is what makes joining with
|
|
21
|
+
* `|` unambiguous: a UUID cannot contain the separator, so no two distinct
|
|
22
|
+
* contexts can encode to the same bytes.
|
|
23
|
+
*/
|
|
24
|
+
function encodeAad(ctx) {
|
|
25
|
+
checkContext(ctx);
|
|
26
|
+
return Buffer.from(`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`, "utf8");
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/kek/types.ts
|
|
30
|
+
/** Length of the data encryption keys we generate, in bytes (AES-256). */
|
|
31
|
+
const DEK_BYTES = 32;
|
|
32
|
+
/**
|
|
33
|
+
* The key service could not answer: it is down, throttling past the
|
|
34
|
+
* retries, or refusing coffre's own credentials. Nothing is known about the
|
|
35
|
+
* wrapped key, so the vault fails the call instead of refusing it.
|
|
36
|
+
*/
|
|
37
|
+
var KekUnavailableError = class extends Error {
|
|
38
|
+
name = "KekUnavailableError";
|
|
39
|
+
uncertain;
|
|
40
|
+
constructor(message, uncertain = false) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.uncertain = uncertain;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** No more work may start. A request already sent may have been accepted by KMS. */
|
|
46
|
+
var KekCancelledError = class extends KekUnavailableError {
|
|
47
|
+
name = "KekCancelledError";
|
|
48
|
+
constructor(uncertain = false) {
|
|
49
|
+
super("key operation was cancelled", uncertain);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** The ciphertext does not open under the claimed key and context. */
|
|
53
|
+
var KekBadClaimError = class extends Error {
|
|
54
|
+
name = "KekBadClaimError";
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { AAD_VERSION as a, KekUnavailableError as i, KekBadClaimError as n, checkContext as o, KekCancelledError as r, encodeAad as s, DEK_BYTES as t };
|
package/dist/vault.d.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { Role } from "./access.js";
|
|
2
|
+
//#region src/access-fault.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Why the vault's members and grants do not follow from its log, as facts
|
|
5
|
+
* rather than a sentence. The vault knows only ids; the app knows what the
|
|
6
|
+
* people and places are called, so it words the same fault with names.
|
|
7
|
+
*/
|
|
8
|
+
type AccessFault =
|
|
9
|
+
/** An entry changes someone the log never admitted. */
|
|
10
|
+
{
|
|
11
|
+
kind: 'unadmitted-change';
|
|
12
|
+
seq: number;
|
|
13
|
+
principal: string;
|
|
14
|
+
} |
|
|
15
|
+
/** The store has a member the log never admitted. */
|
|
16
|
+
{
|
|
17
|
+
kind: 'unlogged-member';
|
|
18
|
+
principal: string;
|
|
19
|
+
} |
|
|
20
|
+
/** The log admits someone the store does not have. */
|
|
21
|
+
{
|
|
22
|
+
kind: 'missing-member';
|
|
23
|
+
principal: string;
|
|
24
|
+
} |
|
|
25
|
+
/** The store's row for someone differs from the log's in these fields. */
|
|
26
|
+
{
|
|
27
|
+
kind: 'member-differs';
|
|
28
|
+
principal: string;
|
|
29
|
+
fields: string[];
|
|
30
|
+
} |
|
|
31
|
+
/** The store holds a grant the log never gave. */
|
|
32
|
+
{
|
|
33
|
+
kind: 'unlogged-grant';
|
|
34
|
+
grant: FaultGrant;
|
|
35
|
+
} |
|
|
36
|
+
/** The log gives a grant the store does not hold. */
|
|
37
|
+
{
|
|
38
|
+
kind: 'missing-grant';
|
|
39
|
+
grant: FaultGrant;
|
|
40
|
+
} |
|
|
41
|
+
/**
|
|
42
|
+
* The store's row for someone fails the vault's integrity check: changed
|
|
43
|
+
* outside the vault (`mac`), or put back from before the log's last change
|
|
44
|
+
* to them (`stale`).
|
|
45
|
+
*/
|
|
46
|
+
{
|
|
47
|
+
kind: 'tampered-member';
|
|
48
|
+
principal: string;
|
|
49
|
+
why: 'mac' | 'stale';
|
|
50
|
+
};
|
|
51
|
+
type FaultGrant = {
|
|
52
|
+
principal: string;
|
|
53
|
+
projectId: string;
|
|
54
|
+
environmentId: string | null;
|
|
55
|
+
role: string;
|
|
56
|
+
};
|
|
57
|
+
/** How to name a principal (`user:ada@…`) and a place; ids by default. */
|
|
58
|
+
type FaultNames = {
|
|
59
|
+
principal?: (principal: string) => string;
|
|
60
|
+
place?: (projectId: string, environmentId: string | null) => string;
|
|
61
|
+
};
|
|
62
|
+
/** The fault in a sentence, with whatever names the caller can give. */
|
|
63
|
+
export declare function describeAccessFault(fault: AccessFault, names?: FaultNames): string;
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/checkpoint.d.ts
|
|
66
|
+
/** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
|
|
67
|
+
export declare function checkpointMessage(checkpoint: Omit<Checkpoint, 'signature' | 'keyId'>): Uint8Array<ArrayBuffer>;
|
|
68
|
+
/** Whether `checkpoint` was signed by the key whose raw public half is `publicKey`. */
|
|
69
|
+
export declare function verifyCheckpoint(checkpoint: Checkpoint, publicKey: string): Promise<boolean>;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/vault.d.ts
|
|
72
|
+
/**
|
|
73
|
+
* The vault as the app sees it: the contract between `@coffre/server` and
|
|
74
|
+
* `@coffre/vault`, kept in core so that neither side owns it. Every argument
|
|
75
|
+
* and result is plain JSON: strings, numbers, booleans, arrays and objects,
|
|
76
|
+
* never a Buffer, a Date, a class or a function. So it crosses a Workers RPC
|
|
77
|
+
* boundary, or a process boundary later, as it is, and the in-process
|
|
78
|
+
* transport proves it by round-tripping everything through JSON.
|
|
79
|
+
*
|
|
80
|
+
* The app says who is asking and the vault decides. The app never holds a
|
|
81
|
+
* key: it encrypts a value under a fresh data key, has the vault wrap that
|
|
82
|
+
* key, and stores the result; to read, it names stored versions and the
|
|
83
|
+
* vault loads their keys itself.
|
|
84
|
+
*
|
|
85
|
+
* Decisions come back as values: `{ ok: false, refusal }` for a refusal, which
|
|
86
|
+
* the vault has already logged. Only a fault (a bug, the store down) throws.
|
|
87
|
+
*/
|
|
88
|
+
export interface Vault {
|
|
89
|
+
/** Unwrap data keys for `principal`: all of them, or none. */
|
|
90
|
+
unwrap(input: UnwrapInput): Promise<Outcome<{
|
|
91
|
+
keys: string[];
|
|
92
|
+
}>>;
|
|
93
|
+
/**
|
|
94
|
+
* Wrap fresh data keys for new versions `principal` writes: all, or none.
|
|
95
|
+
* `seqs` are the `key.wrap` entries, one per key, which the app's
|
|
96
|
+
* `secret.write` entries name as related.
|
|
97
|
+
*/
|
|
98
|
+
wrap(input: WrapInput): Promise<Outcome<{
|
|
99
|
+
wrapped: WrappedKey[];
|
|
100
|
+
seqs: number[];
|
|
101
|
+
}>>;
|
|
102
|
+
/**
|
|
103
|
+
* Wrap existing data keys again for new versions of the same secrets,
|
|
104
|
+
* under the current key: restoring an old value. It needs a write grant,
|
|
105
|
+
* not a read one, since the key never leaves the vault.
|
|
106
|
+
*/
|
|
107
|
+
rewrap(input: RewrapInput): Promise<Outcome<{
|
|
108
|
+
wrapped: WrappedKey[];
|
|
109
|
+
seqs: number[];
|
|
110
|
+
}>>;
|
|
111
|
+
/**
|
|
112
|
+
* What one principal holds right now: the app asks once per request. A
|
|
113
|
+
* decision, its rows checked: lists of who holds what read the rows
|
|
114
|
+
* themselves, and need not ask.
|
|
115
|
+
*/
|
|
116
|
+
access(principal: string): Promise<Access>;
|
|
117
|
+
/** Set roles at several places for one principal: all of it, or none. */
|
|
118
|
+
setAccess(input: SetAccessInput): Promise<Outcome<{
|
|
119
|
+
changes: AccessChange[];
|
|
120
|
+
}>>;
|
|
121
|
+
/**
|
|
122
|
+
* Add a member, bring back a removed one, or change whether they are an
|
|
123
|
+
* owner. `generation` is theirs now: sessions and tokens of an earlier
|
|
124
|
+
* one are dead.
|
|
125
|
+
*/
|
|
126
|
+
admit(input: AdmitInput): Promise<Outcome<{
|
|
127
|
+
created: boolean;
|
|
128
|
+
owner: boolean;
|
|
129
|
+
generation: number;
|
|
130
|
+
}>>;
|
|
131
|
+
/** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
|
|
132
|
+
remove(input: RemoveInput): Promise<Outcome<{
|
|
133
|
+
revoked: Grant[];
|
|
134
|
+
generation: number;
|
|
135
|
+
}>>;
|
|
136
|
+
/**
|
|
137
|
+
* Sign the log up to its last entry, in an `audit.checkpoint` entry of
|
|
138
|
+
* the vault's, if the whole chain recomputes from its first entry, the
|
|
139
|
+
* vault's entries by their MACs, and the prefix the last checkpoint signed
|
|
140
|
+
* is still there. The vault reads the log itself: it takes nobody's word
|
|
141
|
+
* for where it ends, or for what came before. It checks every
|
|
142
|
+
* member's row too, as `access` would, and logs a `vault.tampered` for
|
|
143
|
+
* each one changed around it: lists read rows without asking the vault.
|
|
144
|
+
*/
|
|
145
|
+
checkpoint(): Promise<Outcome<{
|
|
146
|
+
checkpoint: Checkpoint;
|
|
147
|
+
}>>;
|
|
148
|
+
/**
|
|
149
|
+
* What only the vault's configuration says, which the app shows: the key
|
|
150
|
+
* its checkpoints verify under, and the root admins, as principals.
|
|
151
|
+
*/
|
|
152
|
+
about(): Promise<{
|
|
153
|
+
publicKey: string;
|
|
154
|
+
rootAdmins: string[];
|
|
155
|
+
}>;
|
|
156
|
+
/**
|
|
157
|
+
* Check the whole log: rehash it from the first entry, the vault's
|
|
158
|
+
* entries by their MACs, every checkpoint against the prefix it signed,
|
|
159
|
+
* and replay it to see that who is a member, and what they hold, follow
|
|
160
|
+
* from it. A verdict and nothing else, so anyone may ask; the app asks
|
|
161
|
+
* for owners.
|
|
162
|
+
*/
|
|
163
|
+
verifyLog(input: VerifyLogInput): Promise<LogVerification>;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The vault's entries that change what a member holds: what their row's
|
|
167
|
+
* `access_seq` names, and what a finding about them is newer than while it
|
|
168
|
+
* stands.
|
|
169
|
+
*/
|
|
170
|
+
export declare const ACCESS_ACTIONS: readonly ["member.add", "member.restore", "member.owner", "member.remove", "access.grant", "access.revoke"];
|
|
171
|
+
/** A refusal, already logged by the time the app sees it. */
|
|
172
|
+
export type Refusal = {
|
|
173
|
+
code: RefusalCode;
|
|
174
|
+
message: string;
|
|
175
|
+
};
|
|
176
|
+
export type RefusalCode =
|
|
177
|
+
/** The principal was removed; only `admit` brings them back. */
|
|
178
|
+
'removed' |
|
|
179
|
+
/** The principal was never admitted. */
|
|
180
|
+
'not_a_member' |
|
|
181
|
+
/** No grant covers the place for what was asked. */
|
|
182
|
+
'no_grant' |
|
|
183
|
+
/** A grant covered it, but has expired. */
|
|
184
|
+
'expired' |
|
|
185
|
+
/** Too many unwraps in the window; see `BulkLimit`. */
|
|
186
|
+
'bulk_limit' |
|
|
187
|
+
/** The wrapped key does not belong to the secret it was presented as. */
|
|
188
|
+
'bad_claim' |
|
|
189
|
+
/** Not allowed to manage access, members or the log. */
|
|
190
|
+
'not_allowed' |
|
|
191
|
+
/** Root admins come from the vault's configuration and cannot be changed. */
|
|
192
|
+
'root_admin' |
|
|
193
|
+
/** Well-formed, but not something the rules allow: a project role on an environment, an owner token. */
|
|
194
|
+
'invalid' |
|
|
195
|
+
/** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
|
|
196
|
+
'log_broken' |
|
|
197
|
+
/**
|
|
198
|
+
* A KEK the vault is given does not open what it wrapped: a restore with
|
|
199
|
+
* the wrong key, or a key mistyped under the right id. Every key
|
|
200
|
+
* operation is refused until the vault restarts with the right one.
|
|
201
|
+
*/
|
|
202
|
+
'wrong_kek' |
|
|
203
|
+
/**
|
|
204
|
+
* The member's row or grants were changed outside the vault, or put back
|
|
205
|
+
* from before a later change: it refuses them until an owner removes them,
|
|
206
|
+
* which starts their access over from the log.
|
|
207
|
+
*/
|
|
208
|
+
'tampered';
|
|
209
|
+
export type Outcome<T> = ({
|
|
210
|
+
ok: true;
|
|
211
|
+
} & T) | {
|
|
212
|
+
ok: false;
|
|
213
|
+
refusal: Refusal;
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* A proposed version the app will store. The ids bind its key, and `path`
|
|
217
|
+
* is the app's label for the write's log. Reads name stored version ids
|
|
218
|
+
* instead, so the vault reads the binding and wrapped key itself.
|
|
219
|
+
*/
|
|
220
|
+
export type SecretRef = {
|
|
221
|
+
projectId: string;
|
|
222
|
+
environmentId: string;
|
|
223
|
+
secretId: string;
|
|
224
|
+
version: number;
|
|
225
|
+
/** `market/prod/DATABASE_URL`. */
|
|
226
|
+
path: string;
|
|
227
|
+
};
|
|
228
|
+
/** A wrapped data key and the key encryption key that wrapped it; `bytes` is base64. */
|
|
229
|
+
export type WrappedKey = {
|
|
230
|
+
kekProvider: string;
|
|
231
|
+
kekId: string;
|
|
232
|
+
kekVersion: string;
|
|
233
|
+
bytes: string;
|
|
234
|
+
};
|
|
235
|
+
/** Why someone reads: shown in the log, and the same rules apply to each. */
|
|
236
|
+
export type Purpose = 'reveal' | 'run' | 'compare' | 'sync';
|
|
237
|
+
/** Ties the vault's entries to the app's request and audit rows. */
|
|
238
|
+
type Correlation = {
|
|
239
|
+
requestId?: string | null;
|
|
240
|
+
/**
|
|
241
|
+
* The one action the call is part of, the app's id for it: a reveal's
|
|
242
|
+
* reads, a write's versions, an access change. Its entries share it.
|
|
243
|
+
*/
|
|
244
|
+
operationId?: string | null;
|
|
245
|
+
};
|
|
246
|
+
export type UnwrapInput = Correlation & {
|
|
247
|
+
principal: string;
|
|
248
|
+
purpose: Purpose;
|
|
249
|
+
items: {
|
|
250
|
+
secretVersionId: string;
|
|
251
|
+
}[];
|
|
252
|
+
};
|
|
253
|
+
export type WrapInput = Correlation & {
|
|
254
|
+
principal: string;
|
|
255
|
+
/** `key` is a fresh 32-byte data key, base64. */
|
|
256
|
+
items: {
|
|
257
|
+
secret: SecretRef;
|
|
258
|
+
key: string;
|
|
259
|
+
}[];
|
|
260
|
+
};
|
|
261
|
+
export type RewrapInput = Correlation & {
|
|
262
|
+
principal: string;
|
|
263
|
+
/** `secret` is the proposed new version; `secretVersionId` is the stored source. */
|
|
264
|
+
items: {
|
|
265
|
+
secret: SecretRef;
|
|
266
|
+
secretVersionId: string;
|
|
267
|
+
}[];
|
|
268
|
+
};
|
|
269
|
+
export type Grant = {
|
|
270
|
+
projectId: string;
|
|
271
|
+
/** Null for the whole project. */
|
|
272
|
+
environmentId: string | null;
|
|
273
|
+
role: Role;
|
|
274
|
+
/** ISO 8601, or null for no end. */
|
|
275
|
+
expiresAt: string | null;
|
|
276
|
+
grantedAt: string;
|
|
277
|
+
grantedBy: string;
|
|
278
|
+
};
|
|
279
|
+
export type Access = {
|
|
280
|
+
principal: string;
|
|
281
|
+
/**
|
|
282
|
+
* `unknown` for someone never admitted, `tampered` for a member whose row
|
|
283
|
+
* fails the vault's integrity check: refused everything, as `removed` is.
|
|
284
|
+
* Root admins are always active.
|
|
285
|
+
*/
|
|
286
|
+
status: 'active' | 'removed' | 'unknown' | 'tampered';
|
|
287
|
+
/** Advanced by removal, even if the app cannot commit its credential revocations. */
|
|
288
|
+
generation: number;
|
|
289
|
+
isRootAdmin: boolean;
|
|
290
|
+
/** Root admins and users admitted as owners, while active. */
|
|
291
|
+
isOwner: boolean;
|
|
292
|
+
/** Live grants only, and none unless active. */
|
|
293
|
+
grants: Grant[];
|
|
294
|
+
/** When the status last changed, and who changed it; null for root admins and strangers. */
|
|
295
|
+
since: string | null;
|
|
296
|
+
by: string | null;
|
|
297
|
+
};
|
|
298
|
+
/** One place: a role to hold there, with an optional end, or null for none. */
|
|
299
|
+
export type GrantChange = {
|
|
300
|
+
projectId: string;
|
|
301
|
+
environmentId: string | null;
|
|
302
|
+
role: Role | null;
|
|
303
|
+
expiresAt: string | null;
|
|
304
|
+
};
|
|
305
|
+
export type AccessChange = 'created' | 'updated' | 'revoked' | 'unchanged';
|
|
306
|
+
export type SetAccessInput = Correlation & {
|
|
307
|
+
actor: string;
|
|
308
|
+
principal: string;
|
|
309
|
+
changes: GrantChange[];
|
|
310
|
+
};
|
|
311
|
+
export type AdmitInput = Correlation & {
|
|
312
|
+
actor: string;
|
|
313
|
+
principal: string;
|
|
314
|
+
/** Left out, an existing member keeps their role and a new one is not an owner. */
|
|
315
|
+
owner?: boolean;
|
|
316
|
+
};
|
|
317
|
+
export type RemoveInput = Correlation & {
|
|
318
|
+
actor: string;
|
|
319
|
+
principal: string;
|
|
320
|
+
/**
|
|
321
|
+
* For a sync: the environment it pushes from. Whoever may manage it there
|
|
322
|
+
* may stop the sync, also once its grant there is revoked and only the
|
|
323
|
+
* one on its credential is left.
|
|
324
|
+
*/
|
|
325
|
+
source?: {
|
|
326
|
+
projectId: string;
|
|
327
|
+
environmentId: string;
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* A prefix of the log the vault signed: every entry up to `seq`, the last
|
|
332
|
+
* of which has `hash`, which pins every byte before it. Kept as the vault's
|
|
333
|
+
* `audit.checkpoint` entry, just after the prefix it signs.
|
|
334
|
+
*/
|
|
335
|
+
export type Checkpoint = {
|
|
336
|
+
seq: number;
|
|
337
|
+
/** The hash of entry `seq`, hex. */
|
|
338
|
+
hash: string;
|
|
339
|
+
signedAt: string;
|
|
340
|
+
keyId: string;
|
|
341
|
+
/** Ed25519 over `checkpointMessage(...)`, base64. */
|
|
342
|
+
signature: string;
|
|
343
|
+
};
|
|
344
|
+
/** An entry of the vault's log, and so the chain up to it. */
|
|
345
|
+
export type LogHead = {
|
|
346
|
+
seq: number;
|
|
347
|
+
hash: string;
|
|
348
|
+
};
|
|
349
|
+
export type VerifyLogInput = {
|
|
350
|
+
/**
|
|
351
|
+
* The last entry the app verified, its own entries by its key: the vault
|
|
352
|
+
* checks the log still holds it, so between them both authors' entries
|
|
353
|
+
* are authenticated over the same prefix.
|
|
354
|
+
*/
|
|
355
|
+
upTo?: LogHead | null;
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Whether the vault's log holds, with the number of entries in it. On a
|
|
359
|
+
* failure, the entry where it breaks, or null when the chain holds but the
|
|
360
|
+
* members and grants do not follow from it. Then `fault` says which, as
|
|
361
|
+
* facts the app can word with names: `reason` has only ids.
|
|
362
|
+
* Full checks report live key batches in `pending`; overdue ones fail.
|
|
363
|
+
*/
|
|
364
|
+
export type LogVerification = {
|
|
365
|
+
ok: true;
|
|
366
|
+
entries: number;
|
|
367
|
+
pending?: number;
|
|
368
|
+
} | {
|
|
369
|
+
ok: false;
|
|
370
|
+
failedAtSeq: number | null;
|
|
371
|
+
reason: string;
|
|
372
|
+
fault?: AccessFault;
|
|
373
|
+
};
|
|
374
|
+
//#endregion
|
|
375
|
+
export type { AccessFault, FaultGrant, FaultNames };
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/checkpoint.ts
|
|
2
|
+
/** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
|
|
3
|
+
function checkpointMessage(checkpoint) {
|
|
4
|
+
const { seq, hash, signedAt } = checkpoint;
|
|
5
|
+
return new Uint8Array(new TextEncoder().encode(`coffre.checkpoint.v3|${seq}|${hash}|${signedAt}`));
|
|
6
|
+
}
|
|
7
|
+
/** Whether `checkpoint` was signed by the key whose raw public half is `publicKey`. */
|
|
8
|
+
async function verifyCheckpoint(checkpoint, publicKey) {
|
|
9
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(Buffer.from(publicKey, "base64")), { name: "Ed25519" }, false, ["verify"]);
|
|
10
|
+
return crypto.subtle.verify("Ed25519", key, new Uint8Array(Buffer.from(checkpoint.signature, "base64")), checkpointMessage(checkpoint));
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/access-fault.ts
|
|
14
|
+
/** The fault in a sentence, with whatever names the caller can give. */
|
|
15
|
+
function describeAccessFault(fault, names = {}) {
|
|
16
|
+
const who = names.principal ?? ((principal) => principal);
|
|
17
|
+
const where = names.place ?? ((projectId, environmentId) => environmentId === null ? projectId : `${projectId}/${environmentId}`);
|
|
18
|
+
const grant = ({ principal, projectId, environmentId, role }) => `${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
|
|
19
|
+
switch (fault.kind) {
|
|
20
|
+
case "unadmitted-change": return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
|
|
21
|
+
case "unlogged-member": return `the store has ${who(fault.principal)} as a member, whom the log never admitted`;
|
|
22
|
+
case "missing-member": return `the log admits ${who(fault.principal)}, whom the store does not have`;
|
|
23
|
+
case "member-differs": return `the store's ${who(fault.principal)} differs from the log's in ${fault.fields.join(", ")}`;
|
|
24
|
+
case "unlogged-grant": return `the store holds a grant the log never gave: ${grant(fault.grant)}`;
|
|
25
|
+
case "missing-grant": return `the log gives a grant the store does not hold: ${grant(fault.grant)}`;
|
|
26
|
+
case "tampered-member": return fault.why === "mac" ? `the store's ${who(fault.principal)}, or their grants, were changed outside the vault` : `the store's ${who(fault.principal)} is older than the log's last change to them: it was put back`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/vault.ts
|
|
31
|
+
/**
|
|
32
|
+
* The vault's entries that change what a member holds: what their row's
|
|
33
|
+
* `access_seq` names, and what a finding about them is newer than while it
|
|
34
|
+
* stands.
|
|
35
|
+
*/
|
|
36
|
+
const ACCESS_ACTIONS = [
|
|
37
|
+
"member.add",
|
|
38
|
+
"member.restore",
|
|
39
|
+
"member.owner",
|
|
40
|
+
"member.remove",
|
|
41
|
+
"access.grant",
|
|
42
|
+
"access.revoke"
|
|
43
|
+
];
|
|
44
|
+
//#endregion
|
|
45
|
+
export { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint };
|
package/package.json
CHANGED
|
@@ -1,8 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/erwinkn/coffre.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
"./access": {
|
|
21
|
+
"coffre:source": "./src/access.ts",
|
|
22
|
+
"types": "./dist/access.d.ts",
|
|
23
|
+
"default": "./dist/access.js"
|
|
24
|
+
},
|
|
25
|
+
"./audit": {
|
|
26
|
+
"coffre:source": "./src/audit/chain.ts",
|
|
27
|
+
"types": "./dist/audit.d.ts",
|
|
28
|
+
"default": "./dist/audit.js"
|
|
29
|
+
},
|
|
30
|
+
"./dotenv": {
|
|
31
|
+
"coffre:source": "./src/dotenv.ts",
|
|
32
|
+
"types": "./dist/dotenv.d.ts",
|
|
33
|
+
"default": "./dist/dotenv.js"
|
|
34
|
+
},
|
|
35
|
+
"./envelope": {
|
|
36
|
+
"coffre:source": "./src/envelope.ts",
|
|
37
|
+
"types": "./dist/envelope.d.ts",
|
|
38
|
+
"default": "./dist/envelope.js"
|
|
39
|
+
},
|
|
40
|
+
"./identity": {
|
|
41
|
+
"coffre:source": "./src/identity/index.ts",
|
|
42
|
+
"types": "./dist/identity.d.ts",
|
|
43
|
+
"default": "./dist/identity.js"
|
|
44
|
+
},
|
|
45
|
+
"./kek": {
|
|
46
|
+
"coffre:source": "./src/kek/index.ts",
|
|
47
|
+
"types": "./dist/kek.d.ts",
|
|
48
|
+
"default": "./dist/kek.js"
|
|
49
|
+
},
|
|
50
|
+
"./schemas": {
|
|
51
|
+
"coffre:source": "./src/schemas.ts",
|
|
52
|
+
"types": "./dist/schemas.d.ts",
|
|
53
|
+
"default": "./dist/schemas.js"
|
|
54
|
+
},
|
|
55
|
+
"./vault": {
|
|
56
|
+
"coffre:source": "./src/vault.ts",
|
|
57
|
+
"types": "./dist/vault.d.ts",
|
|
58
|
+
"default": "./dist/vault.js"
|
|
59
|
+
},
|
|
60
|
+
"./package.json": "./package.json"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"jose": "6.2.3",
|
|
64
|
+
"oauth4webapi": "3.8.8",
|
|
65
|
+
"zod": "4.4.3"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "26.1.1",
|
|
69
|
+
"tsdown": "0.23.0",
|
|
70
|
+
"typescript": "5.9.3",
|
|
71
|
+
"@coffre/conformance": "0.1.0"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsdown",
|
|
75
|
+
"typecheck": "tsc --noEmit",
|
|
76
|
+
"test": "node --conditions=coffre:source --test"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why the vault's members and grants do not follow from its log, as facts
|
|
3
|
+
* rather than a sentence. The vault knows only ids; the app knows what the
|
|
4
|
+
* people and places are called, so it words the same fault with names.
|
|
5
|
+
*/
|
|
6
|
+
export type AccessFault =
|
|
7
|
+
/** An entry changes someone the log never admitted. */
|
|
8
|
+
| { kind: 'unadmitted-change'; seq: number; principal: string }
|
|
9
|
+
/** The store has a member the log never admitted. */
|
|
10
|
+
| { kind: 'unlogged-member'; principal: string }
|
|
11
|
+
/** The log admits someone the store does not have. */
|
|
12
|
+
| { kind: 'missing-member'; principal: string }
|
|
13
|
+
/** The store's row for someone differs from the log's in these fields. */
|
|
14
|
+
| { kind: 'member-differs'; principal: string; fields: string[] }
|
|
15
|
+
/** The store holds a grant the log never gave. */
|
|
16
|
+
| { kind: 'unlogged-grant'; grant: FaultGrant }
|
|
17
|
+
/** The log gives a grant the store does not hold. */
|
|
18
|
+
| { kind: 'missing-grant'; grant: FaultGrant }
|
|
19
|
+
/**
|
|
20
|
+
* The store's row for someone fails the vault's integrity check: changed
|
|
21
|
+
* outside the vault (`mac`), or put back from before the log's last change
|
|
22
|
+
* to them (`stale`).
|
|
23
|
+
*/
|
|
24
|
+
| { kind: 'tampered-member'; principal: string; why: 'mac' | 'stale' };
|
|
25
|
+
|
|
26
|
+
export type FaultGrant = { principal: string; projectId: string; environmentId: string | null; role: string };
|
|
27
|
+
|
|
28
|
+
/** How to name a principal (`user:ada@…`) and a place; ids by default. */
|
|
29
|
+
export type FaultNames = {
|
|
30
|
+
principal?: (principal: string) => string;
|
|
31
|
+
place?: (projectId: string, environmentId: string | null) => string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The fault in a sentence, with whatever names the caller can give. */
|
|
35
|
+
export function describeAccessFault(fault: AccessFault, names: FaultNames = {}): string {
|
|
36
|
+
const who = names.principal ?? ((principal: string) => principal);
|
|
37
|
+
const where =
|
|
38
|
+
names.place ??
|
|
39
|
+
((projectId: string, environmentId: string | null) =>
|
|
40
|
+
environmentId === null ? projectId : `${projectId}/${environmentId}`);
|
|
41
|
+
const grant = ({ principal, projectId, environmentId, role }: FaultGrant) =>
|
|
42
|
+
`${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
|
|
43
|
+
switch (fault.kind) {
|
|
44
|
+
case 'unadmitted-change':
|
|
45
|
+
return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
|
|
46
|
+
case 'unlogged-member':
|
|
47
|
+
return `the store has ${who(fault.principal)} as a member, whom the log never admitted`;
|
|
48
|
+
case 'missing-member':
|
|
49
|
+
return `the log admits ${who(fault.principal)}, whom the store does not have`;
|
|
50
|
+
case 'member-differs':
|
|
51
|
+
return `the store's ${who(fault.principal)} differs from the log's in ${fault.fields.join(', ')}`;
|
|
52
|
+
case 'unlogged-grant':
|
|
53
|
+
return `the store holds a grant the log never gave: ${grant(fault.grant)}`;
|
|
54
|
+
case 'missing-grant':
|
|
55
|
+
return `the log gives a grant the store does not hold: ${grant(fault.grant)}`;
|
|
56
|
+
case 'tampered-member':
|
|
57
|
+
return fault.why === 'mac'
|
|
58
|
+
? `the store's ${who(fault.principal)}, or their grants, were changed outside the vault`
|
|
59
|
+
: `the store's ${who(fault.principal)} is older than the log's last change to them: it was put back`;
|
|
60
|
+
}
|
|
61
|
+
}
|