@davidvornholt/standards 0.18.0 → 0.20.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.
Files changed (38) hide show
  1. package/README.md +5 -4
  2. package/package.json +12 -1
  3. package/src/cli.ts +283 -73
  4. package/src/creds-add-cloudflare-commit.ts +2 -5
  5. package/src/creds-add-github.ts +82 -30
  6. package/src/creds-add-policy.ts +106 -26
  7. package/src/creds-add-preflight.ts +68 -0
  8. package/src/creds-add.ts +29 -20
  9. package/src/creds-args.ts +5 -0
  10. package/src/creds-cloudflare-zone.ts +40 -0
  11. package/src/creds-commands.ts +21 -9
  12. package/src/creds-github-app-api.ts +132 -0
  13. package/src/creds-github-apps.ts +107 -0
  14. package/src/creds-login-github-manifest.ts +38 -15
  15. package/src/creds-login-github.ts +78 -24
  16. package/src/creds-plan-renew.ts +2 -5
  17. package/src/creds-sops-value.ts +89 -0
  18. package/src/creds-sops.ts +0 -53
  19. package/src/creds-store-github.ts +121 -0
  20. package/src/creds-store-lock-holder.ts +98 -0
  21. package/src/creds-store-lock-inspection.ts +125 -0
  22. package/src/creds-store-lock-lease.ts +32 -0
  23. package/src/creds-store-lock.ts +27 -51
  24. package/src/creds-store.ts +14 -43
  25. package/src/github-command-shared.ts +8 -1
  26. package/src/github-live-drift.ts +3 -3
  27. package/src/managed-files.ts +116 -14
  28. package/src/managed-path-ownership.ts +85 -0
  29. package/src/poller-closing-reference.ts +179 -0
  30. package/src/poller-fix-outcome.ts +9 -0
  31. package/src/poller-fix-publication.ts +5 -12
  32. package/src/poller-fix-run.ts +2 -3
  33. package/src/poller-fix-validation.ts +7 -8
  34. package/src/poller-outcome.ts +4 -1
  35. package/src/poller-protected-paths.ts +1 -25
  36. package/src/poller-protocol.ts +0 -42
  37. package/src/poller-review-publication.ts +2 -8
  38. package/src/poller-review-validation.ts +5 -12
