@crombolo/transfer 0.11.0 → 0.13.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/package.json +3 -3
- package/src/check.ts +221 -28
- package/src/export.ts +46 -0
- package/src/import.ts +331 -193
- package/src/index.ts +3 -0
- package/src/load.ts +166 -2
- package/src/model.ts +59 -1
- package/src/package.ts +17 -0
- package/src/people-apply.ts +572 -0
- package/src/people.ts +167 -0
- package/src/resolutions.ts +69 -7
- package/src/revert.ts +44 -5
- package/src/seal.ts +129 -0
- package/src/write.ts +173 -22
package/src/people.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle's people (story 071): member groups, members, user groups and
|
|
3
|
+
* users, in the bundle's `people/` directory — always sealed (`seal.ts`). The
|
|
4
|
+
* manifest says how many of each, never who.
|
|
5
|
+
* docs/content-transfer.md, "People".
|
|
6
|
+
*/
|
|
7
|
+
import type { BundleValue } from './model.ts'
|
|
8
|
+
import { openBytes, type Seal, sealBytes } from './seal.ts'
|
|
9
|
+
|
|
10
|
+
export interface BundleMemberGroup {
|
|
11
|
+
key: string
|
|
12
|
+
name: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BundleMember {
|
|
16
|
+
key: string
|
|
17
|
+
name: string
|
|
18
|
+
memberTypeAlias: string
|
|
19
|
+
email: string
|
|
20
|
+
username: string
|
|
21
|
+
isApproved: boolean
|
|
22
|
+
isLockedOut: boolean
|
|
23
|
+
/** As the source stored it, with its algorithm marker; null for none. */
|
|
24
|
+
passwordHash: string | null
|
|
25
|
+
passwordConfig: string | null
|
|
26
|
+
groupKeys: string[]
|
|
27
|
+
values: BundleValue[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BundleUserGroup {
|
|
31
|
+
key: string
|
|
32
|
+
alias: string
|
|
33
|
+
name: string
|
|
34
|
+
icon: string | null
|
|
35
|
+
sections: string[]
|
|
36
|
+
/** Default permission verbs, e.g. `Umb.Document.Read`. */
|
|
37
|
+
permissions: string[]
|
|
38
|
+
hasAccessToAllLanguages: boolean
|
|
39
|
+
languages: string[]
|
|
40
|
+
documentStartNodeKey: string | null
|
|
41
|
+
mediaStartNodeKey: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BundleUser {
|
|
45
|
+
key: string
|
|
46
|
+
name: string
|
|
47
|
+
userName: string
|
|
48
|
+
email: string
|
|
49
|
+
languageIsoCode: string | null
|
|
50
|
+
disabled: boolean
|
|
51
|
+
passwordHash: string | null
|
|
52
|
+
passwordConfig: string | null
|
|
53
|
+
groupAliases: string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BundlePeople {
|
|
57
|
+
memberGroups: BundleMemberGroup[]
|
|
58
|
+
members: BundleMember[]
|
|
59
|
+
userGroups: BundleUserGroup[]
|
|
60
|
+
users: BundleUser[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const EMPTY_PEOPLE: BundlePeople = {
|
|
64
|
+
memberGroups: [],
|
|
65
|
+
members: [],
|
|
66
|
+
userGroups: [],
|
|
67
|
+
users: [],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the manifest says of a bundle's people: counts, never who. */
|
|
71
|
+
export interface PeopleBlock {
|
|
72
|
+
counts: { memberGroups: number; members: number; userGroups: number; users: number }
|
|
73
|
+
/** Every sealed file, in path order. */
|
|
74
|
+
paths: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const PEOPLE_DIR = 'people'
|
|
78
|
+
|
|
79
|
+
const MEMBER_GROUPS = `${PEOPLE_DIR}/member-groups.json`
|
|
80
|
+
const USER_GROUPS = `${PEOPLE_DIR}/user-groups.json`
|
|
81
|
+
const memberPath = (key: string) => `${PEOPLE_DIR}/members/${key}.json`
|
|
82
|
+
const userPath = (key: string) => `${PEOPLE_DIR}/users/${key}.json`
|
|
83
|
+
|
|
84
|
+
const json = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`)
|
|
85
|
+
|
|
86
|
+
/** People sealed under the bundle's seal: the files, and the manifest's block. */
|
|
87
|
+
export function sealPeople(
|
|
88
|
+
people: BundlePeople,
|
|
89
|
+
{ key }: Seal,
|
|
90
|
+
): { files: Array<{ path: string; bytes: Uint8Array }>; block: PeopleBlock } {
|
|
91
|
+
const plain: Array<{ path: string; bytes: Buffer }> = [
|
|
92
|
+
{ path: MEMBER_GROUPS, bytes: json(people.memberGroups) },
|
|
93
|
+
{ path: USER_GROUPS, bytes: json(people.userGroups) },
|
|
94
|
+
...people.members.map((m) => ({ path: memberPath(m.key), bytes: json(m) })),
|
|
95
|
+
...people.users.map((u) => ({ path: userPath(u.key), bytes: json(u) })),
|
|
96
|
+
].sort((a, b) => a.path.localeCompare(b.path))
|
|
97
|
+
const files = plain.map((file) => ({
|
|
98
|
+
path: file.path,
|
|
99
|
+
bytes: sealBytes(key, file.path, file.bytes),
|
|
100
|
+
}))
|
|
101
|
+
return {
|
|
102
|
+
files,
|
|
103
|
+
block: {
|
|
104
|
+
counts: {
|
|
105
|
+
memberGroups: people.memberGroups.length,
|
|
106
|
+
members: people.members.length,
|
|
107
|
+
userGroups: people.userGroups.length,
|
|
108
|
+
users: people.users.length,
|
|
109
|
+
},
|
|
110
|
+
paths: files.map((f) => f.path),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A bundle's people, opened with its key; `SealedFileError` for a file altered or moved. */
|
|
116
|
+
export function openPeople(
|
|
117
|
+
block: PeopleBlock,
|
|
118
|
+
files: ReadonlyMap<string, Uint8Array>,
|
|
119
|
+
key: Buffer,
|
|
120
|
+
): BundlePeople {
|
|
121
|
+
const read = <T>(path: string): T => {
|
|
122
|
+
const sealed = files.get(path)
|
|
123
|
+
if (!sealed) throw new Error(`${path} is missing from the bundle's people.`)
|
|
124
|
+
return JSON.parse(openBytes(key, path, sealed).toString('utf8')) as T
|
|
125
|
+
}
|
|
126
|
+
const out: BundlePeople = {
|
|
127
|
+
memberGroups: block.paths.includes(MEMBER_GROUPS) ? read(MEMBER_GROUPS) : [],
|
|
128
|
+
userGroups: block.paths.includes(USER_GROUPS) ? read(USER_GROUPS) : [],
|
|
129
|
+
members: [],
|
|
130
|
+
users: [],
|
|
131
|
+
}
|
|
132
|
+
for (const path of block.paths) {
|
|
133
|
+
if (path.startsWith(`${PEOPLE_DIR}/members/`)) out.members.push(read<BundleMember>(path))
|
|
134
|
+
if (path.startsWith(`${PEOPLE_DIR}/users/`)) out.users.push(read<BundleUser>(path))
|
|
135
|
+
}
|
|
136
|
+
const counts = block.counts
|
|
137
|
+
if (
|
|
138
|
+
out.members.length !== counts.members ||
|
|
139
|
+
out.users.length !== counts.users ||
|
|
140
|
+
out.memberGroups.length !== counts.memberGroups ||
|
|
141
|
+
out.userGroups.length !== counts.userGroups
|
|
142
|
+
)
|
|
143
|
+
throw new Error("The bundle's people do not match the counts its manifest gives.")
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A manifest's people block read without trusting it: undefined when absent or malformed. Pure. */
|
|
148
|
+
export function parsePeopleBlock(raw: unknown): PeopleBlock | undefined {
|
|
149
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
150
|
+
const block = raw as Record<string, unknown>
|
|
151
|
+
const counts = block.counts as Record<string, unknown> | undefined
|
|
152
|
+
if (!counts || !Array.isArray(block.paths)) return undefined
|
|
153
|
+
const paths = (block.paths as unknown[]).filter(
|
|
154
|
+
(p): p is string =>
|
|
155
|
+
typeof p === 'string' && p.startsWith(`${PEOPLE_DIR}/`) && !p.split('/').includes('..'),
|
|
156
|
+
)
|
|
157
|
+
const count = (name: string) => (Number.isInteger(counts[name]) ? (counts[name] as number) : 0)
|
|
158
|
+
return {
|
|
159
|
+
counts: {
|
|
160
|
+
memberGroups: count('memberGroups'),
|
|
161
|
+
members: count('members'),
|
|
162
|
+
userGroups: count('userGroups'),
|
|
163
|
+
users: count('users'),
|
|
164
|
+
},
|
|
165
|
+
paths: paths.sort(),
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/resolutions.ts
CHANGED
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
* again in production, each time against more content.
|
|
8
8
|
*
|
|
9
9
|
* It holds resolutions **by node key**, so a path that means something
|
|
10
|
-
* different in the next environment cannot change what was decided.
|
|
10
|
+
* different in the next environment cannot change what was decided. Each node's
|
|
11
|
+
* answers are `"*"` for the whole node and `"<alias>"` or `"<alias>@<culture>"`
|
|
12
|
+
* for one property.
|
|
11
13
|
*/
|
|
12
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
13
15
|
import { join } from 'node:path'
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isResolution,
|
|
18
|
+
type NodeResolutions,
|
|
19
|
+
nodeResolutionProblems,
|
|
20
|
+
type Resolution,
|
|
21
|
+
WHOLE_NODE,
|
|
22
|
+
} from './check.ts'
|
|
15
23
|
|
|
16
24
|
export const RESOLUTIONS_FILE = 'resolutions.json'
|
|
17
25
|
|
|
@@ -21,7 +29,48 @@ export interface ResolutionsFile {
|
|
|
21
29
|
/** Where the bundle was placed, when it was re-rooted. */
|
|
22
30
|
under?: string | null
|
|
23
31
|
all?: Resolution | null
|
|
24
|
-
byNode: Record<string,
|
|
32
|
+
byNode: Record<string, NodeResolutions>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `--resolve` values into answers by node: `<key>=<choice>` answers the node,
|
|
37
|
+
* `<key>/<alias>[@<culture>]=<choice>` one property. Pure; problems are named.
|
|
38
|
+
*/
|
|
39
|
+
export function parseResolveFlags(pairs: readonly string[]): {
|
|
40
|
+
byNode: Record<string, NodeResolutions>
|
|
41
|
+
problems: string[]
|
|
42
|
+
} {
|
|
43
|
+
const byNode: Record<string, Record<string, Resolution>> = {}
|
|
44
|
+
const problems: string[] = []
|
|
45
|
+
for (const pair of pairs) {
|
|
46
|
+
const eq = pair.lastIndexOf('=')
|
|
47
|
+
if (eq < 0) {
|
|
48
|
+
problems.push(`--resolve wants <key>=<choice> or <key>/<property>=<choice>, not "${pair}"`)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const target = pair.slice(0, eq)
|
|
52
|
+
const choice = pair.slice(eq + 1)
|
|
53
|
+
const slash = target.indexOf('/')
|
|
54
|
+
const key = slash < 0 ? target : target.slice(0, slash)
|
|
55
|
+
const handle = slash < 0 ? WHOLE_NODE : target.slice(slash + 1)
|
|
56
|
+
const found = nodeResolutionProblems(key, { [handle]: choice })
|
|
57
|
+
if (found.length > 0 || !handle) {
|
|
58
|
+
problems.push(...(found.length > 0 ? found : [`--resolve "${pair}" names no property`]))
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
byNode[key] = { ...(byNode[key] ?? {}), [handle]: choice as Resolution }
|
|
62
|
+
}
|
|
63
|
+
return { byNode, problems }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Answers laid over others, node by node and property by property. Pure. */
|
|
67
|
+
export function mergeResolutions(
|
|
68
|
+
under: Readonly<Record<string, NodeResolutions>>,
|
|
69
|
+
over: Readonly<Record<string, NodeResolutions>>,
|
|
70
|
+
): Record<string, NodeResolutions> {
|
|
71
|
+
const out: Record<string, NodeResolutions> = { ...under }
|
|
72
|
+
for (const [key, answers] of Object.entries(over)) out[key] = { ...(out[key] ?? {}), ...answers }
|
|
73
|
+
return out
|
|
25
74
|
}
|
|
26
75
|
|
|
27
76
|
export function writeResolutions(file: ResolutionsFile): string {
|
|
@@ -31,7 +80,12 @@ export function writeResolutions(file: ResolutionsFile): string {
|
|
|
31
80
|
under: file.under ?? null,
|
|
32
81
|
all: file.all ?? null,
|
|
33
82
|
byNode: Object.fromEntries(
|
|
34
|
-
Object.entries(file.byNode)
|
|
83
|
+
Object.entries(file.byNode)
|
|
84
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
85
|
+
.map(([key, answers]) => [
|
|
86
|
+
key,
|
|
87
|
+
Object.fromEntries(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b))),
|
|
88
|
+
]),
|
|
35
89
|
),
|
|
36
90
|
},
|
|
37
91
|
null,
|
|
@@ -68,11 +122,19 @@ export function loadResolutions(dir: string, bundleId: string): LoadedResolution
|
|
|
68
122
|
if (found && found !== bundleId)
|
|
69
123
|
problems.push(`${RESOLUTIONS_FILE}: answers bundle ${found}, not ${bundleId} — ignoring it`)
|
|
70
124
|
|
|
71
|
-
const byNode: Record<string,
|
|
125
|
+
const byNode: Record<string, NodeResolutions> = {}
|
|
72
126
|
const entries = typeof record.byNode === 'object' && record.byNode !== null ? record.byNode : {}
|
|
73
127
|
for (const [key, value] of Object.entries(entries as Record<string, unknown>)) {
|
|
74
|
-
if (typeof value
|
|
75
|
-
|
|
128
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
129
|
+
problems.push(
|
|
130
|
+
`${RESOLUTIONS_FILE}: "${key}" must map "*" or a property to a resolution, as { "*": "take-bundle" }`,
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const found = nodeResolutionProblems(key, value as Record<string, unknown>)
|
|
135
|
+
if (found.length > 0)
|
|
136
|
+
problems.push(...found.map((message) => `${RESOLUTIONS_FILE}: ${message}`))
|
|
137
|
+
else byNode[key] = value as Record<string, Resolution>
|
|
76
138
|
}
|
|
77
139
|
const all = typeof record.all === 'string' && isResolution(record.all) ? record.all : null
|
|
78
140
|
|
package/src/revert.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type TransferRun,
|
|
28
28
|
TransferRunRepository,
|
|
29
29
|
} from '@crombolo/data'
|
|
30
|
+
import { isPersonKind, removePerson } from './people-apply.ts'
|
|
30
31
|
|
|
31
32
|
/** What to do about a node whose state has moved on since the import. */
|
|
32
33
|
export type RevertResolution = 'discard' | 'skip'
|
|
@@ -74,7 +75,7 @@ export interface RevertPlan {
|
|
|
74
75
|
* run created, and `unrecycle` brings back what a *reverted* run recycled —
|
|
75
76
|
* which is what makes a revert revertible in its own right.
|
|
76
77
|
*/
|
|
77
|
-
action: 'restore' | 'recycle' | 'unrecycle' | 'skip' | 'gone'
|
|
78
|
+
action: 'restore' | 'recycle' | 'unrecycle' | 'skip' | 'gone' | 'remove'
|
|
78
79
|
resolution: RevertResolution | undefined
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -91,6 +92,8 @@ export interface RevertResult {
|
|
|
91
92
|
check: RevertCheck
|
|
92
93
|
restored: string[]
|
|
93
94
|
recycled: string[]
|
|
95
|
+
/** Members, users and groups the run had created, now removed. */
|
|
96
|
+
removed: string[]
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
const link = (backOfficePath: string | undefined, key: string): string | null =>
|
|
@@ -154,6 +157,30 @@ export async function checkRevert(
|
|
|
154
157
|
|
|
155
158
|
for (const change of changes) {
|
|
156
159
|
if (change.action === 'skip') continue
|
|
160
|
+
// People have no history to roll back to: what the run created goes, and
|
|
161
|
+
// what it updated keeps the bundle's details.
|
|
162
|
+
if (isPersonKind(change.kind)) {
|
|
163
|
+
if (change.action === 'create')
|
|
164
|
+
plan.push({
|
|
165
|
+
key: change.nodeKey,
|
|
166
|
+
name: change.nodeKey,
|
|
167
|
+
action: 'remove',
|
|
168
|
+
resolution: undefined,
|
|
169
|
+
})
|
|
170
|
+
else
|
|
171
|
+
findings.push({
|
|
172
|
+
kind: 'auto',
|
|
173
|
+
code: 'person-kept',
|
|
174
|
+
subjectType: change.kind,
|
|
175
|
+
subjectKey: change.nodeKey,
|
|
176
|
+
subjectName: change.nodeKey,
|
|
177
|
+
propertyAlias: null,
|
|
178
|
+
culture: null,
|
|
179
|
+
message: `${change.kind} ${change.nodeKey} was updated by the run and keeps those details`,
|
|
180
|
+
link: null,
|
|
181
|
+
})
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
157
184
|
const resolution = resolutions[change.nodeKey] ?? options.resolveAll
|
|
158
185
|
const node = await docs.nodes.byKey(change.nodeKey)
|
|
159
186
|
const name = node?.text ?? change.nodeKey
|
|
@@ -261,7 +288,7 @@ export async function revertRun(
|
|
|
261
288
|
db.transaction(async (tx) => {
|
|
262
289
|
const check = await checkRevert(tx, runId, options)
|
|
263
290
|
if (check.outstanding.length > 0)
|
|
264
|
-
return { runId: undefined, check, restored: [], recycled: [] }
|
|
291
|
+
return { runId: undefined, check, restored: [], recycled: [], removed: [] }
|
|
265
292
|
|
|
266
293
|
const runs = new TransferRunRepository(tx)
|
|
267
294
|
const original = (await runs.byId(runId)) as TransferRun
|
|
@@ -288,7 +315,19 @@ export async function revertRun(
|
|
|
288
315
|
const skipped = new Set(
|
|
289
316
|
check.plan.filter((p) => p.action === 'skip' || p.action === 'gone').map((p) => p.key),
|
|
290
317
|
)
|
|
291
|
-
const todo = changes.filter(
|
|
318
|
+
const todo = changes.filter(
|
|
319
|
+
(c) => c.action !== 'skip' && !skipped.has(c.nodeKey) && !isPersonKind(c.kind),
|
|
320
|
+
)
|
|
321
|
+
// Members before the groups they belong to.
|
|
322
|
+
const removed: string[] = []
|
|
323
|
+
const order = ['member', 'user', 'member-group', 'user-group']
|
|
324
|
+
for (const change of changes
|
|
325
|
+
.filter((c) => isPersonKind(c.kind) && c.action === 'create')
|
|
326
|
+
.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind)))
|
|
327
|
+
if (
|
|
328
|
+
await removePerson(tx, change.kind as Parameters<typeof removePerson>[1], change.nodeKey)
|
|
329
|
+
)
|
|
330
|
+
removed.push(change.nodeKey)
|
|
292
331
|
const byKey = new Map<string, NodeRow>()
|
|
293
332
|
for (const change of todo) {
|
|
294
333
|
const node = await repo(change.kind).nodes.byKey(change.nodeKey)
|
|
@@ -372,7 +411,7 @@ export async function revertRun(
|
|
|
372
411
|
eventId: markers?.head ?? null,
|
|
373
412
|
})
|
|
374
413
|
}
|
|
375
|
-
await runs.finish(newRunId, todo.length)
|
|
414
|
+
await runs.finish(newRunId, todo.length + removed.length)
|
|
376
415
|
await runs.setStatus(runId, 'reverted')
|
|
377
416
|
|
|
378
417
|
await appendCacheInstruction(tx, {
|
|
@@ -381,7 +420,7 @@ export async function revertRun(
|
|
|
381
420
|
by: options.nodeId,
|
|
382
421
|
})
|
|
383
422
|
|
|
384
|
-
return { runId: newRunId, check, restored, recycled }
|
|
423
|
+
return { runId: newRunId, check, restored, recycled, removed }
|
|
385
424
|
}),
|
|
386
425
|
)
|
|
387
426
|
}
|
package/src/seal.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealing what in a bundle can hold personal information (story 071):
|
|
3
|
+
* property values, media files, protected-page usernames and people. The key is stretched from a passphrase
|
|
4
|
+
* given at export and at install — never stored — with scrypt and a salt the
|
|
5
|
+
* bundle keeps; each file is AES-256-GCM with a nonce of its own and its path
|
|
6
|
+
* as additional data, so a file cannot be swapped for another's. A check value
|
|
7
|
+
* tells a wrong passphrase apart from a damaged file. docs/content-transfer.md,
|
|
8
|
+
* "People".
|
|
9
|
+
*/
|
|
10
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
/** What the manifest keeps to derive the key again: never the key itself. */
|
|
13
|
+
export interface SealParameters {
|
|
14
|
+
kdf: 'scrypt'
|
|
15
|
+
/** scrypt's cost, block size and parallelism. */
|
|
16
|
+
n: number
|
|
17
|
+
r: number
|
|
18
|
+
p: number
|
|
19
|
+
/** Base64. */
|
|
20
|
+
salt: string
|
|
21
|
+
/** A known value sealed under the key, so a wrong passphrase is named as one. */
|
|
22
|
+
check: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SEAL_MAGIC = Buffer.from('CRBSEAL1')
|
|
26
|
+
const CHECK_PATH = 'people/.check'
|
|
27
|
+
const CHECK_TEXT = 'crombolo people'
|
|
28
|
+
|
|
29
|
+
export class WrongPassphraseError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super('The passphrase does not open this bundle’s people.')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SealedFileError extends Error {}
|
|
36
|
+
|
|
37
|
+
/** The key a passphrase gives under these parameters. */
|
|
38
|
+
export function deriveKey(
|
|
39
|
+
passphrase: string,
|
|
40
|
+
params: Pick<SealParameters, 'n' | 'r' | 'p' | 'salt'>,
|
|
41
|
+
): Buffer {
|
|
42
|
+
if (!passphrase) throw new WrongPassphraseError()
|
|
43
|
+
return scryptSync(passphrase.normalize('NFC'), Buffer.from(params.salt, 'base64'), 32, {
|
|
44
|
+
N: params.n,
|
|
45
|
+
r: params.r,
|
|
46
|
+
p: params.p,
|
|
47
|
+
maxmem: 256 * params.n * params.r + 1024 * 1024,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One file sealed: magic, nonce, ciphertext, tag. `nonce` is injectable for tests. */
|
|
52
|
+
export function sealBytes(
|
|
53
|
+
key: Buffer,
|
|
54
|
+
path: string,
|
|
55
|
+
plain: Uint8Array,
|
|
56
|
+
nonce = randomBytes(12),
|
|
57
|
+
): Buffer {
|
|
58
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce)
|
|
59
|
+
cipher.setAAD(Buffer.from(path))
|
|
60
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()])
|
|
61
|
+
return Buffer.concat([SEAL_MAGIC, nonce, body, cipher.getAuthTag()])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One file opened; throws when it was altered, moved or is not sealed. */
|
|
65
|
+
export function openBytes(key: Buffer, path: string, sealed: Uint8Array): Buffer {
|
|
66
|
+
const bytes = Buffer.from(sealed)
|
|
67
|
+
if (bytes.length < SEAL_MAGIC.length + 12 + 16 || !bytes.subarray(0, 8).equals(SEAL_MAGIC))
|
|
68
|
+
throw new SealedFileError(`${path} is not a sealed file.`)
|
|
69
|
+
const nonce = bytes.subarray(8, 20)
|
|
70
|
+
const tag = bytes.subarray(bytes.length - 16)
|
|
71
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce)
|
|
72
|
+
decipher.setAAD(Buffer.from(path))
|
|
73
|
+
decipher.setAuthTag(tag)
|
|
74
|
+
try {
|
|
75
|
+
return Buffer.concat([decipher.update(bytes.subarray(20, bytes.length - 16)), decipher.final()])
|
|
76
|
+
} catch {
|
|
77
|
+
throw new SealedFileError(`${path} was altered, or belongs to another file.`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The parameters a bundle keeps, and the key they give. */
|
|
82
|
+
export interface Seal {
|
|
83
|
+
params: SealParameters
|
|
84
|
+
key: Buffer
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Fresh parameters and the key they give: what an export starts with. */
|
|
88
|
+
export function newSeal(
|
|
89
|
+
passphrase: string,
|
|
90
|
+
cost: Pick<SealParameters, 'n' | 'r' | 'p'> = { n: 2 ** 17, r: 8, p: 1 },
|
|
91
|
+
): Seal {
|
|
92
|
+
const salt = randomBytes(16).toString('base64')
|
|
93
|
+
const key = deriveKey(passphrase, { ...cost, salt })
|
|
94
|
+
const check = sealBytes(key, CHECK_PATH, Buffer.from(CHECK_TEXT)).toString('base64')
|
|
95
|
+
return { params: { kdf: 'scrypt', ...cost, salt, check }, key }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The key for a bundle's people, or a refusal naming a wrong passphrase. */
|
|
99
|
+
export function unlock(passphrase: string, params: SealParameters): Buffer {
|
|
100
|
+
const key = deriveKey(passphrase, params)
|
|
101
|
+
try {
|
|
102
|
+
const opened = openBytes(key, CHECK_PATH, Buffer.from(params.check, 'base64'))
|
|
103
|
+
if (opened.toString() !== CHECK_TEXT) throw new WrongPassphraseError()
|
|
104
|
+
} catch {
|
|
105
|
+
throw new WrongPassphraseError()
|
|
106
|
+
}
|
|
107
|
+
return key
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A manifest's seal read without trusting it: undefined when absent or malformed. Pure. */
|
|
111
|
+
export function parseSealParameters(raw: unknown): SealParameters | undefined {
|
|
112
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
113
|
+
const seal = raw as Record<string, unknown>
|
|
114
|
+
if (
|
|
115
|
+
seal.kdf !== 'scrypt' ||
|
|
116
|
+
typeof seal.salt !== 'string' ||
|
|
117
|
+
typeof seal.check !== 'string' ||
|
|
118
|
+
![seal.n, seal.r, seal.p].every((v) => Number.isInteger(v) && (v as number) > 0)
|
|
119
|
+
)
|
|
120
|
+
return undefined
|
|
121
|
+
return {
|
|
122
|
+
kdf: 'scrypt',
|
|
123
|
+
n: seal.n as number,
|
|
124
|
+
r: seal.r as number,
|
|
125
|
+
p: seal.p as number,
|
|
126
|
+
salt: seal.salt,
|
|
127
|
+
check: seal.check,
|
|
128
|
+
}
|
|
129
|
+
}
|