@reclaimprotocol/agent 0.1.39 → 0.1.40

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,16 +1,24 @@
1
+ import { conflict } from '@hapi/boom'
1
2
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
2
- import type { ReclaimClient } from '@reclaimprotocol/client/api'
3
+ import { isProblemError, type ReclaimClient } from '@reclaimprotocol/client/api'
3
4
  import assert from 'node:assert'
4
5
  import { isAbsolute, relative, resolve } from 'node:path'
5
6
  import { defineTool, type RegisteredTool } from '../../server.ts'
6
- import { normalizeEthHex, readDotenvKey, upsertEnvVar } from './eth-key.ts'
7
+ import {
8
+ backupEnvVar,
9
+ normalizeEthHex,
10
+ readDotenvKey,
11
+ removeEnvVar,
12
+ upsertEnvVar,
13
+ } from './eth-key.ts'
7
14
 
8
15
  /**
9
16
  * Creates the organization's eth key pair for session binding and encrypted
10
17
  * results: writes the private key to the app's server env file and registers
11
18
  * only the public key with Builder, so the private key never passes through
12
19
  * the agent's transcript. Reuses a key the env file already holds, so a
13
- * repeat call (for example, to turn on encryption) keeps the same key.
20
+ * repeat call (for example, to turn on encryption) keeps the same key. With
21
+ * `replace`, always generates a fresh key and rotates the org's key to it.
14
22
  */
15
23
  export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
16
24
  return defineTool<{
@@ -19,6 +27,7 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
19
27
  envFile?: string
20
28
  envVar?: string
21
29
  encryptResults?: boolean
30
+ replace?: boolean
22
31
  }>(
23
32
  {
24
33
  name: 'issue_org_keypair',
@@ -30,8 +39,12 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
30
39
  + 'private key to a server env file in the project (reusing a key '
31
40
  + 'already there) and registers only the public key. Returns the '
32
41
  + 'address, file, and variable; never the key. Returns 409 when the '
33
- + 'org already has a different key: ask the developer for that key '
34
- + 'rather than replacing it.',
42
+ + 'org already has a different key. Set `replace: true` only when '
43
+ + 'the developer asked to rotate or replace the key: it generates '
44
+ + 'a new key and swaps it in one step, and apps holding the old '
45
+ + 'key can no longer decrypt new results. The old key is first '
46
+ + 'appended to `<envFile>.backup` (gitignored); tell the developer '
47
+ + 'its path.',
35
48
  inputSchema: {
36
49
  type: 'object',
37
50
  required: ['orgId', 'projectDir'],
@@ -55,11 +68,18 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
55
68
  encryptResults: {
56
69
  type: 'boolean',
57
70
  description:
58
- 'Encrypt delivered results to this key. Default false.',
71
+ 'Encrypt delivered results to this key. Default false, or the '
72
+ + 'org\'s current setting with `replace`.',
73
+ },
74
+ replace: {
75
+ type: 'boolean',
76
+ description:
77
+ 'Rotate: generate a new key and replace the org\'s current '
78
+ + 'one. Default false.',
59
79
  },
60
80
  },
61
81
  },
62
- annotations: { openWorldHint: true, destructiveHint: false },
82
+ annotations: { openWorldHint: true, destructiveHint: true },
63
83
  },