@@ -0,0 +1,132 @@
1
+ import { createSign } from 'node:crypto';
2
+ import type { GithubBrokerApp } from './creds-store';
3
+ import { type ApiResponse, apiError, HTTP_OK, request } from './github-api';
4
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
5
+
6
+ const JWT_CLOCK_SKEW_SECONDS = 60;
7
+ const SECONDS_PER_MINUTE = 60;
8
+ const JWT_LIFETIME_MINUTES = 9;
9
+ const JWT_LIFETIME_SECONDS = JWT_LIFETIME_MINUTES * SECONDS_PER_MINUTE;
10
+ const MILLISECONDS_PER_SECOND = 1000;
11
+ const HTTP_NOT_FOUND = 404;
12
+
13
+ type GithubAppResult<T> =
14
+ | { readonly ok: true; readonly value: T }
15
+ | { readonly ok: false; readonly problem: string };
16
+
17
+ export const createGithubAppJwt = (
18
+ app: GithubBrokerApp,
19
+ now = Date.now(),
20
+ ): string => {
21
+ const seconds = Math.floor(now / MILLISECONDS_PER_SECOND);
22
+ const header = Buffer.from(
23
+ JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
24
+ ).toString('base64url');
25
+ const payload = Buffer.from(
26
+ JSON.stringify({
27
+ iat: seconds - JWT_CLOCK_SKEW_SECONDS,
28
+ exp: seconds + JWT_LIFETIME_SECONDS,
29
+ iss: app.clientId,
30
+ }),
31
+ ).toString('base64url');
32
+ const unsigned = `${header}.${payload}`;
33
+ const signature = createSign('RSA-SHA256')
34
+ .update(unsigned)
35
+ .sign(app.privateKey, 'base64url');
36
+ return `${unsigned}.${signature}`;
37
+ };
38
+
39
+ const appRequest = async (
40
+ app: GithubBrokerApp,
41
+ path: string,
42
+ ): Promise<GithubAppResult<ApiResponse>> => {
43
+ try {
44
+ return {
45
+ ok: true,
46
+ value: await request(createGithubAppJwt(app), 'GET', path),
47
+ };
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ return {
51
+ ok: false,
52
+ problem: `cannot authenticate as App ${app.slug}: ${message}`,
53
+ };
54
+ }
55
+ };
56
+
57
+ export const resolveGithubAppOwner = async (
58
+ app: GithubBrokerApp,
59
+ ): Promise<GithubAppResult<string>> => {
60
+ const result = await appRequest(app, '/app');
61
+ if (!result.ok) {
62
+ return result;
63
+ }
64
+ if (result.value.status !== HTTP_OK) {
65
+ return {
66
+ ok: false,
67
+ problem: apiError(`resolve owner for App ${app.slug}`, result.value),
68
+ };
69
+ }
70
+ const { body } = result.value;
71
+ if (
72
+ !isRecord(body) ||
73
+ body.id !== app.appId ||
74
+ !isRecord(body.owner) ||
75
+ !isNonEmptyString(body.owner.login)
76
+ ) {
77
+ return {
78
+ ok: false,
79
+ problem: `resolve owner for App ${app.slug}: unexpected response shape or App identity`,
80
+ };
81
+ }
82
+ return { ok: true, value: body.owner.login };
83
+ };
84
+
85
+ export const verifyGithubAppInstallation = async (
86
+ app: GithubBrokerApp,
87
+ repo: string,
88
+ ): Promise<GithubAppResult<true>> => {
89
+ const [owner, name, ...rest] = repo.split('/');
90
+ if (
91
+ owner === undefined ||
92
+ name === undefined ||
93
+ owner.length === 0 ||
94
+ name.length === 0 ||
95
+ rest.length > 0
96
+ ) {
97
+ return { ok: false, problem: `invalid GitHub repository: ${repo}` };
98
+ }
99
+ const result = await appRequest(
100
+ app,
101
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`,
102
+ );
103
+ if (!result.ok) {
104
+ return result;
105
+ }
106
+ if (result.value.status === HTTP_NOT_FOUND) {
107
+ return {
108
+ ok: false,
109
+ problem: `App ${app.slug} for ${owner} is not installed on ${repo}; install it for that selected repository at ${app.htmlUrl}/installations/new`,
110
+ };
111
+ }
112
+ if (result.value.status !== HTTP_OK) {
113
+ return {
114
+ ok: false,
115
+ problem: apiError(`verify App installation on ${repo}`, result.value),
116
+ };
117
+ }
118
+ const { body } = result.value;
119
+ if (
120
+ !isRecord(body) ||
121
+ body.app_id !== app.appId ||
122
+ !isRecord(body.account) ||
123
+ !isNonEmptyString(body.account.login) ||
124
+ body.account.login.toLowerCase() !== owner.toLowerCase()
125
+ ) {
126
+ return {
127
+ ok: false,
128
+ problem: `verify App installation on ${repo}: unexpected App or account identity`,
129
+ };
130
+ }
131
+ return { ok: true, value: true };
132
+ };
@@ -0,0 +1,107 @@
1
+ import { resolveGithubAppOwner } from './creds-github-app-api';
2
+ import {
3
+ type BrokerStore,
4
+ type GithubBrokerApp,
5
+ readBrokerStore,
6
+ updateBrokerStore,
7
+ } from './creds-store';
8
+
9
+ type GithubAppsResult<T> =
10
+ | { readonly ok: true; readonly value: T }
11
+ | { readonly ok: false; readonly problem: string };
12
+
13
+ export const sameGithubApp = (
14
+ left: GithubBrokerApp,
15
+ right: GithubBrokerApp,
16
+ ): boolean =>
17
+ left.owner?.toLowerCase() === right.owner?.toLowerCase() &&
18
+ left.appId === right.appId &&
19
+ left.slug === right.slug &&
20
+ left.htmlUrl === right.htmlUrl &&
21
+ left.clientId === right.clientId &&
22
+ left.privateKey === right.privateKey;
23
+
24
+ export const loadOwnedGithubStore = async (
25
+ path: string,
26
+ lookupOwner: (
27
+ app: GithubBrokerApp,
28
+ ) => Promise<GithubAppsResult<string>> = resolveGithubAppOwner,
29
+ ): Promise<GithubAppsResult<BrokerStore>> => {
30
+ const initial = await readBrokerStore(path);
31
+ const legacy = initial.github.find((app) => app.owner === null);
32
+ if (legacy === undefined) {
33
+ return { ok: true, value: initial };
34
+ }
35
+ const owner = await lookupOwner(legacy);
36
+ if (!owner.ok) {
37
+ return {
38
+ ok: false,
39
+ problem: `cannot migrate the legacy singleton GitHub App in ${path}: ${owner.problem}`,
40
+ };
41
+ }
42
+ await updateBrokerStore(path, (current) => ({
43
+ ...current,
44
+ github: current.github.map((app) =>
45
+ app.owner === null && sameGithubApp(app, legacy)
46
+ ? { ...app, owner: owner.value }
47
+ : app,
48
+ ),
49
+ }));
50
+ const migrated = await readBrokerStore(path);
51
+ if (migrated.github.some((app) => app.owner === null)) {
52
+ return {
53
+ ok: false,
54
+ problem: `the GitHub App in ${path} changed while its legacy owner was being migrated; retry`,
55
+ };
56
+ }
57
+ return { ok: true, value: migrated };
58
+ };
59
+
60
+ export const selectGithubAppForRepo = (
61
+ apps: ReadonlyArray<GithubBrokerApp>,
62
+ repo: string,
63
+ ): GithubAppsResult<GithubBrokerApp> => {
64
+ const owner = repo.split('/')[0] ?? '';
65
+ const matches = apps.filter(
66
+ (app) => app.owner?.toLowerCase() === owner.toLowerCase(),
67
+ );
68
+ if (matches.length === 1 && matches[0] !== undefined) {
69
+ return { ok: true, value: matches[0] };
70
+ }
71
+ if (matches.length > 1) {
72
+ return {
73
+ ok: false,
74
+ problem: `multiple broker GitHub Apps are configured for repository owner ${owner}; keep exactly one entry for that owner`,
75
+ };
76
+ }
77
+ const loginHint =
78
+ apps.length === 0
79
+ ? 'no broker GitHub Apps are configured'
80
+ : `configured owners: ${apps.map((app) => app.owner).join(', ')}`;
81
+ return {
82
+ ok: false,
83
+ problem: `no broker GitHub App is configured for repository owner ${owner} (${loginHint}); run \`standards creds login github --org ${owner}\` for an organization, or run it without --org while signed in as ${owner}`,
84
+ };
85
+ };
86
+
87
+ export const upsertGithubApp = (
88
+ apps: ReadonlyArray<GithubBrokerApp>,
89
+ app: GithubBrokerApp,
90
+ ): {
91
+ readonly apps: ReadonlyArray<GithubBrokerApp>;
92
+ readonly replaced: GithubBrokerApp | null;
93
+ } => {
94
+ if (app.owner === null || apps.some((entry) => entry.owner === null)) {
95
+ throw new Error('legacy GitHub Apps must be migrated before login');
96
+ }
97
+ const index = apps.findIndex(
98
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
99
+ );
100
+ if (index === -1) {
101
+ return { apps: [...apps, app], replaced: null };
102
+ }
103
+ return {
104
+ apps: apps.map((entry, entryIndex) => (entryIndex === index ? app : entry)),
105
+ replaced: apps[index] ?? null,
106
+ };
107
+ };
@@ -4,11 +4,35 @@
4
4
  // creds-login-github.ts.
