@coffre/core 0.0.0-stage → 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 +11 -2
- 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 -3
- 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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erwin Kuhn
|
|
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,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @coffre/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What coffre's other packages share: access rules, envelope encryption, the
|
|
4
|
+
key-encryption keys (a local key, or AWS KMS), the audit chain, identity and
|
|
5
|
+
sign-in, and `Vault`, the contract between the server and the vault.
|
|
6
|
+
|
|
7
|
+
A deployment does not import it. `@coffre/server` and `@coffre/vault`
|
|
8
|
+
re-export what a deployment needs from it.
|
|
9
|
+
|
|
10
|
+
Part of [coffre](https://github.com/erwinkn/coffre), a secrets manager you
|
|
11
|
+
deploy as a small project of your own. Its eight `@coffre/*` packages are
|
|
12
|
+
released together, at one version. MIT licensed.
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/access.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Who may do what: seven permissions and the six roles that bundle them.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately constants and not tables or a policy language. A grant names
|
|
6
|
+
* one role for one member on one place, and a check is a lookup in this file,
|
|
7
|
+
* so authorisation stays something you can read end to end.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PERMISSIONS: readonly ["secret.read", "secret.write", "secret.archive", "audit.read", "environment.manage", "grant.manage", "project.manage"];
|
|
10
|
+
export type Permission = (typeof PERMISSIONS)[number];
|
|
11
|
+
/**
|
|
12
|
+
* Permissions that only make sense on a project. Granting
|
|
13
|
+
* `environment.manage` on one environment would be incoherent: what it
|
|
14
|
+
* authorises is creating that environment's siblings.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PROJECT_ONLY_PERMISSIONS: readonly Permission[];
|
|
17
|
+
export declare const ROLES: {
|
|
18
|
+
readonly viewer: {
|
|
19
|
+
readonly name: "Viewer";
|
|
20
|
+
readonly description: "Read secret values.";
|
|
21
|
+
readonly permissions: readonly ["secret.read"];
|
|
22
|
+
};
|
|
23
|
+
readonly developer: {
|
|
24
|
+
readonly name: "Developer";
|
|
25
|
+
readonly description: "Read and write secrets.";
|
|
26
|
+
readonly permissions: readonly ["secret.read", "secret.write"];
|
|
27
|
+
};
|
|
28
|
+
readonly maintainer: {
|
|
29
|
+
readonly name: "Maintainer";
|
|
30
|
+
readonly description: "Read, write and retire secrets, and manage environments.";
|
|
31
|
+
readonly permissions: readonly ["secret.read", "secret.write", "secret.archive", "environment.manage"];
|
|
32
|
+
};
|
|
33
|
+
readonly 'access-manager': {
|
|
34
|
+
readonly name: "Access manager";
|
|
35
|
+
readonly description: "Manage who has access. Cannot read secret values.";
|
|
36
|
+
readonly permissions: readonly ["grant.manage"];
|
|
37
|
+
};
|
|
38
|
+
readonly auditor: {
|
|
39
|
+
readonly name: "Auditor";
|
|
40
|
+
readonly description: "Read the audit log. Cannot read secret values.";
|
|
41
|
+
readonly permissions: readonly ["audit.read"];
|
|
42
|
+
};
|
|
43
|
+
readonly owner: {
|
|
44
|
+
readonly name: "Owner";
|
|
45
|
+
readonly description: "Everything, including reading secret values.";
|
|
46
|
+
readonly permissions: readonly ["secret.read", "secret.write", "secret.archive", "audit.read", "environment.manage", "grant.manage", "project.manage"];
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export type Role = keyof typeof ROLES;
|
|
50
|
+
export declare const ROLE_NAMES: Role[];
|
|
51
|
+
export declare function isRole(value: string): value is Role;
|
|
52
|
+
export declare function roleGrants(role: Role, permission: Permission): boolean;
|
|
53
|
+
/** Roles that hold a project-only permission can only be granted on a project. */
|
|
54
|
+
export declare function assignableToEnvironment(role: Role): boolean;
|
|
55
|
+
/** A place a permission applies to: a project, or one of its environments. */
|
|
56
|
+
export type Place = {
|
|
57
|
+
projectId: string;
|
|
58
|
+
environmentId?: string | null;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* What someone holds: everything a permission check reads. The app builds
|
|
62
|
+
* it from what the vault says once per request; the vault builds it from
|
|
63
|
+
* its own store. Both then ask the same functions below, so the rules
|
|
64
|
+
* cannot drift apart.
|
|
65
|
+
*/
|
|
66
|
+
export type Holdings = {
|
|
67
|
+
/** Named in the vault's configuration: everything, everywhere. */
|
|
68
|
+
isRootAdmin: boolean;
|
|
69
|
+
/** Root admins and active users with the instance `owner` role. */
|
|
70
|
+
isOwner: boolean;
|
|
71
|
+
/** Live grants only. */
|
|
72
|
+
grants: readonly {
|
|
73
|
+
projectId: string;
|
|
74
|
+
environmentId: string | null;
|
|
75
|
+
role: Role;
|
|
76
|
+
}[];
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Whether `holder` may do `permission` at `place`.
|
|
80
|
+
*
|
|
81
|
+
* on a project its project grant; owners also manage every project
|
|
82
|
+
* on an environment its environment grant or its project's grant
|
|
83
|
+
*
|
|
84
|
+
* An environment grant never reaches up to the project: `developer` on
|
|
85
|
+
* `market/prod` does not let anyone rename `market`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function allows(holder: Holdings, permission: Permission, place: Place): boolean;
|
|
88
|
+
/** A sync reads as a principal of its own, `sync:<id>`. */
|
|
89
|
+
export declare function isSyncPrincipal(principal: string): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Whether `actor` may set `subject`'s role at `place`, or take it away
|
|
92
|
+
* (`role` null). `grant.manage` on the project decides, with one addition:
|
|
93
|
+
* whoever may manage a project's environments may let a sync read an
|
|
94
|
+
* environment they can read themselves, and take that away again, since
|
|
95
|
+
* adding and removing syncs is theirs to do.
|
|
96
|
+
*/
|
|
97
|
+
export declare function mayManageAccess(actor: Holdings, subject: string, change: Place & {
|
|
98
|
+
role: Role | null;
|
|
99
|
+
}): boolean;
|
|
100
|
+
//#endregion
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/access.ts
|
|
2
|
+
/**
|
|
3
|
+
* Who may do what: seven permissions and the six roles that bundle them.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately constants and not tables or a policy language. A grant names
|
|
6
|
+
* one role for one member on one place, and a check is a lookup in this file,
|
|
7
|
+
* so authorisation stays something you can read end to end.
|
|
8
|
+
*/
|
|
9
|
+
const PERMISSIONS = [
|
|
10
|
+
"secret.read",
|
|
11
|
+
"secret.write",
|
|
12
|
+
"secret.archive",
|
|
13
|
+
"audit.read",
|
|
14
|
+
"environment.manage",
|
|
15
|
+
"grant.manage",
|
|
16
|
+
"project.manage"
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Permissions that only make sense on a project. Granting
|
|
20
|
+
* `environment.manage` on one environment would be incoherent: what it
|
|
21
|
+
* authorises is creating that environment's siblings.
|
|
22
|
+
*/
|
|
23
|
+
const PROJECT_ONLY_PERMISSIONS = [
|
|
24
|
+
"environment.manage",
|
|
25
|
+
"grant.manage",
|
|
26
|
+
"project.manage"
|
|
27
|
+
];
|
|
28
|
+
const ROLES = {
|
|
29
|
+
viewer: {
|
|
30
|
+
name: "Viewer",
|
|
31
|
+
description: "Read secret values.",
|
|
32
|
+
permissions: ["secret.read"]
|
|
33
|
+
},
|
|
34
|
+
developer: {
|
|
35
|
+
name: "Developer",
|
|
36
|
+
description: "Read and write secrets.",
|
|
37
|
+
permissions: ["secret.read", "secret.write"]
|
|
38
|
+
},
|
|
39
|
+
maintainer: {
|
|
40
|
+
name: "Maintainer",
|
|
41
|
+
description: "Read, write and retire secrets, and manage environments.",
|
|
42
|
+
permissions: [
|
|
43
|
+
"secret.read",
|
|
44
|
+
"secret.write",
|
|
45
|
+
"secret.archive",
|
|
46
|
+
"environment.manage"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"access-manager": {
|
|
50
|
+
name: "Access manager",
|
|
51
|
+
description: "Manage who has access. Cannot read secret values.",
|
|
52
|
+
permissions: ["grant.manage"]
|
|
53
|
+
},
|
|
54
|
+
auditor: {
|
|
55
|
+
name: "Auditor",
|
|
56
|
+
description: "Read the audit log. Cannot read secret values.",
|
|
57
|
+
permissions: ["audit.read"]
|
|
58
|
+
},
|
|
59
|
+
owner: {
|
|
60
|
+
name: "Owner",
|
|
61
|
+
description: "Everything, including reading secret values.",
|
|
62
|
+
permissions: [...PERMISSIONS]
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const ROLE_NAMES = Object.keys(ROLES);
|
|
66
|
+
function isRole(value) {
|
|
67
|
+
return Object.hasOwn(ROLES, value);
|
|
68
|
+
}
|
|
69
|
+
function roleGrants(role, permission) {
|
|
70
|
+
return ROLES[role].permissions.includes(permission);
|
|
71
|
+
}
|
|
72
|
+
/** Roles that hold a project-only permission can only be granted on a project. */
|
|
73
|
+
function assignableToEnvironment(role) {
|
|
74
|
+
return !ROLES[role].permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether `holder` may do `permission` at `place`.
|
|
78
|
+
*
|
|
79
|
+
* on a project its project grant; owners also manage every project
|
|
80
|
+
* on an environment its environment grant or its project's grant
|
|
81
|
+
*
|
|
82
|
+
* An environment grant never reaches up to the project: `developer` on
|
|
83
|
+
* `market/prod` does not let anyone rename `market`.
|
|
84
|
+
*/
|
|
85
|
+
function allows(holder, permission, place) {
|
|
86
|
+
if (holder.isRootAdmin) return true;
|
|
87
|
+
const environmentId = place.environmentId ?? null;
|
|
88
|
+
if (environmentId === null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) return true;
|
|
89
|
+
return holder.grants.some((grant) => grant.projectId === place.projectId && (grant.environmentId === null || grant.environmentId === environmentId) && roleGrants(grant.role, permission));
|
|
90
|
+
}
|
|
91
|
+
/** A sync reads as a principal of its own, `sync:<id>`. */
|
|
92
|
+
function isSyncPrincipal(principal) {
|
|
93
|
+
return principal.startsWith("sync:");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether `actor` may set `subject`'s role at `place`, or take it away
|
|
97
|
+
* (`role` null). `grant.manage` on the project decides, with one addition:
|
|
98
|
+
* whoever may manage a project's environments may let a sync read an
|
|
99
|
+
* environment they can read themselves, and take that away again, since
|
|
100
|
+
* adding and removing syncs is theirs to do.
|
|
101
|
+
*/
|
|
102
|
+
function mayManageAccess(actor, subject, change) {
|
|
103
|
+
if (allows(actor, "grant.manage", { projectId: change.projectId })) return true;
|
|
104
|
+
if (!isSyncPrincipal(subject) || !allows(actor, "environment.manage", { projectId: change.projectId })) return false;
|
|
105
|
+
if (change.role === null) return true;
|
|
106
|
+
return change.role === "viewer" && (change.environmentId ?? null) !== null && allows(actor, "secret.read", change);
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { PERMISSIONS, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_NAMES, allows, assignableToEnvironment, isRole, isSyncPrincipal, mayManageAccess, roleGrants };
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//#region src/audit/chain.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The audit log's format: one chain, written by two authors, the app and the
|
|
4
|
+
* vault (docs/design/single-database.md, question 6).
|
|
5
|
+
*
|
|
6
|
+
* mac = HMAC-SHA256(author's key, "coffre.audit.mac.v2" ‖ prev_hash ‖ fields)
|
|
7
|
+
* hash = SHA-256( "coffre.audit.chain.v2" ‖ prev_hash ‖ fields ‖ mac)
|
|
8
|
+
*
|
|
9
|
+
* The MAC says which author wrote an entry: only the holder of its key can
|
|
10
|
+
* make it. The hash is public: anyone who can read the table can check that
|
|
11
|
+
* each entry follows the one before, MACs included, so a signed checkpoint
|
|
12
|
+
* pins a prefix byte for byte. Because each MAC covers the previous hash, an
|
|
13
|
+
* author can rewrite only its own entries since the other's last one.
|
|
14
|
+
*
|
|
15
|
+
* This authenticates what is retained, not its freshness: rolling back the
|
|
16
|
+
* log and its head together needs no key. That limit is accepted (question 7).
|
|
17
|
+
*
|
|
18
|
+
* Both authors encode through this module, and test/vectors/audit-v2.json
|
|
19
|
+
* pins the bytes, so they cannot drift apart. A change to what is encoded is
|
|
20
|
+
* a new format version, never an edit of this one.
|
|
21
|
+
*/
|
|
22
|
+
/** Part of the format: a change to the encoding changes these. */
|
|
23
|
+
export declare const LOG_FORMAT = "coffre.audit.v2";
|
|
24
|
+
/** Length of a chain hash, and of a MAC, in bytes. */
|
|
25
|
+
export declare const HASH_BYTES = 32;
|
|
26
|
+
/** The `prev_hash` of the first entry. */
|
|
27
|
+
export declare const GENESIS_HASH: Buffer;
|
|
28
|
+
export type Author = 'app' | 'vault';
|
|
29
|
+
/**
|
|
30
|
+
* Every field an entry's MAC and hash cover, in their order. Times are
|
|
31
|
+
* milliseconds since the epoch, from the database's clock; sequence numbers
|
|
32
|
+
* are bigints, encoded in decimal; `metadata` is JSON text, covered as
|
|
33
|
+
* stored.
|
|
34
|
+
*/
|
|
35
|
+
export type LogFields = {
|
|
36
|
+
seq: bigint;
|
|
37
|
+
author: Author;
|
|
38
|
+
keyId: string;
|
|
39
|
+
occurredAt: number;
|
|
40
|
+
actor: string;
|
|
41
|
+
action: string;
|
|
42
|
+
decision: string;
|
|
43
|
+
code: string | null;
|
|
44
|
+
subjectPrincipal: string | null;
|
|
45
|
+
projectId: string | null;
|
|
46
|
+
environmentId: string | null;
|
|
47
|
+
secretId: string | null;
|
|
48
|
+
secretVersionId: string | null;
|
|
49
|
+
operationId: string | null;
|
|
50
|
+
requestId: string | null;
|
|
51
|
+
sourceIp: string | null;
|
|
52
|
+
relatedSeq: bigint | null;
|
|
53
|
+
metadata: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The fields as bytes, injectively: each is length-prefixed, a null is
|
|
57
|
+
* length -1, distinct from the empty string, and numbers are decimal text.
|
|
58
|
+
* Strings contain Unicode scalar values: lone UTF-16 surrogates are refused,
|
|
59
|
+
* not replaced on conversion to UTF-8. Undefined is not a wire value.
|
|
60
|
+
*/
|
|
61
|
+
export declare function encodeFields(fields: LogFields): Buffer;
|
|
62
|
+
/** A key one author MACs its entries with, and the id each entry records. */
|
|
63
|
+
export type LogKey = {
|
|
64
|
+
author: Author;
|
|
65
|
+
keyId: string;
|
|
66
|
+
key: Buffer;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* An author's log key, derived from one of its secrets: `auditChainKey` for
|
|
70
|
+
* the app. The id is a fingerprint of the key, so a rotation shows in every
|
|
71
|
+
* entry, and an old key can be kept for verification by its id.
|
|
72
|
+
*/
|
|
73
|
+
export declare function deriveLogKey(author: Author, secret: Uint8Array): LogKey;
|
|
74
|
+
/** The MAC of an entry, under its author's key. */
|
|
75
|
+
export declare function entryMac(key: Buffer, prevHash: Buffer, fields: LogFields): Buffer;
|
|
76
|
+
/** The public hash of an entry, which the next one chains to. */
|
|
77
|
+
export declare function entryHash(prevHash: Buffer, fields: LogFields, mac: Buffer): Buffer;
|
|
78
|
+
/** An entry's MAC and hash, as `key` writes it after `prevHash`. */
|
|
79
|
+
export declare function sealEntry(key: LogKey, prevHash: Buffer, fields: LogFields): {
|
|
80
|
+
mac: Buffer;
|
|
81
|
+
hash: Buffer;
|
|
82
|
+
};
|
|
83
|
+
export type StoredEntry = LogFields & {
|
|
84
|
+
prevHash: Buffer;
|
|
85
|
+
mac: Buffer;
|
|
86
|
+
hash: Buffer;
|
|
87
|
+
};
|
|
88
|
+
export type ChainVerification =
|
|
89
|
+
/** `authenticated` is below `entries` only for a `chainOnly` author's entries. */
|
|
90
|
+
{
|
|
91
|
+
ok: true;
|
|
92
|
+
entries: number;
|
|
93
|
+
head: Buffer;
|
|
94
|
+
nextSeq: bigint;
|
|
95
|
+
authenticated: number;
|
|
96
|
+
} | {
|
|
97
|
+
ok: false;
|
|
98
|
+
failedAtSeq: bigint;
|
|
99
|
+
reason: string;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Check a run of entries, oldest first: that the numbers run on from
|
|
103
|
+
* `startSeq` without a gap, that each links to the hash before it, that each
|
|
104
|
+
* hash is its entry's, and that each entry carries a MAC from one of `keys`.
|
|
105
|
+
*
|
|
106
|
+
* An author whose key the verifier does not hold fails every entry it wrote,
|
|
107
|
+
* unless it is named in `chainOnly`: then its entries are checked for their
|
|
108
|
+
* place in the chain only, and `authenticated` counts the rest. That is a
|
|
109
|
+
* partial verdict, for a caller that has the other author check its own
|
|
110
|
+
* entries over the same prefix; the public chain alone proves nothing about
|
|
111
|
+
* who wrote an entry, since anyone who can insert a row can link it.
|
|
112
|
+
*/
|
|
113
|
+
export declare function verifyEntries(entries: readonly StoredEntry[], { startSeq, startPrevHash, keys, chainOnly }: {
|
|
114
|
+
startSeq?: bigint;
|
|
115
|
+
startPrevHash?: Buffer;
|
|
116
|
+
keys: readonly LogKey[];
|
|
117
|
+
chainOnly?: readonly Author[];
|
|
118
|
+
}): ChainVerification;
|
|
119
|
+
//#endregion
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/audit/chain.ts
|
|
3
|
+
/**
|
|
4
|
+
* The audit log's format: one chain, written by two authors, the app and the
|
|
5
|
+
* vault (docs/design/single-database.md, question 6).
|
|
6
|
+
*
|
|
7
|
+
* mac = HMAC-SHA256(author's key, "coffre.audit.mac.v2" ‖ prev_hash ‖ fields)
|
|
8
|
+
* hash = SHA-256( "coffre.audit.chain.v2" ‖ prev_hash ‖ fields ‖ mac)
|
|
9
|
+
*
|
|
10
|
+
* The MAC says which author wrote an entry: only the holder of its key can
|
|
11
|
+
* make it. The hash is public: anyone who can read the table can check that
|
|
12
|
+
* each entry follows the one before, MACs included, so a signed checkpoint
|
|
13
|
+
* pins a prefix byte for byte. Because each MAC covers the previous hash, an
|
|
14
|
+
* author can rewrite only its own entries since the other's last one.
|
|
15
|
+
*
|
|
16
|
+
* This authenticates what is retained, not its freshness: rolling back the
|
|
17
|
+
* log and its head together needs no key. That limit is accepted (question 7).
|
|
18
|
+
*
|
|
19
|
+
* Both authors encode through this module, and test/vectors/audit-v2.json
|
|
20
|
+
* pins the bytes, so they cannot drift apart. A change to what is encoded is
|
|
21
|
+
* a new format version, never an edit of this one.
|
|
22
|
+
*/
|
|
23
|
+
/** Part of the format: a change to the encoding changes these. */
|
|
24
|
+
const LOG_FORMAT = "coffre.audit.v2";
|
|
25
|
+
const MAC_DOMAIN = Buffer.from("coffre.audit.mac.v2", "utf8");
|
|
26
|
+
const CHAIN_DOMAIN = Buffer.from("coffre.audit.chain.v2", "utf8");
|
|
27
|
+
/** Length of a chain hash, and of a MAC, in bytes. */
|
|
28
|
+
const HASH_BYTES = 32;
|
|
29
|
+
/** The `prev_hash` of the first entry. */
|
|
30
|
+
const GENESIS_HASH = Buffer.alloc(32, 0);
|
|
31
|
+
const FIELD_ORDER = [
|
|
32
|
+
"seq",
|
|
33
|
+
"author",
|
|
34
|
+
"keyId",
|
|
35
|
+
"occurredAt",
|
|
36
|
+
"actor",
|
|
37
|
+
"action",
|
|
38
|
+
"decision",
|
|
39
|
+
"code",
|
|
40
|
+
"subjectPrincipal",
|
|
41
|
+
"projectId",
|
|
42
|
+
"environmentId",
|
|
43
|
+
"secretId",
|
|
44
|
+
"secretVersionId",
|
|
45
|
+
"operationId",
|
|
46
|
+
"requestId",
|
|
47
|
+
"sourceIp",
|
|
48
|
+
"relatedSeq",
|
|
49
|
+
"metadata"
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* The fields as bytes, injectively: each is length-prefixed, a null is
|
|
53
|
+
* length -1, distinct from the empty string, and numbers are decimal text.
|
|
54
|
+
* Strings contain Unicode scalar values: lone UTF-16 surrogates are refused,
|
|
55
|
+
* not replaced on conversion to UTF-8. Undefined is not a wire value.
|
|
56
|
+
*/
|
|
57
|
+
function encodeFields(fields) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const name of FIELD_ORDER) {
|
|
60
|
+
const raw = fields[name];
|
|
61
|
+
const header = Buffer.alloc(4);
|
|
62
|
+
if (raw === void 0) throw new Error(`${name} cannot be undefined`);
|
|
63
|
+
if (raw === null) {
|
|
64
|
+
header.writeInt32BE(-1, 0);
|
|
65
|
+
parts.push(header);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (typeof raw === "number" && !Number.isSafeInteger(raw)) throw new Error(`${name} must be a whole number`);
|
|
69
|
+
if (typeof raw === "string" && /[\uD800-\uDFFF]/u.test(raw)) throw new Error(`${name} must be well-formed Unicode`);
|
|
70
|
+
const value = Buffer.from(typeof raw === "string" ? raw : raw.toString(10), "utf8");
|
|
71
|
+
header.writeInt32BE(value.length, 0);
|
|
72
|
+
parts.push(header, value);
|
|
73
|
+
}
|
|
74
|
+
return Buffer.concat(parts);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* An author's log key, derived from one of its secrets: `auditChainKey` for
|
|
78
|
+
* the app. The id is a fingerprint of the key, so a rotation shows in every
|
|
79
|
+
* entry, and an old key can be kept for verification by its id.
|
|
80
|
+
*/
|
|
81
|
+
function deriveLogKey(author, secret) {
|
|
82
|
+
if (secret.length < 32) throw new Error("a log key needs at least 32 bytes of secret");
|
|
83
|
+
const key = Buffer.from(hkdfSync("sha256", secret, /* @__PURE__ */ new Uint8Array(0), `coffre.audit.${author}.v2`, 32));
|
|
84
|
+
return {
|
|
85
|
+
author,
|
|
86
|
+
keyId: `${author}:${createHash("sha256").update(key).digest("hex").slice(0, 16)}`,
|
|
87
|
+
key
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** The MAC of an entry, under its author's key. */
|
|
91
|
+
function entryMac(key, prevHash, fields) {
|
|
92
|
+
checkHash(prevHash, "prevHash");
|
|
93
|
+
return createHmac("sha256", key).update(MAC_DOMAIN).update(prevHash).update(encodeFields(fields)).digest();
|
|
94
|
+
}
|
|
95
|
+
/** The public hash of an entry, which the next one chains to. */
|
|
96
|
+
function entryHash(prevHash, fields, mac) {
|
|
97
|
+
checkHash(prevHash, "prevHash");
|
|
98
|
+
checkHash(mac, "mac");
|
|
99
|
+
return createHash("sha256").update(CHAIN_DOMAIN).update(prevHash).update(encodeFields(fields)).update(mac).digest();
|
|
100
|
+
}
|
|
101
|
+
/** An entry's MAC and hash, as `key` writes it after `prevHash`. */
|
|
102
|
+
function sealEntry(key, prevHash, fields) {
|
|
103
|
+
if (fields.author !== key.author || fields.keyId !== key.keyId) throw new Error(`an entry by ${fields.author} under ${fields.keyId} cannot be sealed with ${key.keyId}`);
|
|
104
|
+
const mac = entryMac(key.key, prevHash, fields);
|
|
105
|
+
return {
|
|
106
|
+
mac,
|
|
107
|
+
hash: entryHash(prevHash, fields, mac)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check a run of entries, oldest first: that the numbers run on from
|
|
112
|
+
* `startSeq` without a gap, that each links to the hash before it, that each
|
|
113
|
+
* hash is its entry's, and that each entry carries a MAC from one of `keys`.
|
|
114
|
+
*
|
|
115
|
+
* An author whose key the verifier does not hold fails every entry it wrote,
|
|
116
|
+
* unless it is named in `chainOnly`: then its entries are checked for their
|
|
117
|
+
* place in the chain only, and `authenticated` counts the rest. That is a
|
|
118
|
+
* partial verdict, for a caller that has the other author check its own
|
|
119
|
+
* entries over the same prefix; the public chain alone proves nothing about
|
|
120
|
+
* who wrote an entry, since anyone who can insert a row can link it.
|
|
121
|
+
*/
|
|
122
|
+
function verifyEntries(entries, { startSeq = 0n, startPrevHash = GENESIS_HASH, keys, chainOnly = [] }) {
|
|
123
|
+
const authors = new Set(keys.map((key) => key.author));
|
|
124
|
+
let expectedSeq = startSeq;
|
|
125
|
+
let previous = startPrevHash;
|
|
126
|
+
let authenticated = 0;
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const fail = (reason) => ({
|
|
129
|
+
ok: false,
|
|
130
|
+
failedAtSeq: entry.seq,
|
|
131
|
+
reason
|
|
132
|
+
});
|
|
133
|
+
if (entry.seq !== expectedSeq) return fail(`sequence gap: expected seq ${expectedSeq}, found ${entry.seq}`);
|
|
134
|
+
if (!equal(entry.prevHash, previous)) return fail("prev_hash does not match the entry before");
|
|
135
|
+
if (entry.mac.length !== 32 || !equal(entryHash(entry.prevHash, entry, entry.mac), entry.hash)) return fail("hash does not match the entry");
|
|
136
|
+
if (authors.has(entry.author)) {
|
|
137
|
+
const key = keys.find((candidate) => candidate.keyId === entry.keyId && candidate.author === entry.author);
|
|
138
|
+
if (key === void 0) return fail(`written under ${entry.keyId}, a key this verifier does not hold`);
|
|
139
|
+
if (!equal(entryMac(key.key, entry.prevHash, entry), entry.mac)) return fail(`not written by the ${entry.author}: its MAC does not match`);
|
|
140
|
+
authenticated += 1;
|
|
141
|
+
} else if (!chainOnly.includes(entry.author)) return fail(`written as the ${entry.author}, whose keys this verifier does not hold`);
|
|
142
|
+
previous = entry.hash;
|
|
143
|
+
expectedSeq = entry.seq + 1n;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
entries: entries.length,
|
|
148
|
+
head: previous,
|
|
149
|
+
nextSeq: expectedSeq,
|
|
150
|
+
authenticated
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function checkHash(value, what) {
|
|
154
|
+
if (value.length !== 32) throw new Error(`${what} must be 32 bytes, got ${value.length}`);
|
|
155
|
+
}
|
|
156
|
+
function equal(a, b) {
|
|
157
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
export { GENESIS_HASH, HASH_BYTES, LOG_FORMAT, deriveLogKey, encodeFields, entryHash, entryMac, sealEntry, verifyEntries };
|
package/dist/dotenv.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/dotenv.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A small .env parser, for bulk import, and its inverse, for export.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately hand-written rather than pulled from npm: this runs inside the
|
|
6
|
+
* service that holds every credential we own, and the whole grammar is about
|
|
7
|
+
* thirty lines. The server parses and the CLI formats with this one module,
|
|
8
|
+
* so `coffre export` output always imports back unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Supported: `KEY=value`, `export KEY=value`, single and double quoting,
|
|
11
|
+
* escapes inside double quotes, `#` comments, blank lines, and surrounding
|
|
12
|
+
* whitespace. NOT supported: multi-line values and variable interpolation --
|
|
13
|
+
* both are reported as errors rather than being silently mangled.
|
|
14
|
+
*/
|
|
15
|
+
export type ParsedEntry = {
|
|
16
|
+
key: string;
|
|
17
|
+
value: string;
|
|
18
|
+
line: number;
|
|
19
|
+
};
|
|
20
|
+
export type ParseProblem = {
|
|
21
|
+
line: number;
|
|
22
|
+
text: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export type ParseResult = {
|
|
26
|
+
entries: ParsedEntry[];
|
|
27
|
+
problems: ParseProblem[];
|
|
28
|
+
};
|
|
29
|
+
export declare function parseDotenv(input: string): ParseResult;
|
|
30
|
+
/**
|
|
31
|
+
* Write entries as a .env file that `parseDotenv` reads back exactly.
|
|
32
|
+
*
|
|
33
|
+
* Plain values stay bare; anything else is single-quoted, which is literal;
|
|
34
|
+
* values holding a quote or a line break are double-quoted with the four
|
|
35
|
+
* escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatDotenv(entries: Iterable<readonly [key: string, value: string]>): string;
|
|
38
|
+
/**
|
|
39
|
+
* Write entries as POSIX shell `export` statements, for
|
|
40
|
+
* `eval "$(coffre export … --format shell)"`. Single quotes are the only
|
|
41
|
+
* shell quoting with no expansion inside; a single quote itself is written
|
|
42
|
+
* as `'\''`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function formatShellExports(entries: Iterable<readonly [key: string, value: string]>): string;
|
|
45
|
+
//#endregion
|