@better-forge/vault 0.1.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.
@@ -1,177 +1,177 @@
1
-
2
-
3
- /**
4
- * Which parts of a configuration are secrets.
5
- *
6
- * Secrets never leave the API as stored: reads get a mask, and a write that sends the mask back means "keep the
7
- * stored value". The same table drives the config export, so a new secret field is declared in exactly one place.
8
- * Values read from a vault are references, not secrets, and are not listed here.
9
- *
10
- * The table is grouped by type for readability, but masking and restoring look at every secret field of the kind
11
- * whatever the object's type. A config can hold leftovers of another type (the type changed, or an API client sent
12
- * extra keys), and those must not read back in the clear.
13
- */
14
-
15
- import type { Sealer } from './sealed.js'
16
-
17
- export const SECRET_MASK = '••••••••'
18
-
19
- /** Three bullets in a row are never part of a real secret; they are a mask, or a mask somebody edited by hand. */
20
- const LOOKS_MASKED = /•{3}/
21
-
22
- export type SecretField =
23
- /** A string at `path`; `tail` also shows its last 4 characters (long tokens only) so it can be recognised. */
24
- | { path: readonly string[]; tail?: true }
25
- /** Every entry of the object at `mapAt` whose key names a credential (Authorization, X-Api-Key, ...). */
26
- | { mapAt: readonly string[] }
27
-
28
- /** Credential-looking header names. Wide on purpose: masking `X-Idempotency-Key` costs nothing, leaking `X-Auth` does. */
29
- export const SENSITIVE_HEADER = /auth|token|secret|key|pass|pwd|credential|cookie|session|signature/i
30
-
31
- export const uniqueSecretFields = (fields: readonly SecretField[]): SecretField[] => {
32
- const seen = new Set<string>()
33
- return fields.filter((field) => {
34
- const id = 'mapAt' in field ? `map:${field.mapAt.join('.')}` : `path:${field.path.join('.')}`
35
- return seen.has(id) ? false : (seen.add(id), true)
36
- })
37
- }
38
-
39
- type Json = Record<string, unknown>
40
-
41
- const isObject = (value: unknown): value is Json => !!value && typeof value === 'object' && !Array.isArray(value)
42
-
43
- function valueAt(root: unknown, path: readonly string[]): unknown {
44
- let current = root
45
- for (const segment of path) {
46
- if (!isObject(current)) return undefined
47
- current = current[segment]
48
- }
49
- return current
50
- }
51
-
52
- /** One concrete secret value: the object holding it and its key, plus the dotted name used in messages. */
53
- interface Slot { holder: Json; key: string; label: string; tail: boolean }
54
-
55
- /** `••••••••`, plus the last 4 characters of a long token so it can be told apart from another one. */
56
- function mask(value: string, tail: boolean): string {
57
- return `${SECRET_MASK}${tail && value.length >= 16 ? value.slice(-4) : ''}`
58
- }
59
-
60
- export type RestoreResult = { config: unknown } | { error: string }
61
-
62
- /** A switch that makes a destination less safe: a path whose value is `true`, or a path with the value that is the unsafe one (`verifyTls` off is `false`). */
63
- export type LooseningSwitch = readonly string[] | { path: readonly string[]; value: unknown }
64
-
65
- export function createSecretFields<Kind extends string>(kinds: Readonly<Record<Kind, {
66
- fields: readonly SecretField[]
67
- destinations: readonly (readonly string[])[]
68
- /** Switches that make a destination less safe (certificate checks off): turning one on counts as a changed destination, turning it off does not. */
69
- loosening?: readonly LooseningSwitch[]
70
- }>>) {
71
- function slotsIn(config: unknown, kind: Kind): Slot[] {
72
- const slots: Slot[] = []
73
- for (const field of kinds[kind].fields) {
74
- if ('mapAt' in field) {
75
- const holder = valueAt(config, field.mapAt)
76
- if (!isObject(holder)) continue
77
- for (const key of Object.keys(holder)) {
78
- if (SENSITIVE_HEADER.test(key)) slots.push({ holder, key, label: `${field.mapAt.join('.')}.${key}`, tail: false })
79
- }
80
- } else {
81
- const holder = valueAt(config, field.path.slice(0, -1))
82
- const key = field.path[field.path.length - 1]!
83
- if (isObject(holder) && key in holder) slots.push({ holder, key, label: field.path.join('.'), tail: field.tail === true })
84
- }
85
- }
86
- return slots
87
- }
88
-
89
- /** A copy of `config` with every secret replaced by its mask, whatever its type (a number is as secret as a string). */
90
- function maskSecrets<T>(kind: Kind, config: T): T {
91
- const copy = structuredClone(config)
92
- for (const { holder, key, tail } of slotsIn(copy, kind)) {
93
- const value = holder[key]
94
- if (value !== null && value !== undefined && value !== '') holder[key] = mask(String(value), tail)
95
- }
96
- return copy
97
- }
98
-
99
- /**
100
- * Resolves the secrets of an incoming config against the stored one: a mask that matches the stored secret becomes
101
- * that secret again. Refused instead of saved:
102
- * - a mask, or something that looks like an edited mask, that matches nothing stored;
103
- * - a secret that is not text;
104
- * - a kept secret while the place it is sent to changed (URL, host, port), so it cannot be redirected.
105
- * Pass `stored` as undefined for a new object (or one whose type changed), where any mask is a mistake.
106
- */
107
- function restoreSecrets(kind: Kind, incoming: unknown, stored: unknown): RestoreResult {
108
- const copy = structuredClone(incoming)
109
- const storedSlots = new Map(slotsIn(stored, kind).map((slot) => [slot.label, slot]))
110
- let kept = false
111
- for (const { holder, key, label, tail } of slotsIn(copy, kind)) {
112
- const value = holder[key]
113
- if (value === null || value === undefined) continue
114
- if (typeof value !== 'string') return { error: `${label} must be text` }
115
- if (!LOOKS_MASKED.test(value)) continue
116
- const previous = storedSlots.get(label)
117
- const original = previous?.holder[previous.key]
118
- if (typeof original === 'string' && original && value === mask(original, tail)) {
119
- holder[key] = original
120
- kept = true
121
- } else {
122
- return { error: `${label} is a masked placeholder that does not match the stored value; enter the full value to replace it` }
123
- }
124
- }
125
- if (kept) {
126
- const moved = changedDestination(kind, copy, stored)
127
- if (moved) return { error: `${moved} changed, so the stored secrets cannot be kept; enter them again` }
128
- }
129
- return { config: copy }
130
- }
131
-
132
- /** The first place a configuration is sent to (URL, host, port) that differs between `incoming` and `stored`, or null. */
133
- function changedDestination(kind: Kind, incoming: unknown, stored: unknown): string | null {
134
- for (const path of kinds[kind].destinations) {
135
- // An empty value means the same as an absent one.
136
- const normalised = (config: unknown) => valueAt(config, path) || null
137
- if (JSON.stringify(normalised(incoming)) !== JSON.stringify(normalised(stored))) return path.join('.')
138
- }
139
- for (const entry of kinds[kind].loosening ?? []) {
140
- const { path, value } = Array.isArray(entry) ? { path: entry as readonly string[], value: true } : entry as { path: readonly string[]; value: unknown }
141
- if (valueAt(incoming, path) === value && valueAt(stored, path) !== value) return path.join('.')
142
- }
143
- return null
144
- }
145
-
146
- /** A copy of `config` with every secret that is text passed through `convert` (a vault reference or a number is not text and stays). */
147
- function convertSecrets<T>(kind: Kind, config: T, convert: (value: string) => string): T {
148
- const copy = structuredClone(config)
149
- for (const { holder, key } of slotsIn(copy, kind)) {
150
- const value = holder[key]
151
- if (typeof value === 'string') holder[key] = convert(value)
152
- }
153
- return copy
154
- }
155
-
156
- /**
157
- * The codec of a column that stores a configuration as JSON text: the secrets are encrypted on the way in and opened
158
- * on the way out, so a copy of the database alone does not carry them. Text that is not JSON is passed through.
159
- */
160
- function configCodec(kind: Kind, sealer: Pick<Sealer, 'seal' | 'open'>): Pick<Sealer, 'seal' | 'open'> {
161
- const through = (convert: (value: string) => string) => (json: string): string => {
162
- let parsed: unknown
163
- try { parsed = JSON.parse(json) } catch { return json }
164
- // A config without a secret is returned as it was written, not as JSON.stringify would write it.
165
- let changed = false
166
- const converted = convertSecrets(kind, parsed, (value) => {
167
- const result = convert(value)
168
- if (result !== value) changed = true
169
- return result
170
- })
171
- return changed ? JSON.stringify(converted) : json
172
- }
173
- return { seal: through(sealer.seal), open: through(sealer.open) }
174
- }
175
-
176
- return { maskSecrets, restoreSecrets, changedDestination, configCodec }
177
- }
1
+
2
+
3
+ /**
4
+ * Which parts of a configuration are secrets.
5
+ *
6
+ * Secrets never leave the API as stored: reads get a mask, and a write that sends the mask back means "keep the
7
+ * stored value". The same table drives the config export, so a new secret field is declared in exactly one place.
8
+ * Values read from a vault are references, not secrets, and are not listed here.
9
+ *
10
+ * The table is grouped by type for readability, but masking and restoring look at every secret field of the kind
11
+ * whatever the object's type. A config can hold leftovers of another type (the type changed, or an API client sent
12
+ * extra keys), and those must not read back in the clear.
13
+ */
14
+
15
+ import type { Sealer } from './sealed.js'
16
+
17
+ export const SECRET_MASK = '••••••••'
18
+
19
+ /** Three bullets in a row are never part of a real secret; they are a mask, or a mask somebody edited by hand. */
20
+ const LOOKS_MASKED = /•{3}/
21
+
22
+ export type SecretField =
23
+ /** A string at `path`; `tail` also shows its last 4 characters (long tokens only) so it can be recognised. */
24
+ | { path: readonly string[]; tail?: true }
25
+ /** Every entry of the object at `mapAt` whose key names a credential (Authorization, X-Api-Key, ...). */
26
+ | { mapAt: readonly string[] }
27
+
28
+ /** Credential-looking header names. Wide on purpose: masking `X-Idempotency-Key` costs nothing, leaking `X-Auth` does. */
29
+ export const SENSITIVE_HEADER = /auth|token|secret|key|pass|pwd|credential|cookie|session|signature/i
30
+
31
+ export const uniqueSecretFields = (fields: readonly SecretField[]): SecretField[] => {
32
+ const seen = new Set<string>()
33
+ return fields.filter((field) => {
34
+ const id = 'mapAt' in field ? `map:${field.mapAt.join('.')}` : `path:${field.path.join('.')}`
35
+ return seen.has(id) ? false : (seen.add(id), true)
36
+ })
37
+ }
38
+
39
+ type Json = Record<string, unknown>
40
+
41
+ const isObject = (value: unknown): value is Json => !!value && typeof value === 'object' && !Array.isArray(value)
42
+
43
+ function valueAt(root: unknown, path: readonly string[]): unknown {
44
+ let current = root
45
+ for (const segment of path) {
46
+ if (!isObject(current)) return undefined
47
+ current = current[segment]
48
+ }
49
+ return current
50
+ }
51
+
52
+ /** One concrete secret value: the object holding it and its key, plus the dotted name used in messages. */
53
+ interface Slot { holder: Json; key: string; label: string; tail: boolean }
54
+
55
+ /** `••••••••`, plus the last 4 characters of a long token so it can be told apart from another one. */
56
+ function mask(value: string, tail: boolean): string {
57
+ return `${SECRET_MASK}${tail && value.length >= 16 ? value.slice(-4) : ''}`
58
+ }
59
+
60
+ export type RestoreResult = { config: unknown } | { error: string }
61
+
62
+ /** A switch that makes a destination less safe: a path whose value is `true`, or a path with the value that is the unsafe one (`verifyTls` off is `false`). */
63
+ export type LooseningSwitch = readonly string[] | { path: readonly string[]; value: unknown }
64
+
65
+ export function createSecretFields<Kind extends string>(kinds: Readonly<Record<Kind, {
66
+ fields: readonly SecretField[]
67
+ destinations: readonly (readonly string[])[]
68
+ /** Switches that make a destination less safe (certificate checks off): turning one on counts as a changed destination, turning it off does not. */
69
+ loosening?: readonly LooseningSwitch[]
70
+ }>>) {
71
+ function slotsIn(config: unknown, kind: Kind): Slot[] {
72
+ const slots: Slot[] = []
73
+ for (const field of kinds[kind].fields) {
74
+ if ('mapAt' in field) {
75
+ const holder = valueAt(config, field.mapAt)
76
+ if (!isObject(holder)) continue
77
+ for (const key of Object.keys(holder)) {
78
+ if (SENSITIVE_HEADER.test(key)) slots.push({ holder, key, label: `${field.mapAt.join('.')}.${key}`, tail: false })
79
+ }
80
+ } else {
81
+ const holder = valueAt(config, field.path.slice(0, -1))
82
+ const key = field.path[field.path.length - 1]!
83
+ if (isObject(holder) && key in holder) slots.push({ holder, key, label: field.path.join('.'), tail: field.tail === true })
84
+ }
85
+ }
86
+ return slots
87
+ }
88
+
89
+ /** A copy of `config` with every secret replaced by its mask, whatever its type (a number is as secret as a string). */
90
+ function maskSecrets<T>(kind: Kind, config: T): T {
91
+ const copy = structuredClone(config)
92
+ for (const { holder, key, tail } of slotsIn(copy, kind)) {
93
+ const value = holder[key]
94
+ if (value !== null && value !== undefined && value !== '') holder[key] = mask(String(value), tail)
95
+ }
96
+ return copy
97
+ }
98
+
99
+ /**
100
+ * Resolves the secrets of an incoming config against the stored one: a mask that matches the stored secret becomes
101
+ * that secret again. Refused instead of saved:
102
+ * - a mask, or something that looks like an edited mask, that matches nothing stored;
103
+ * - a secret that is not text;
104
+ * - a kept secret while the place it is sent to changed (URL, host, port), so it cannot be redirected.
105
+ * Pass `stored` as undefined for a new object (or one whose type changed), where any mask is a mistake.
106
+ */
107
+ function restoreSecrets(kind: Kind, incoming: unknown, stored: unknown): RestoreResult {
108
+ const copy = structuredClone(incoming)
109
+ const storedSlots = new Map(slotsIn(stored, kind).map((slot) => [slot.label, slot]))
110
+ let kept = false
111
+ for (const { holder, key, label, tail } of slotsIn(copy, kind)) {
112
+ const value = holder[key]
113
+ if (value === null || value === undefined) continue
114
+ if (typeof value !== 'string') return { error: `${label} must be text` }
115
+ if (!LOOKS_MASKED.test(value)) continue
116
+ const previous = storedSlots.get(label)
117
+ const original = previous?.holder[previous.key]
118
+ if (typeof original === 'string' && original && value === mask(original, tail)) {
119
+ holder[key] = original
120
+ kept = true
121
+ } else {
122
+ return { error: `${label} is a masked placeholder that does not match the stored value; enter the full value to replace it` }
123
+ }
124
+ }
125
+ if (kept) {
126
+ const moved = changedDestination(kind, copy, stored)
127
+ if (moved) return { error: `${moved} changed, so the stored secrets cannot be kept; enter them again` }
128
+ }
129
+ return { config: copy }
130
+ }
131
+
132
+ /** The first place a configuration is sent to (URL, host, port) that differs between `incoming` and `stored`, or null. */
133
+ function changedDestination(kind: Kind, incoming: unknown, stored: unknown): string | null {
134
+ for (const path of kinds[kind].destinations) {
135
+ // An empty value means the same as an absent one.
136
+ const normalised = (config: unknown) => valueAt(config, path) || null
137
+ if (JSON.stringify(normalised(incoming)) !== JSON.stringify(normalised(stored))) return path.join('.')
138
+ }
139
+ for (const entry of kinds[kind].loosening ?? []) {
140
+ const { path, value } = Array.isArray(entry) ? { path: entry as readonly string[], value: true } : entry as { path: readonly string[]; value: unknown }
141
+ if (valueAt(incoming, path) === value && valueAt(stored, path) !== value) return path.join('.')
142
+ }
143
+ return null
144
+ }
145
+
146
+ /** A copy of `config` with every secret that is text passed through `convert` (a vault reference or a number is not text and stays). */
147
+ function convertSecrets<T>(kind: Kind, config: T, convert: (value: string) => string): T {
148
+ const copy = structuredClone(config)
149
+ for (const { holder, key } of slotsIn(copy, kind)) {
150
+ const value = holder[key]
151
+ if (typeof value === 'string') holder[key] = convert(value)
152
+ }
153
+ return copy
154
+ }
155
+
156
+ /**
157
+ * The codec of a column that stores a configuration as JSON text: the secrets are encrypted on the way in and opened
158
+ * on the way out, so a copy of the database alone does not carry them. Text that is not JSON is passed through.
159
+ */
160
+ function configCodec(kind: Kind, sealer: Pick<Sealer, 'seal' | 'open'>): Pick<Sealer, 'seal' | 'open'> {
161
+ const through = (convert: (value: string) => string) => (json: string): string => {
162
+ let parsed: unknown
163
+ try { parsed = JSON.parse(json) } catch { return json }
164
+ // A config without a secret is returned as it was written, not as JSON.stringify would write it.
165
+ let changed = false
166
+ const converted = convertSecrets(kind, parsed, (value) => {
167
+ const result = convert(value)
168
+ if (result !== value) changed = true
169
+ return result
170
+ })
171
+ return changed ? JSON.stringify(converted) : json
172
+ }
173
+ return { seal: through(sealer.seal), open: through(sealer.open) }
174
+ }
175
+
176
+ return { maskSecrets, restoreSecrets, changedDestination, configCodec }
177
+ }
package/src/tables.ts CHANGED
@@ -1,23 +1,23 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
-
3
- export const vaults = sqliteTable('vaults', {
4
- id: integer('id').primaryKey({ autoIncrement: true }),
5
- name: text('name').notNull(),
6
- type: text('type').notNull().default('local'), // 'local' | 'hashicorp'
7
- description: text('description'),
8
- createdAt: integer('created_at').notNull(),
9
- updatedAt: integer('updated_at').notNull(),
10
- /** Encrypted JSON connection settings of a 'hashicorp' vault; NULL for 'local'. Never sent to the browser. */
11
- connectionConfig: text('connection_config'),
12
- })
13
-
14
- export const vaultSecrets = sqliteTable('vault_secrets', {
15
- id: integer('id').primaryKey({ autoIncrement: true }),
16
- vaultId: integer('vault_id').notNull(),
17
- name: text('name').notNull(),
18
- type: text('type').notNull(), // 'userpass' | 'value' | 'json'
19
- /** Local vault: the encrypted value. HashiCorp vault: the encrypted reference `{ path, key? }`. */
20
- encryptedValue: text('encrypted_value').notNull(),
21
- createdAt: integer('created_at').notNull(),
22
- updatedAt: integer('updated_at').notNull(),
23
- })
1
+ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ export const vaults = sqliteTable('vaults', {
4
+ id: integer('id').primaryKey({ autoIncrement: true }),
5
+ name: text('name').notNull(),
6
+ type: text('type').notNull().default('local'), // 'local' | 'hashicorp'
7
+ description: text('description'),
8
+ createdAt: integer('created_at').notNull(),
9
+ updatedAt: integer('updated_at').notNull(),
10
+ /** Encrypted JSON connection settings of a 'hashicorp' vault; NULL for 'local'. Never sent to the browser. */
11
+ connectionConfig: text('connection_config'),
12
+ })
13
+
14
+ export const vaultSecrets = sqliteTable('vault_secrets', {
15
+ id: integer('id').primaryKey({ autoIncrement: true }),
16
+ vaultId: integer('vault_id').notNull(),
17
+ name: text('name').notNull(),
18
+ type: text('type').notNull(), // 'userpass' | 'value' | 'json'
19
+ /** Local vault: the encrypted value. HashiCorp vault: the encrypted reference `{ path, key? }`. */
20
+ encryptedValue: text('encrypted_value').notNull(),
21
+ createdAt: integer('created_at').notNull(),
22
+ updatedAt: integer('updated_at').notNull(),
23
+ })