@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/access.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may do what: seven permissions and the six roles that bundle them.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately constants and not tables or a policy language. A grant names
|
|
5
|
+
* one role for one member on one place, and a check is a lookup in this file,
|
|
6
|
+
* so authorisation stays something you can read end to end.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PERMISSIONS = [
|
|
10
|
+
'secret.read',
|
|
11
|
+
'secret.write',
|
|
12
|
+
'secret.archive',
|
|
13
|
+
'audit.read',
|
|
14
|
+
'environment.manage',
|
|
15
|
+
'grant.manage',
|
|
16
|
+
'project.manage',
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type Permission = (typeof PERMISSIONS)[number];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Permissions that only make sense on a project. Granting
|
|
23
|
+
* `environment.manage` on one environment would be incoherent: what it
|
|
24
|
+
* authorises is creating that environment's siblings.
|
|
25
|
+
*/
|
|
26
|
+
export const PROJECT_ONLY_PERMISSIONS: readonly Permission[] = [
|
|
27
|
+
'environment.manage',
|
|
28
|
+
'grant.manage',
|
|
29
|
+
'project.manage',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const ROLES = {
|
|
33
|
+
viewer: {
|
|
34
|
+
name: 'Viewer',
|
|
35
|
+
description: 'Read secret values.',
|
|
36
|
+
permissions: ['secret.read'],
|
|
37
|
+
},
|
|
38
|
+
developer: {
|
|
39
|
+
name: 'Developer',
|
|
40
|
+
description: 'Read and write secrets.',
|
|
41
|
+
permissions: ['secret.read', 'secret.write'],
|
|
42
|
+
},
|
|
43
|
+
maintainer: {
|
|
44
|
+
name: 'Maintainer',
|
|
45
|
+
description: 'Read, write and retire secrets, and manage environments.',
|
|
46
|
+
permissions: ['secret.read', 'secret.write', 'secret.archive', 'environment.manage'],
|
|
47
|
+
},
|
|
48
|
+
'access-manager': {
|
|
49
|
+
name: 'Access manager',
|
|
50
|
+
description: 'Manage who has access. Cannot read secret values.',
|
|
51
|
+
permissions: ['grant.manage'],
|
|
52
|
+
},
|
|
53
|
+
auditor: {
|
|
54
|
+
name: 'Auditor',
|
|
55
|
+
description: 'Read the audit log. Cannot read secret values.',
|
|
56
|
+
permissions: ['audit.read'],
|
|
57
|
+
},
|
|
58
|
+
owner: {
|
|
59
|
+
name: 'Owner',
|
|
60
|
+
description: 'Everything, including reading secret values.',
|
|
61
|
+
permissions: [...PERMISSIONS],
|
|
62
|
+
},
|
|
63
|
+
} as const satisfies Record<string, {
|
|
64
|
+
name: string;
|
|
65
|
+
description: string;
|
|
66
|
+
permissions: readonly Permission[];
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
export type Role = keyof typeof ROLES;
|
|
70
|
+
|
|
71
|
+
export const ROLE_NAMES = Object.keys(ROLES) as Role[];
|
|
72
|
+
|
|
73
|
+
export function isRole(value: string): value is Role {
|
|
74
|
+
return Object.hasOwn(ROLES, value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function roleGrants(role: Role, permission: Permission): boolean {
|
|
78
|
+
return (ROLES[role].permissions as readonly Permission[]).includes(permission);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Roles that hold a project-only permission can only be granted on a project. */
|
|
82
|
+
export function assignableToEnvironment(role: Role): boolean {
|
|
83
|
+
return !ROLES[role].permissions.some((permission) =>
|
|
84
|
+
PROJECT_ONLY_PERMISSIONS.includes(permission),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A place a permission applies to: a project, or one of its environments. */
|
|
89
|
+
export type Place = { projectId: string; environmentId?: string | null };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What someone holds: everything a permission check reads. The app builds
|
|
93
|
+
* it from what the vault says once per request; the vault builds it from
|
|
94
|
+
* its own store. Both then ask the same functions below, so the rules
|
|
95
|
+
* cannot drift apart.
|
|
96
|
+
*/
|
|
97
|
+
export type Holdings = {
|
|
98
|
+
/** Named in the vault's configuration: everything, everywhere. */
|
|
99
|
+
isRootAdmin: boolean;
|
|
100
|
+
/** Root admins and active users with the instance `owner` role. */
|
|
101
|
+
isOwner: boolean;
|
|
102
|
+
/** Live grants only. */
|
|
103
|
+
grants: readonly { projectId: string; environmentId: string | null; role: Role }[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether `holder` may do `permission` at `place`.
|
|
108
|
+
*
|
|
109
|
+
* on a project its project grant; owners also manage every project
|
|
110
|
+
* on an environment its environment grant or its project's grant
|
|
111
|
+
*
|
|
112
|
+
* An environment grant never reaches up to the project: `developer` on
|
|
113
|
+
* `market/prod` does not let anyone rename `market`.
|
|
114
|
+
*/
|
|
115
|
+
export function allows(holder: Holdings, permission: Permission, place: Place): boolean {
|
|
116
|
+
if (holder.isRootAdmin) return true;
|
|
117
|
+
const environmentId = place.environmentId ?? null;
|
|
118
|
+
if (environmentId === null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return holder.grants.some(
|
|
122
|
+
(grant) =>
|
|
123
|
+
grant.projectId === place.projectId &&
|
|
124
|
+
(grant.environmentId === null || grant.environmentId === environmentId) &&
|
|
125
|
+
roleGrants(grant.role, permission),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A sync reads as a principal of its own, `sync:<id>`. */
|
|
130
|
+
export function isSyncPrincipal(principal: string): boolean {
|
|
131
|
+
return principal.startsWith('sync:');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `actor` may set `subject`'s role at `place`, or take it away
|
|
136
|
+
* (`role` null). `grant.manage` on the project decides, with one addition:
|
|
137
|
+
* whoever may manage a project's environments may let a sync read an
|
|
138
|
+
* environment they can read themselves, and take that away again, since
|
|
139
|
+
* adding and removing syncs is theirs to do.
|
|
140
|
+
*/
|
|
141
|
+
export function mayManageAccess(
|
|
142
|
+
actor: Holdings,
|
|
143
|
+
subject: string,
|
|
144
|
+
change: Place & { role: Role | null },
|
|
145
|
+
): boolean {
|
|
146
|
+
if (allows(actor, 'grant.manage', { projectId: change.projectId })) return true;
|
|
147
|
+
if (!isSyncPrincipal(subject) || !allows(actor, 'environment.manage', { projectId: change.projectId })) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (change.role === null) return true;
|
|
151
|
+
return change.role === 'viewer' && (change.environmentId ?? null) !== null && allows(actor, 'secret.read', change);
|
|
152
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
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
|
+
|
|
24
|
+
/** Part of the format: a change to the encoding changes these. */
|
|
25
|
+
export const LOG_FORMAT = 'coffre.audit.v2';
|
|
26
|
+
const MAC_DOMAIN = Buffer.from('coffre.audit.mac.v2', 'utf8');
|
|
27
|
+
const CHAIN_DOMAIN = Buffer.from('coffre.audit.chain.v2', 'utf8');
|
|
28
|
+
|
|
29
|
+
/** Length of a chain hash, and of a MAC, in bytes. */
|
|
30
|
+
export const HASH_BYTES = 32;
|
|
31
|
+
|
|
32
|
+
/** The `prev_hash` of the first entry. */
|
|
33
|
+
export const GENESIS_HASH: Buffer = Buffer.alloc(HASH_BYTES, 0);
|
|
34
|
+
|
|
35
|
+
export type Author = 'app' | 'vault';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every field an entry's MAC and hash cover, in their order. Times are
|
|
39
|
+
* milliseconds since the epoch, from the database's clock; sequence numbers
|
|
40
|
+
* are bigints, encoded in decimal; `metadata` is JSON text, covered as
|
|
41
|
+
* stored.
|
|
42
|
+
*/
|
|
43
|
+
export type LogFields = {
|
|
44
|
+
seq: bigint;
|
|
45
|
+
author: Author;
|
|
46
|
+
keyId: string;
|
|
47
|
+
occurredAt: number;
|
|
48
|
+
actor: string;
|
|
49
|
+
action: string;
|
|
50
|
+
decision: string;
|
|
51
|
+
code: string | null;
|
|
52
|
+
subjectPrincipal: string | null;
|
|
53
|
+
projectId: string | null;
|
|
54
|
+
environmentId: string | null;
|
|
55
|
+
secretId: string | null;
|
|
56
|
+
secretVersionId: string | null;
|
|
57
|
+
operationId: string | null;
|
|
58
|
+
requestId: string | null;
|
|
59
|
+
sourceIp: string | null;
|
|
60
|
+
relatedSeq: bigint | null;
|
|
61
|
+
metadata: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const FIELD_ORDER = [
|
|
65
|
+
'seq',
|
|
66
|
+
'author',
|
|
67
|
+
'keyId',
|
|
68
|
+
'occurredAt',
|
|
69
|
+
'actor',
|
|
70
|
+
'action',
|
|
71
|
+
'decision',
|
|
72
|
+
'code',
|
|
73
|
+
'subjectPrincipal',
|
|
74
|
+
'projectId',
|
|
75
|
+
'environmentId',
|
|
76
|
+
'secretId',
|
|
77
|
+
'secretVersionId',
|
|
78
|
+
'operationId',
|
|
79
|
+
'requestId',
|
|
80
|
+
'sourceIp',
|
|
81
|
+
'relatedSeq',
|
|
82
|
+
'metadata',
|
|
83
|
+
] as const satisfies readonly (keyof LogFields)[];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The fields as bytes, injectively: each is length-prefixed, a null is
|
|
87
|
+
* length -1, distinct from the empty string, and numbers are decimal text.
|
|
88
|
+
* Strings contain Unicode scalar values: lone UTF-16 surrogates are refused,
|
|
89
|
+
* not replaced on conversion to UTF-8. Undefined is not a wire value.
|
|
90
|
+
*/
|
|
91
|
+
export function encodeFields(fields: LogFields): Buffer {
|
|
92
|
+
const parts: Buffer[] = [];
|
|
93
|
+
for (const name of FIELD_ORDER) {
|
|
94
|
+
const raw = fields[name];
|
|
95
|
+
const header = Buffer.alloc(4);
|
|
96
|
+
if (raw === undefined) throw new Error(`${name} cannot be undefined`);
|
|
97
|
+
if (raw === null) {
|
|
98
|
+
header.writeInt32BE(-1, 0);
|
|
99
|
+
parts.push(header);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw === 'number' && !Number.isSafeInteger(raw)) throw new Error(`${name} must be a whole number`);
|
|
103
|
+
if (typeof raw === 'string' && /[\uD800-\uDFFF]/u.test(raw)) throw new Error(`${name} must be well-formed Unicode`);
|
|
104
|
+
const value = Buffer.from(typeof raw === 'string' ? raw : raw.toString(10), 'utf8');
|
|
105
|
+
header.writeInt32BE(value.length, 0);
|
|
106
|
+
parts.push(header, value);
|
|
107
|
+
}
|
|
108
|
+
return Buffer.concat(parts);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A key one author MACs its entries with, and the id each entry records. */
|
|
112
|
+
export type LogKey = { author: Author; keyId: string; key: Buffer };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* An author's log key, derived from one of its secrets: `auditChainKey` for
|
|
116
|
+
* the app. The id is a fingerprint of the key, so a rotation shows in every
|
|
117
|
+
* entry, and an old key can be kept for verification by its id.
|
|
118
|
+
*/
|
|
119
|
+
export function deriveLogKey(author: Author, secret: Uint8Array): LogKey {
|
|
120
|
+
if (secret.length < 32) throw new Error('a log key needs at least 32 bytes of secret');
|
|
121
|
+
const key = Buffer.from(hkdfSync('sha256', secret, new Uint8Array(0), `coffre.audit.${author}.v2`, 32));
|
|
122
|
+
const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
123
|
+
return { author, keyId: `${author}:${fingerprint}`, key };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The MAC of an entry, under its author's key. */
|
|
127
|
+
export function entryMac(key: Buffer, prevHash: Buffer, fields: LogFields): Buffer {
|
|
128
|
+
checkHash(prevHash, 'prevHash');
|
|
129
|
+
return createHmac('sha256', key).update(MAC_DOMAIN).update(prevHash).update(encodeFields(fields)).digest();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The public hash of an entry, which the next one chains to. */
|
|
133
|
+
export function entryHash(prevHash: Buffer, fields: LogFields, mac: Buffer): Buffer {
|
|
134
|
+
checkHash(prevHash, 'prevHash');
|
|
135
|
+
checkHash(mac, 'mac');
|
|
136
|
+
return createHash('sha256').update(CHAIN_DOMAIN).update(prevHash).update(encodeFields(fields)).update(mac).digest();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** An entry's MAC and hash, as `key` writes it after `prevHash`. */
|
|
140
|
+
export function sealEntry(key: LogKey, prevHash: Buffer, fields: LogFields): { mac: Buffer; hash: Buffer } {
|
|
141
|
+
if (fields.author !== key.author || fields.keyId !== key.keyId) {
|
|
142
|
+
throw new Error(`an entry by ${fields.author} under ${fields.keyId} cannot be sealed with ${key.keyId}`);
|
|
143
|
+
}
|
|
144
|
+
const mac = entryMac(key.key, prevHash, fields);
|
|
145
|
+
return { mac, hash: entryHash(prevHash, fields, mac) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type StoredEntry = LogFields & { prevHash: Buffer; mac: Buffer; hash: Buffer };
|
|
149
|
+
|
|
150
|
+
export type ChainVerification =
|
|
151
|
+
/** `authenticated` is below `entries` only for a `chainOnly` author's entries. */
|
|
152
|
+
| { ok: true; entries: number; head: Buffer; nextSeq: bigint; authenticated: number }
|
|
153
|
+
| { ok: false; failedAtSeq: bigint; reason: string };
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check a run of entries, oldest first: that the numbers run on from
|
|
157
|
+
* `startSeq` without a gap, that each links to the hash before it, that each
|
|
158
|
+
* hash is its entry's, and that each entry carries a MAC from one of `keys`.
|
|
159
|
+
*
|
|
160
|
+
* An author whose key the verifier does not hold fails every entry it wrote,
|
|
161
|
+
* unless it is named in `chainOnly`: then its entries are checked for their
|
|
162
|
+
* place in the chain only, and `authenticated` counts the rest. That is a
|
|
163
|
+
* partial verdict, for a caller that has the other author check its own
|
|
164
|
+
* entries over the same prefix; the public chain alone proves nothing about
|
|
165
|
+
* who wrote an entry, since anyone who can insert a row can link it.
|
|
166
|
+
*/
|
|
167
|
+
export function verifyEntries(
|
|
168
|
+
entries: readonly StoredEntry[],
|
|
169
|
+
{
|
|
170
|
+
startSeq = 0n,
|
|
171
|
+
startPrevHash = GENESIS_HASH,
|
|
172
|
+
keys,
|
|
173
|
+
chainOnly = [],
|
|
174
|
+
}: { startSeq?: bigint; startPrevHash?: Buffer; keys: readonly LogKey[]; chainOnly?: readonly Author[] },
|
|
175
|
+
): ChainVerification {
|
|
176
|
+
const authors = new Set(keys.map((key) => key.author));
|
|
177
|
+
let expectedSeq = startSeq;
|
|
178
|
+
let previous = startPrevHash;
|
|
179
|
+
let authenticated = 0;
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const fail = (reason: string) => ({ ok: false as const, failedAtSeq: entry.seq, reason });
|
|
182
|
+
if (entry.seq !== expectedSeq) return fail(`sequence gap: expected seq ${expectedSeq}, found ${entry.seq}`);
|
|
183
|
+
if (!equal(entry.prevHash, previous)) return fail('prev_hash does not match the entry before');
|
|
184
|
+
if (entry.mac.length !== HASH_BYTES || !equal(entryHash(entry.prevHash, entry, entry.mac), entry.hash)) {
|
|
185
|
+
return fail('hash does not match the entry');
|
|
186
|
+
}
|
|
187
|
+
if (authors.has(entry.author)) {
|
|
188
|
+
const key = keys.find((candidate) => candidate.keyId === entry.keyId && candidate.author === entry.author);
|
|
189
|
+
if (key === undefined) return fail(`written under ${entry.keyId}, a key this verifier does not hold`);
|
|
190
|
+
if (!equal(entryMac(key.key, entry.prevHash, entry), entry.mac)) return fail(`not written by the ${entry.author}: its MAC does not match`);
|
|
191
|
+
authenticated += 1;
|
|
192
|
+
} else if (!chainOnly.includes(entry.author)) {
|
|
193
|
+
return fail(`written as the ${entry.author}, whose keys this verifier does not hold`);
|
|
194
|
+
}
|
|
195
|
+
previous = entry.hash;
|
|
196
|
+
expectedSeq = entry.seq + 1n;
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, entries: entries.length, head: previous, nextSeq: expectedSeq, authenticated };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function checkHash(value: Buffer, what: string): void {
|
|
202
|
+
if (value.length !== HASH_BYTES) throw new Error(`${what} must be ${HASH_BYTES} bytes, got ${value.length}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function equal(a: Buffer, b: Buffer): boolean {
|
|
206
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
207
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Checkpoint } from './vault.ts';
|
|
2
|
+
|
|
3
|
+
/** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
|
|
4
|
+
export function checkpointMessage(checkpoint: Omit<Checkpoint, 'signature' | 'keyId'>): Uint8Array<ArrayBuffer> {
|
|
5
|
+
const { seq, hash, signedAt } = checkpoint;
|
|
6
|
+
return new Uint8Array(new TextEncoder().encode(`coffre.checkpoint.v3|${seq}|${hash}|${signedAt}`));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Whether `checkpoint` was signed by the key whose raw public half is `publicKey`. */
|
|
10
|
+
export async function verifyCheckpoint(checkpoint: Checkpoint, publicKey: string): Promise<boolean> {
|
|
11
|
+
const key = await crypto.subtle.importKey('raw', new Uint8Array(Buffer.from(publicKey, 'base64')), { name: 'Ed25519' }, false, ['verify']);
|
|
12
|
+
return crypto.subtle.verify('Ed25519', key, new Uint8Array(Buffer.from(checkpoint.signature, 'base64')), checkpointMessage(checkpoint));
|
|
13
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binding context for a secret's ciphertext.
|
|
3
|
+
*
|
|
4
|
+
* This is the AES-GCM additional authenticated data (AAD). It ties a ciphertext
|
|
5
|
+
* to exactly one (project, environment, secret) triple, so a ciphertext lifted
|
|
6
|
+
* out of the `dev` environment row and dropped into the `prod` row fails to
|
|
7
|
+
* decrypt rather than silently succeeding.
|
|
8
|
+
*
|
|
9
|
+
* These are immutable UUIDs, never names. Binding to `project/environment/key`
|
|
10
|
+
* names would mean that renaming an environment or a secret key makes every
|
|
11
|
+
* existing ciphertext permanently undecryptable.
|
|
12
|
+
*/
|
|
13
|
+
export type SecretContext = {
|
|
14
|
+
projectId: string;
|
|
15
|
+
environmentId: string;
|
|
16
|
+
secretId: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Version prefix for the AAD encoding. If the encoding ever changes, this
|
|
21
|
+
* changes with it, and old ciphertexts keep decrypting under the old rule.
|
|
22
|
+
*/
|
|
23
|
+
export const AAD_VERSION = 'coffre.aad.v1';
|
|
24
|
+
|
|
25
|
+
const UUID_RE =
|
|
26
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
27
|
+
|
|
28
|
+
function assertUuid(value: string, field: string): void {
|
|
29
|
+
if (typeof value !== 'string' || !UUID_RE.test(value)) {
|
|
30
|
+
throw new Error(`context.${field} must be a lowercase UUID, got: ${String(value)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Throw unless every field is a lowercase UUID. */
|
|
35
|
+
export function checkContext(ctx: SecretContext): void {
|
|
36
|
+
assertUuid(ctx.projectId, 'projectId');
|
|
37
|
+
assertUuid(ctx.environmentId, 'environmentId');
|
|
38
|
+
assertUuid(ctx.secretId, 'secretId');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Encode a context into AAD bytes.
|
|
43
|
+
*
|
|
44
|
+
* Every field is validated as a UUID first, which is what makes joining with
|
|
45
|
+
* `|` unambiguous: a UUID cannot contain the separator, so no two distinct
|
|
46
|
+
* contexts can encode to the same bytes.
|
|
47
|
+
*/
|
|
48
|
+
export function encodeAad(ctx: SecretContext): Buffer {
|
|
49
|
+
checkContext(ctx);
|
|
50
|
+
return Buffer.from(
|
|
51
|
+
`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`,
|
|
52
|
+
'utf8',
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/dotenv.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small .env parser, for bulk import, and its inverse, for export.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately hand-written rather than pulled from npm: this runs inside the
|
|
5
|
+
* service that holds every credential we own, and the whole grammar is about
|
|
6
|
+
* thirty lines. The server parses and the CLI formats with this one module,
|
|
7
|
+
* so `coffre export` output always imports back unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Supported: `KEY=value`, `export KEY=value`, single and double quoting,
|
|
10
|
+
* escapes inside double quotes, `#` comments, blank lines, and surrounding
|
|
11
|
+
* whitespace. NOT supported: multi-line values and variable interpolation --
|
|
12
|
+
* both are reported as errors rather than being silently mangled.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ParsedEntry = { key: string; value: string; line: number };
|
|
16
|
+
export type ParseProblem = { line: number; text: string; reason: string };
|
|
17
|
+
|
|
18
|
+
export type ParseResult = {
|
|
19
|
+
entries: ParsedEntry[];
|
|
20
|
+
problems: ParseProblem[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
24
|
+
|
|
25
|
+
export function parseDotenv(input: string): ParseResult {
|
|
26
|
+
const entries: ParsedEntry[] = [];
|
|
27
|
+
const problems: ParseProblem[] = [];
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
|
|
30
|
+
// Split on all three line-ending conventions. Splitting on /\r?\n/ alone
|
|
31
|
+
// leaves a lone \r inside the value, which silently swallows the following
|
|
32
|
+
// key into the preceding secret -- corruption with no error.
|
|
33
|
+
const lines = input.split(/\r\n|\n|\r/);
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < lines.length; index++) {
|
|
36
|
+
const lineNumber = index + 1;
|
|
37
|
+
const raw = lines[index];
|
|
38
|
+
const trimmed = raw.trim();
|
|
39
|
+
|
|
40
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
41
|
+
|
|
42
|
+
const withoutExport = trimmed.startsWith('export ')
|
|
43
|
+
? trimmed.slice('export '.length).trim()
|
|
44
|
+
: trimmed;
|
|
45
|
+
|
|
46
|
+
const equals = withoutExport.indexOf('=');
|
|
47
|
+
if (equals === -1) {
|
|
48
|
+
problems.push({ line: lineNumber, text: trimmed, reason: 'no "=" on this line' });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
53
|
+
if (!KEY_RE.test(key)) {
|
|
54
|
+
problems.push({
|
|
55
|
+
line: lineNumber,
|
|
56
|
+
text: key,
|
|
57
|
+
reason: 'key must match ^[A-Za-z_][A-Za-z0-9_]*$',
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let rest = withoutExport.slice(equals + 1).trim();
|
|
63
|
+
let value: string;
|
|
64
|
+
|
|
65
|
+
if (rest.startsWith('"')) {
|
|
66
|
+
const closing = findClosingQuote(rest, '"');
|
|
67
|
+
if (closing === -1) {
|
|
68
|
+
problems.push({
|
|
69
|
+
line: lineNumber,
|
|
70
|
+
text: key,
|
|
71
|
+
reason: 'unterminated double quote (multi-line values are not supported)',
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
value = unescapeDoubleQuoted(rest.slice(1, closing));
|
|
76
|
+
// Anything other than whitespace or a comment after the closing quote
|
|
77
|
+
// means the line is not what its author thought it was. Rejecting beats
|
|
78
|
+
// silently discarding part of a credential.
|
|
79
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
80
|
+
problems.push({
|
|
81
|
+
line: lineNumber,
|
|
82
|
+
text: key,
|
|
83
|
+
reason: 'unexpected text after the closing quote',
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
} else if (rest.startsWith("'")) {
|
|
88
|
+
const closing = rest.indexOf("'", 1);
|
|
89
|
+
if (closing === -1) {
|
|
90
|
+
problems.push({
|
|
91
|
+
line: lineNumber,
|
|
92
|
+
text: key,
|
|
93
|
+
reason: 'unterminated single quote (multi-line values are not supported)',
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Single quotes are literal, as in a shell: no escapes inside them.
|
|
98
|
+
value = rest.slice(1, closing);
|
|
99
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
100
|
+
problems.push({
|
|
101
|
+
line: lineNumber,
|
|
102
|
+
text: key,
|
|
103
|
+
reason: 'unexpected text after the closing quote',
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// Unquoted: strip a trailing comment, then trailing whitespace.
|
|
109
|
+
const comment = rest.indexOf(' #');
|
|
110
|
+
if (comment !== -1) rest = rest.slice(0, comment);
|
|
111
|
+
value = rest.trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A NUL byte cannot survive being put in a process environment: execve
|
|
115
|
+
// truncates at the first one, so `coffre run` would inject a silently
|
|
116
|
+
// shortened secret. Refuse it here rather than store something that will
|
|
117
|
+
// be wrong only at the point of use.
|
|
118
|
+
if (value.includes('\u0000')) {
|
|
119
|
+
problems.push({
|
|
120
|
+
line: lineNumber,
|
|
121
|
+
text: key,
|
|
122
|
+
reason: 'value contains a NUL byte, which cannot be passed in an environment',
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (seen.has(key)) {
|
|
128
|
+
problems.push({ line: lineNumber, text: key, reason: 'duplicate key in this file' });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seen.add(key);
|
|
132
|
+
|
|
133
|
+
entries.push({ key, value, line: lineNumber });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { entries, problems };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when what follows a closing quote is neither whitespace nor a comment. */
|
|
140
|
+
function trailingIsSignificant(rest: string): boolean {
|
|
141
|
+
const trimmed = rest.trim();
|
|
142
|
+
return trimmed !== '' && !trimmed.startsWith('#');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findClosingQuote(text: string, quote: string): number {
|
|
146
|
+
for (let index = 1; index < text.length; index++) {
|
|
147
|
+
if (text[index] === '\\') {
|
|
148
|
+
index++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (text[index] === quote) return index;
|
|
152
|
+
}
|
|
153
|
+
return -1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Expand escapes inside a double-quoted value.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately minimal: only the escapes that are necessary. `\n` and `\r`
|
|
160
|
+
* because multi-line values (PEM keys) cannot otherwise be expressed on one
|
|
161
|
+
* line, and `\\` `\"` `\'` because quoting needs them.
|
|
162
|
+
*
|
|
163
|
+
* EVERYTHING ELSE IS PRESERVED VERBATIM, backslash included. The obvious
|
|
164
|
+
* implementation -- returning the character and dropping the backslash for
|
|
165
|
+
* unknown escapes -- silently mangles credentials: `"C:\path\to\file"` came
|
|
166
|
+
* out as `C:path<TAB>ofile`, and `"\d+\w"` as `d+w`. A secrets store that
|
|
167
|
+
* quietly rewrites what you gave it is worse than one that refuses.
|
|
168
|
+
*
|
|
169
|
+
* A value that should contain no interpretation at all belongs in single
|
|
170
|
+
* quotes, which are literal.
|
|
171
|
+
*/
|
|
172
|
+
function unescapeDoubleQuoted(text: string): string {
|
|
173
|
+
return text.replace(/\\(.)/g, (match, character: string) => {
|
|
174
|
+
switch (character) {
|
|
175
|
+
case 'n':
|
|
176
|
+
return '\n';
|
|
177
|
+
case 'r':
|
|
178
|
+
return '\r';
|
|
179
|
+
case '\\':
|
|
180
|
+
case '"':
|
|
181
|
+
case "'":
|
|
182
|
+
return character;
|
|
183
|
+
default:
|
|
184
|
+
return match;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Values that read the same unquoted: no spaces, quotes, `#`, `$` or backslashes. */
|
|
190
|
+
const PLAIN_VALUE = /^[A-Za-z0-9_./:@+,=%?-]*$/;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Write entries as a .env file that `parseDotenv` reads back exactly.
|
|
194
|
+
*
|
|
195
|
+
* Plain values stay bare; anything else is single-quoted, which is literal;
|
|
196
|
+
* values holding a quote or a line break are double-quoted with the four
|
|
197
|
+
* escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
|
|
198
|
+
*/
|
|
199
|
+
export function formatDotenv(entries: Iterable<readonly [key: string, value: string]>): string {
|
|
200
|
+
let out = '';
|
|
201
|
+
for (const [key, value] of entries) {
|
|
202
|
+
assertWritable(key, value);
|
|
203
|
+
out += `${key}=${quoteDotenv(value)}\n`;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertWritable(key: string, value: string): void {
|
|
209
|
+
if (!KEY_RE.test(key)) throw new Error(`${JSON.stringify(key)} is not a valid variable name`);
|
|
210
|
+
if (value.includes('\u0000')) throw new Error(`${key} contains a NUL byte`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function quoteDotenv(value: string): string {
|
|
214
|
+
if (PLAIN_VALUE.test(value)) return value;
|
|
215
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
216
|
+
const escaped = value.replace(/[\\"\n\r]/g, (character) => {
|
|
217
|
+
switch (character) {
|
|
218
|
+
case '\n':
|
|
219
|
+
return '\\n';
|
|
220
|
+
case '\r':
|
|
221
|
+
return '\\r';
|
|
222
|
+
default:
|
|
223
|
+
return `\\${character}`;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return `"${escaped}"`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Write entries as POSIX shell `export` statements, for
|
|
231
|
+
* `eval "$(coffre export … --format shell)"`. Single quotes are the only
|
|
232
|
+
* shell quoting with no expansion inside; a single quote itself is written
|
|
233
|
+
* as `'\''`.
|
|
234
|
+
*/
|
|
235
|
+
export function formatShellExports(entries: Iterable<readonly [key: string, value: string]>): string {
|
|
236
|
+
let out = '';
|
|
237
|
+
for (const [key, value] of entries) {
|
|
238
|
+
assertWritable(key, value);
|
|
239
|
+
out += `export ${key}='${value.replaceAll("'", "'\\''")}'\n`;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|