5
5
 
6
6
  import { randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import { BROKER_IDENTITY_NAME } from './creds-naming';
7
8
  import type { GithubBrokerApp } from './creds-store';
9
+ import { isGithubBrokerApp } from './creds-store-github';
8
10
  import { apiError, HTTP_CREATED, request } from './github-api';
9
11
  import { isRecord } from './github-settings-parse';
10
12
 
11
13
  export const MANIFEST_STATE_BYTES = 32;
14
+ const GITHUB_APP_NAME_MAX_LENGTH = 34;
15
+
16
+ type GithubAppNameResult =
17
+ | { readonly ok: true; readonly value: string }
18
+ | { readonly ok: false; readonly problem: string };
19
+
20
+ export const resolveGithubAppName = (
21
+ requested: string | undefined,
22
+ org: string | undefined,
23
+ ): GithubAppNameResult => {
24
+ const name =
25
+ requested ??
26
+ (org === undefined
27
+ ? BROKER_IDENTITY_NAME
28
+ : `${BROKER_IDENTITY_NAME}-${org}`);
29
+ return name.length <= GITHUB_APP_NAME_MAX_LENGTH
30
+ ? { ok: true, value: name }
31
+ : {
32
+ ok: false,
33
+ problem: `GitHub App names may contain at most ${GITHUB_APP_NAME_MAX_LENGTH} characters; pass --name with a shorter globally unique name`,
34
+ };
35
+ };
12
36
 
13
37
  // Root-credential ceiling: everything the broker may ever delegate. The
14
38
  // `permission-*` inputs a workflow passes when minting are a request, not a
@@ -70,21 +94,20 @@ export const manifestFormHtml = (
70
94
  ): string =>
71
95
  `<!doctype html><html><body><form id="m" action="${escapeAttribute(`${action}?state=${encodeURIComponent(state)}`)}" method="post"><input type="hidden" name="manifest" value="${escapeAttribute(manifest)}"><noscript><button type="submit">Create GitHub App</button></noscript></form><script>document.getElementById("m").submit()</script></body></html>`;
72
96
 
73
- export const parseConversion = (body: unknown): GithubBrokerApp | null =>
74
- isRecord(body) &&
75
- typeof body.id === 'number' &&
76
- typeof body.slug === 'string' &&
77
- typeof body.html_url === 'string' &&
78
- typeof body.client_id === 'string' &&
79
- typeof body.pem === 'string'
80
- ? {
81
- appId: body.id,
82
- slug: body.slug,
83
- htmlUrl: body.html_url,
84
- clientId: body.client_id,
85
- privateKey: body.pem,
86
- }
87
- : null;
97
+ export const parseConversion = (body: unknown): GithubBrokerApp | null => {
98
+ if (!(isRecord(body) && isRecord(body.owner))) {
99
+ return null;
100
+ }
101
+ const app = {
102
+ owner: body.owner.login,
103
+ appId: body.id,
104
+ slug: body.slug,
105
+ htmlUrl: body.html_url,
106
+ clientId: body.client_id,
107
+ privateKey: body.pem,
108
+ };
109
+ return isGithubBrokerApp(app) && app.owner !== null ? app : null;
110
+ };
88
111
 
89
112
  export type ManifestConversion =
90
113
  | { readonly ok: true; readonly app: GithubBrokerApp }
@@ -1,15 +1,20 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openInBrowser } from './creds-browser';
3
+ import {
4
+ loadOwnedGithubStore,
5
+ sameGithubApp,
6
+ upsertGithubApp,
7
+ } from './creds-github-apps';
3
8
  import {
4
9
  buildAppManifest,
5
10
  convertManifestCode,
6
11
  createManifestState,
7
12
  MANIFEST_STATE_BYTES,
8
13
  manifestFormHtml,
14
+ resolveGithubAppName,
9
15
  } from './creds-login-github-manifest';