64
84
  async(args) => {
65
85
  assert(
@@ -75,24 +95,60 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
75
95
  new Error('`envFile` must be inside `projectDir`.'),
76
96
  )
77
97
 
78
- const existing = readDotenvKey(envPath, envVar)
98
+ const replace = args.replace ?? false
99
+ // Rotation always generates a fresh key: the env file's key may or
100
+ // may not be the one the org holds, and reuse would be a no-op.
101
+ const existing = replace ? undefined : readDotenvKey(envPath, envVar)
79
102
  const keypair = existing
80
103
  ? deriveFromPrivateKey(normalizeEthHex(existing))
81
104
  : generateKeypair()
82
- // Register first: a 409 (the org has another key) must not leave a
83
- // key Builder doesn't know in the env file.
84
- const canEncryptResult = args.encryptResults ?? false
85
- await client.call('SetOrgKeypair', {
86
- params: { orgId: args.orgId },
87
- body: {
88
- publicKey: keypair.publicKey,
89
- canEncryptResult,
90
- replace: false,
91
- },
92
- })
105
+ // A rotation keeps the org's current setting unless the caller sets it.
106
+ const canEncryptResult = args.encryptResults
107
+ ?? (replace && await currentCanEncryptResult())
108
+ // Back up and save the key before the org switches to it, so a
109
+ // failed write can't leave the org on a key no app holds. A failed
110
+ // registration (for example, a 409) puts the env file back.
111
+ const old = readDotenvKey(envPath, envVar)
112
+ const backupFile = old && !existing
113
+ ? backupEnvVar(
114
+ args.projectDir, envFile, envVar, 'replaced by issue_org_keypair',
115
+ )
116
+ : undefined
93
117
  const path = upsertEnvVar(
94
118
  args.projectDir, envFile, envVar, keypair.privateKey,
95
119
  )
120
+ try {
121
+ await client.call('SetOrgKeypair', {
122
+ params: { orgId: args.orgId },
123
+ body: {
124
+ publicKey: keypair.publicKey,
125
+ canEncryptResult,
126
+ replace,
127
+ },
128
+ })
129
+ } catch(err) {
130
+ if(old) {
131
+ upsertEnvVar(args.projectDir, envFile, envVar, old)
132
+ } else {
133
+ removeEnvVar(args.projectDir, envFile, envVar)
134
+ }
135
+
136
+ if(!replace && isProblemError(err) && err.output.statusCode === 409) {
137
+ throw conflict(
138
+ 'The org already has a different key. Ask the developer to '
139
+ + 'choose: (a) reuse it: they paste its private key into '
140
+ + `\`${envFile}\` as \`${envVar}\` themselves, never into the `
141
+ + 'chat; (b) rotate it: call `issue_org_keypair` again with '
142
+ + '`replace: true`, after which apps holding the old key can no '
143
+ + 'longer decrypt new results; (c) turn off result encryption: '
144
+ + 'call `update_org_keypair` with `canEncryptResult: false`, '
145
+ + 'which keeps the key.',
146
+ )
147
+ }
148
+
149
+ throw err
150
+ }
151
+
96
152
  return {
97
153
  orgId: args.orgId,
98
154
  address: keypair.address,
@@ -100,6 +156,28 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
100
156
  envFile: path,
101
157
  envVar,
102
158
  reused: Boolean(existing),
159
+ backupFile: backupFile ?? null,
160
+ ...(replace && !backupFile && !old
161
+ ? {
162
+ note: 'Nothing was backed up: the old private key is not in '
163
+ + 'this file. Another app may hold it.',
164
+ }
165
+ : {}),
166
+ }
167
+
168
+ async function currentCanEncryptResult() {
169
+ try {
170
+ const { data } = await client.call('GetOrgKeypair', {
171
+ params: { orgId: args.orgId },
172
+ })
173
+ return data.canEncryptResult
174
+ } catch(err) {
175
+ if(isProblemError(err) && err.output.statusCode === 404) {
176
+ return false
177
+ }
178
+
179
+ throw err
180
+ }
103
181
  }
104
182
  },
105
183
  )
@@ -2,7 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import assert from 'node:assert'
3
3
  import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { defineTool, type RegisteredTool } from '../../server.ts'
5
- import { upsertEnvVar } from './eth-key.ts'
5
+ import { backupEnvVar, readDotenvKey, upsertEnvVar } from './eth-key.ts'
6
6
  import { saveOrgKey } from './org-key-store.ts'
7
7
 
8
8
  /**
@@ -24,7 +24,9 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
24
24
  + 'server env file in the project, so the app can read it at runtime. '
25
25
  + 'Also saves it on this machine for the org-key tools. Returns the '
26
26
  + 'org, file, variable, and last four characters; never the key. '
27
- + 'Replaces an existing variable of the same name in the file. '
27
+ + 'Replaces an existing variable of the same name in the file, first '
28
+ + 'appending the old value to `<envFile>.backup` (gitignored); tell '
29
+ + 'the developer its path. '
28
30
  + 'Adds the file to .gitignore. Use when the app needs a key, or '
29
31
  + 'before `create_verification_session`.',
30
32
  inputSchema: {
@@ -69,6 +71,14 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
69
71
  const { data } = await client.call('IssueOrgToken', {
70
72
  params: { orgId: args.orgId },
71
73
  })
74
+ const old = readDotenvKey(
75
+ resolve(args.projectDir, envFile), envVar,
76
+ )
77
+ const backupFile = old && old !== data.orgSecret
78
+ ? backupEnvVar(
79
+ args.projectDir, envFile, envVar, 'replaced by issue_org_token',
80
+ )
81
+ : undefined
72
82
  const path = upsertEnvVar(
73
83
  args.projectDir, envFile, envVar, data.orgSecret,
74
84
  )
@@ -78,6 +88,7 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
78
88
  envFile: path,
79
89
  envVar,
80
90
  last4: data.last4,
91
+ backupFile: backupFile ?? null,
81
92
  }
82
93
  },
83
94
  )