10
- import { BROKER_IDENTITY_NAME } from './creds-naming';
11
16
  import {
12
- readBrokerStore,
17
+ type GithubBrokerApp,
13
18
  resolveBrokerPath,
14
19
  updateBrokerStore,
15
20
  } from './creds-store';
@@ -84,50 +89,99 @@ export const waitForCode = (
84
89
  };
85
90
  export const githubInstallMessage = (installUrl: string): string =>
86
91
  `Install it only on the selected repositories that need broker credentials: ${installUrl}`;
87
- export const runCredsLoginGithub = async (options: {
88
- readonly name: string | undefined;
89
- readonly org: string | undefined;
90
- }): Promise<boolean> => {
92
+
93
+ type GithubLoginDependencies = {
94
+ readonly waitForCode: typeof waitForCode;
95
+ readonly convertManifestCode: typeof convertManifestCode;
96
+ readonly openInBrowser: typeof openInBrowser;
97
+ };
98
+
99
+ const DEFAULT_LOGIN_DEPENDENCIES: GithubLoginDependencies = {
100
+ waitForCode,
101
+ convertManifestCode,
102
+ openInBrowser,
103
+ };
104
+
105
+ export const runCredsLoginGithub = async (
106
+ options: {
107
+ readonly name: string | undefined;
108
+ readonly org: string | undefined;
109
+ },
110
+ dependencies: GithubLoginDependencies = DEFAULT_LOGIN_DEPENDENCIES,
111
+ ): Promise<boolean> => {
112
+ const resolvedName = resolveGithubAppName(options.name, options.org);
113
+ if (!resolvedName.ok) {
114
+ console.error(`standards creds: ${resolvedName.problem}`);
115
+ return false;
116
+ }
91
117
  const storePath = resolveBrokerPath();
92
- const store = await readBrokerStore(storePath);
93
- if (store.github !== null) {
94
- console.error(
95
- `standards creds: a broker GitHub App is already configured (${store.github.htmlUrl}); remove the "github" section from ${storePath} to re-run login`,
96
- );
118
+ const loaded = await loadOwnedGithubStore(storePath);
119
+ if (!loaded.ok) {
120
+ console.error(`standards creds: ${loaded.problem}`);
97
121
  return false;
98
122
  }
99
123
  const action =
100
124
  options.org === undefined
101
125
  ? 'https://github.com/settings/apps/new'
102
126
  : `https://github.com/organizations/${options.org}/settings/apps/new`;
103
- const name = options.name ?? BROKER_IDENTITY_NAME;
104
- const code = await waitForCode((port, state) =>
127
+ const code = await dependencies.waitForCode((port, state) =>
105
128
  manifestFormHtml(
106
129
  action,
107
130
  JSON.stringify(
108
- buildAppManifest(name, `http://127.0.0.1:${port}/callback`),
131
+ buildAppManifest(
132
+ resolvedName.value,
133
+ `http://127.0.0.1:${port}/callback`,
134
+ ),
109
135
  ),
110
136
  state,
111
137
  ),
112
138
  );
113
- const conversion = await convertManifestCode(code);
139
+ const conversion = await dependencies.convertManifestCode(code);
114
140
  if (!conversion.ok) {
115
141
  console.error(`standards creds: ${conversion.problem}`);
116
142
  return false;
117
143
  }
118
144
  const { app } = conversion;
119
- await updateBrokerStore(storePath, (current) => {
120
- if (current.github !== null) {
121
- throw new Error(
122
- `a broker GitHub App was configured while login was in progress (${current.github.htmlUrl}); the newly created App ${app.htmlUrl} was not stored and should be deleted`,
145
+ const observed = loaded.value.github.find(
146
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
147
+ );
148
+ let replaced: GithubBrokerApp | undefined;
149
+ try {
150
+ await updateBrokerStore(storePath, (current) => {
151
+ const currentEntry = current.github.find(
152
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
123
153
  );
124
- }
125
- return { ...current, github: app };
126
- });
127
- console.log(`standards creds: created App ${app.slug} (${app.htmlUrl})`);
154
+ const unchanged =
155
+ observed === undefined
156
+ ? currentEntry === undefined
157
+ : currentEntry !== undefined && sameGithubApp(currentEntry, observed);
158
+ if (!unchanged) {
159
+ throw new Error(
160
+ `the broker App for ${app.owner} changed while this App was being created`,
161
+ );
162
+ }
163
+ const upserted = upsertGithubApp(current.github, app);
164
+ replaced = upserted.replaced ?? undefined;
165
+ return { ...current, github: upserted.apps };
166
+ });
167
+ } catch (error) {
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ console.error(
170
+ `standards creds: the newly created App ${app.htmlUrl} was not stored (${message}); retry login, and delete the new App if it is still absent from \`creds status\``,
171
+ );
172
+ return false;
173
+ }
174
+ console.log(
175
+ `standards creds: created App ${app.slug} for ${app.owner} (${app.htmlUrl})`,
176
+ );
128
177
  console.log(` credentials stored in ${storePath}`);
178
+ if (replaced !== undefined) {
179
+ console.log(
180
+ ` replaced App ${replaced.slug} for ${app.owner}; update its SOPS destinations, verify token minting, then delete the old App`,
181
+ );
182
+ }
129
183
  const installUrl = `${app.htmlUrl}/installations/new`;
130
184
  console.log(githubInstallMessage(installUrl));
131
- openInBrowser(installUrl);
185
+ dependencies.openInBrowser(installUrl);
132
186
  return true;
133
187
  };
@@ -7,11 +7,8 @@ import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
7
7
  import { resolveTargetRel } from './creds-dest';
8
8
  import type { PlannedAction } from './creds-plan-types';
9
9
  import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
10
- import {
11
- inspectSopsScalarDestination,
12
- setSopsValues,
13
- verifySopsStoredValue,
14
- } from './creds-sops';
10
+ import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
11
+ import { verifySopsStoredValue } from './creds-sops-value';
15
12
  import type { CloudflareBrokerAccount } from './creds-store';
16
13
 
17
14
  type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
@@ -0,0 +1,89 @@
1
+ // Proving that a stored secret matches the value that was just minted, without
2
+ // that value reaching a return type, a log line, or an error message. Callers
3
+ // destroy the new credential on a proven mismatch and keep it on an
4
+ // unverifiable read, so the two negative answers must stay distinct: anything
5
+ // that is not a leaf we can read as a scalar string is unverifiable.
6
+
7
+ import { isContainedSopsPath, parseSopsKeyPath } from './creds-sops-structure';
8
+ import { runSops } from './sops-exec';
9
+
10
+ export type SopsStoredValueVerification =
11
+ | { readonly ok: true; readonly matches: boolean }
12
+ | { readonly ok: false; readonly problem: string };
13
+ type SopsStoredValueInput = {
14
+ readonly consumer: string;
15
+ readonly rel: string;
16
+ readonly dottedPath: string;
17
+ readonly expectedValue: string;
18
+ };
19
+
20
+ // Extracting a scalar leaf prints the stored value itself, with no quoting and
21
+ // no framing of any kind — probed against sops 3.13.0 and 3.13.3. Every byte of
22
+ // stdout is therefore value, which makes exact equality the only sound
23
+ // comparison: a trailing newline is a difference in the stored secret, not
24
+ // output framing.
25
+ //
26
+ // Once equality has been ruled out, output that parses as JSON is output we
27
+ // cannot read as this leaf's own value. It is a branch or list rather than a
28
+ // scalar; or a typed leaf, which a stored string could equally have produced;
29
+ // or a quoted rendering of exactly the value expected, which is either stray
30
+ // quoting in storage or framing from some sops we have not probed. None of
31
+ // those can be told apart from what was printed, and the wrong answer deletes a
32
+ // credential, so all of them stay unverifiable.
33
+ const isUnreadableOutput = (stdout: string, expectedValue: string): boolean => {
34
+ if (stdout === '') {
35
+ return true;
36
+ }
37
+ try {
38
+ const parsed: unknown = JSON.parse(stdout);
39
+ return typeof parsed !== 'string' || parsed === expectedValue;
40
+ } catch {
41
+ // Not JSON at all, which is the normal case: an opaque token value, or a
42
+ // hex access key ID that reads as a malformed number.
43
+ return false;
44
+ }
45
+ };
46
+
47
+ export const verifySopsStoredValueWith = (
48
+ runner: typeof runSops,
49
+ input: SopsStoredValueInput,
50
+ ): SopsStoredValueVerification => {
51
+ const { consumer, rel, dottedPath, expectedValue } = input;
52
+ const path = parseSopsKeyPath(dottedPath);
53
+ const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
54
+ if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
55
+ return { ok: false, problem };
56
+ }
57
+ const extract = path
58
+ .map((segment) => `[${JSON.stringify(segment)}]`)
59
+ .join('');
60
+ const result = runner(
61
+ ['decrypt', '--extract', extract, '--output-type', 'json', rel],
62
+ consumer,
63
+ );
64
+ if (result.status !== 0) {
65
+ return { ok: false, problem };
66
+ }
67
+ // Equality is decided before anything is classified, so a secret whose own
68
+ // text happens to be valid JSON — an all-digit token — is proven by the bytes
69
+ // rather than second-guessed by their shape.
70
+ if (result.stdout === expectedValue) {
71
+ return { ok: true, matches: true };
72
+ }
73
+ return isUnreadableOutput(result.stdout, expectedValue)
74
+ ? { ok: false, problem }
75
+ : { ok: true, matches: false };
76
+ };
77
+
78
+ export const verifySopsStoredValue = (
79
+ consumer: string,
80
+ rel: string,
81
+ dottedPath: string,
82
+ expectedValue: string,
83
+ ): SopsStoredValueVerification =>
84
+ verifySopsStoredValueWith(runSops, {
85
+ consumer,
86
+ rel,
87
+ dottedPath,
88
+ expectedValue,
89
+ });
package/src/creds-sops.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  import {
16
16
  inspectSopsStructure,
17
17
  isContainedSopsPath,
18
- parseSopsKeyPath,
19
18
  type SopsShapeFailure,
20
19
  } from './creds-sops-structure';
21
20
  import { runSops } from './sops-exec';
@@ -36,15 +35,6 @@ export type SopsScalarDestinationResult = SopsScalarStructureResult | ReadError;
36
35
  export type SopsWriteResult =
37
36
  | { readonly ok: true }
38
37
  | { readonly ok: false; readonly problem: string };
39
- export type SopsStoredValueVerification =
40
- | { readonly ok: true; readonly matches: boolean }
41
- | { readonly ok: false; readonly problem: string };
42
- type SopsStoredValueInput = {
43
- readonly consumer: string;
44
- readonly rel: string;
45
- readonly dottedPath: string;
46
- readonly expectedValue: string;
47
- };
48
38
 
49
39
  export const listEncryptedKeys = (text: string): EncryptedKeysReadResult => {
50
40
  const result = inspectSopsStructure(text, true);
@@ -123,49 +113,6 @@ const editorCommand = (): string => {
123
113
  return `"${process.execPath}" "${editor}"`;
124
114
  };
125
115
 
126
- export const verifySopsStoredValueWith = (
127
- runner: typeof runSops,
128
- input: SopsStoredValueInput,
129
- ): SopsStoredValueVerification => {
130
- const { consumer, rel, dottedPath, expectedValue } = input;
131
- const path = parseSopsKeyPath(dottedPath);
132
- const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
133
- if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
134
- return { ok: false, problem };
135
- }
136
- const extract = path
137
- .map((segment) => `[${JSON.stringify(segment)}]`)
138
- .join('');
139
- const result = runner(
140
- ['decrypt', '--extract', extract, '--output-type', 'json', rel],
141
- consumer,
142
- );
143
- if (result.status !== 0) {
144
- return { ok: false, problem };
145
- }
146
- try {
147
- const stored: unknown = JSON.parse(result.stdout);
148
- return typeof stored === 'string'
149
- ? { ok: true, matches: stored === expectedValue }
150
- : { ok: false, problem };
151
- } catch {
152
- return { ok: false, problem };
153
- }
154
- };
155
-
156
- export const verifySopsStoredValue = (
157
- consumer: string,
158
- rel: string,
159
- dottedPath: string,
160
- expectedValue: string,
161
- ): SopsStoredValueVerification =>
162
- verifySopsStoredValueWith(runSops, {
163
- consumer,
164
- rel,
165
- dottedPath,
166
- expectedValue,
167
- });
168
-
169
116
  export const setSopsValues = (
170
117
  consumer: string,
171
118
  rel: string,