agent-messenger 2.20.2 → 2.20.3
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/.claude-plugin/plugin.json +1 -1
- package/bun.lock +41 -0
- package/dist/package.json +2 -1
- package/dist/src/platforms/webex/client.d.ts +2 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +29 -5
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/message.js +17 -13
- package/dist/src/platforms/webex/commands/message.js.map +1 -1
- package/dist/src/platforms/webex/encryption.d.ts +7 -0
- package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
- package/dist/src/platforms/webex/encryption.js +12 -1
- package/dist/src/platforms/webex/encryption.js.map +1 -1
- package/dist/src/platforms/webex/kms-key-provider.d.ts +20 -0
- package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -0
- package/dist/src/platforms/webex/kms-key-provider.js +78 -0
- package/dist/src/platforms/webex/kms-key-provider.js.map +1 -0
- package/docs/content/docs/cli/webex.mdx +1 -1
- package/package.json +2 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webex/references/authentication.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/webex/client.ts +35 -6
- package/src/platforms/webex/commands/message.test.ts +12 -0
- package/src/platforms/webex/commands/message.ts +22 -14
- package/src/platforms/webex/encryption.test.ts +58 -3
- package/src/platforms/webex/encryption.ts +19 -1
- package/src/platforms/webex/kms-key-provider.ts +99 -0
- package/src/platforms/webex/typings/webex-message-handler.d.ts +45 -0
|
@@ -19,7 +19,7 @@ Extracts your first-party Webex session token from a Chromium-based browser wher
|
|
|
19
19
|
- **Supported browsers**: Chrome, Chrome Canary, Edge, Arc, Brave, Vivaldi, Chromium
|
|
20
20
|
- **Token lifetime**: Depends on Webex session policy (typically hours to days). Re-extract when expired.
|
|
21
21
|
- **Auto-extraction**: The CLI attempts browser extraction automatically when no valid token is stored, so you often don't need to run `auth extract` manually.
|
|
22
|
-
- **End-to-end encryption**:
|
|
22
|
+
- **End-to-end encryption**: Messages are encrypted client-side (JWE with AES-256-GCM) before sending via the internal conversation API, so they appear as encrypted in the Webex client. Keys cached from the browser are used directly; for an end-to-end encrypted conversation whose key was not cached at extract time, the key is fetched on demand over the Mercury websocket + KMS flow and persisted for reuse. Only non-encrypted conversations are sent as plaintext.
|
|
23
23
|
- **Best for**: Interactive use, sending messages as yourself without the "via" label
|
|
24
24
|
|
|
25
25
|
```bash
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { WebexCredentialManager } from './credential-manager'
|
|
2
2
|
import { WebexEncryptionService } from './encryption'
|
|
3
|
+
import { KmsKeyProvider } from './kms-key-provider'
|
|
3
4
|
import { escapeHtml, markdownToHtml, stripMarkdown } from './markdown-to-html'
|
|
4
|
-
import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
|
|
5
|
+
import type { WebexConfig, WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
|
|
5
6
|
import { WebexError } from './types'
|
|
6
7
|
|
|
7
8
|
const BASE_URL = 'https://webexapis.com/v1'
|
|
@@ -44,16 +45,40 @@ export class WebexClient {
|
|
|
44
45
|
this.tokenType = config?.tokenType ?? null
|
|
45
46
|
await this.login({ token })
|
|
46
47
|
|
|
47
|
-
if (this.tokenType === 'extracted'
|
|
48
|
-
const keysMap = new Map(Object.entries(config
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
if (this.tokenType === 'extracted') {
|
|
49
|
+
const keysMap = new Map(Object.entries(config?.encryptionKeys ?? {}))
|
|
50
|
+
this.encryption = new WebexEncryptionService(keysMap)
|
|
51
|
+
const kmsProvider = new KmsKeyProvider({ token })
|
|
52
|
+
this.encryption.setKeyProvider({
|
|
53
|
+
fetchKey: async (keyUri: string) => {
|
|
54
|
+
const serializedKey = await kmsProvider.fetchKey(keyUri)
|
|
55
|
+
if (serializedKey) {
|
|
56
|
+
await this.persistEncryptionKey(credManager, keyUri, serializedKey)
|
|
57
|
+
}
|
|
58
|
+
return serializedKey
|
|
59
|
+
},
|
|
60
|
+
close: () => kmsProvider.close(),
|
|
61
|
+
})
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
return this
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
async dispose(): Promise<void> {
|
|
68
|
+
await this.encryption?.close()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async persistEncryptionKey(
|
|
72
|
+
credManager: WebexCredentialManager,
|
|
73
|
+
keyUri: string,
|
|
74
|
+
serializedKey: string,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const latestConfig = await credManager.loadConfig()
|
|
77
|
+
if (!latestConfig) return
|
|
78
|
+
const encryptionKeys = { ...latestConfig.encryptionKeys, [keyUri]: serializedKey }
|
|
79
|
+
await credManager.saveConfig({ ...latestConfig, encryptionKeys } satisfies WebexConfig)
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
private ensureAuth(): string {
|
|
58
83
|
if (this.token === null) {
|
|
59
84
|
throw new WebexError('Not authenticated. Call .login() first.', 'not_authenticated')
|
|
@@ -287,6 +312,9 @@ export class WebexClient {
|
|
|
287
312
|
if (keyUri) {
|
|
288
313
|
const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
|
|
289
314
|
const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
|
|
315
|
+
if (content && !encryptedContent) {
|
|
316
|
+
throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
|
|
317
|
+
}
|
|
290
318
|
if (encryptedDisplayName) {
|
|
291
319
|
const object: Record<string, string> = {
|
|
292
320
|
objectType: 'comment',
|
|
@@ -297,6 +325,7 @@ export class WebexClient {
|
|
|
297
325
|
}
|
|
298
326
|
return { object, encryptionKeyUrl: keyUri }
|
|
299
327
|
}
|
|
328
|
+
throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
|
|
300
329
|
}
|
|
301
330
|
}
|
|
302
331
|
|
|
@@ -32,6 +32,7 @@ let mockGetMessage: ReturnType<typeof spyOn>
|
|
|
32
32
|
let mockDeleteMessage: ReturnType<typeof spyOn>
|
|
33
33
|
let mockEditMessage: ReturnType<typeof spyOn>
|
|
34
34
|
let mockLogin: ReturnType<typeof spyOn>
|
|
35
|
+
let mockDispose: ReturnType<typeof spyOn>
|
|
35
36
|
let consoleLogSpy: ReturnType<typeof spyOn>
|
|
36
37
|
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
37
38
|
let processExitSpy: ReturnType<typeof spyOn>
|
|
@@ -47,6 +48,7 @@ beforeEach(() => {
|
|
|
47
48
|
mockLogin = protoSpy('login').mockImplementation(async function (this: WebexClient) {
|
|
48
49
|
return this
|
|
49
50
|
})
|
|
51
|
+
mockDispose = protoSpy('dispose').mockResolvedValue(undefined)
|
|
50
52
|
mockSendMessage = protoSpy('sendMessage').mockResolvedValue(mockMessage)
|
|
51
53
|
mockSendDirectMessage = protoSpy('sendDirectMessage').mockResolvedValue(mockMessage)
|
|
52
54
|
mockListMessages = protoSpy('listMessages').mockResolvedValue([mockMessage, mockMessage2])
|
|
@@ -78,6 +80,7 @@ it('calls sendMessage with correct args and outputs result', async () => {
|
|
|
78
80
|
expect(output).toContain('msg_123')
|
|
79
81
|
expect(output).toContain('space_456')
|
|
80
82
|
expect(output).toContain('user@example.com')
|
|
83
|
+
expect(mockDispose).toHaveBeenCalled()
|
|
81
84
|
})
|
|
82
85
|
|
|
83
86
|
it('passes markdown option when --markdown flag is set on send', async () => {
|
|
@@ -86,6 +89,14 @@ it('passes markdown option when --markdown flag is set on send', async () => {
|
|
|
86
89
|
expect(mockSendMessage).toHaveBeenCalledWith('space_456', '**bold**', { markdown: true })
|
|
87
90
|
})
|
|
88
91
|
|
|
92
|
+
it('disposes the client when send fails', async () => {
|
|
93
|
+
mockSendMessage.mockRejectedValue(new WebexError('Send failed', 'send_failed'))
|
|
94
|
+
|
|
95
|
+
await sendAction('space_456', 'Hello world', { pretty: false })
|
|
96
|
+
|
|
97
|
+
expect(mockDispose).toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
89
100
|
it('exits with code 1 when not authenticated on send', async () => {
|
|
90
101
|
mockLogin.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
|
|
91
102
|
|
|
@@ -166,6 +177,7 @@ it('calls editMessage with roomId in args and outputs result', async () => {
|
|
|
166
177
|
const output = consoleLogSpy.mock.calls[0][0]
|
|
167
178
|
expect(output).toContain('msg_123')
|
|
168
179
|
expect(output).toContain('Updated message')
|
|
180
|
+
expect(mockDispose).toHaveBeenCalled()
|
|
169
181
|
})
|
|
170
182
|
|
|
171
183
|
it('passes markdown option to editMessage when --markdown flag is set', async () => {
|
|
@@ -6,14 +6,23 @@ import { formatOutput } from '@/shared/utils/output'
|
|
|
6
6
|
import { WebexClient } from '../client'
|
|
7
7
|
import type { WebexMessage } from '../types'
|
|
8
8
|
|
|
9
|
+
async function withWebexClient<T>(run: (client: WebexClient) => Promise<T>): Promise<T> {
|
|
10
|
+
const client = new WebexClient()
|
|
11
|
+
try {
|
|
12
|
+
await client.login()
|
|
13
|
+
return await run(client)
|
|
14
|
+
} finally {
|
|
15
|
+
await client.dispose()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export async function sendAction(
|
|
10
20
|
spaceId: string,
|
|
11
21
|
text: string,
|
|
12
22
|
options: { markdown?: boolean; pretty?: boolean },
|
|
13
23
|
): Promise<void> {
|
|
14
24
|
try {
|
|
15
|
-
const
|
|
16
|
-
const message = await client.sendMessage(spaceId, text, { markdown: options.markdown })
|
|
25
|
+
const message = await withWebexClient((client) => client.sendMessage(spaceId, text, { markdown: options.markdown }))
|
|
17
26
|
|
|
18
27
|
const output = {
|
|
19
28
|
id: message.id,
|
|
@@ -31,9 +40,8 @@ export async function sendAction(
|
|
|
31
40
|
|
|
32
41
|
export async function listAction(spaceId: string, options: { limit?: number; pretty?: boolean }): Promise<void> {
|
|
33
42
|
try {
|
|
34
|
-
const client = await new WebexClient().login()
|
|
35
43
|
const limit = options.limit ?? 50
|
|
36
|
-
const messages = await client.listMessages(spaceId, { max: limit })
|
|
44
|
+
const messages = await withWebexClient((client) => client.listMessages(spaceId, { max: limit }))
|
|
37
45
|
|
|
38
46
|
const output = messages.map((msg: WebexMessage) => ({
|
|
39
47
|
id: msg.id,
|
|
@@ -51,8 +59,7 @@ export async function listAction(spaceId: string, options: { limit?: number; pre
|
|
|
51
59
|
|
|
52
60
|
export async function getAction(messageId: string, options: { pretty?: boolean }): Promise<void> {
|
|
53
61
|
try {
|
|
54
|
-
const
|
|
55
|
-
const message = await client.getMessage(messageId)
|
|
62
|
+
const message = await withWebexClient((client) => client.getMessage(messageId))
|
|
56
63
|
|
|
57
64
|
const output = {
|
|
58
65
|
id: message.id,
|
|
@@ -75,8 +82,7 @@ export async function deleteAction(messageId: string, options: { force?: boolean
|
|
|
75
82
|
return process.exit(0)
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
await client.deleteMessage(messageId)
|
|
85
|
+
await withWebexClient((client) => client.deleteMessage(messageId))
|
|
80
86
|
|
|
81
87
|
console.log(formatOutput({ deleted: messageId }, options.pretty))
|
|
82
88
|
} catch (error) {
|
|
@@ -91,10 +97,11 @@ export async function editAction(
|
|
|
91
97
|
options: { markdown?: boolean; pretty?: boolean },
|
|
92
98
|
): Promise<void> {
|
|
93
99
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const message = await withWebexClient((client) =>
|
|
101
|
+
client.editMessage(messageId, spaceId, text, {
|
|
102
|
+
markdown: options.markdown,
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
98
105
|
|
|
99
106
|
const output = {
|
|
100
107
|
id: message.id,
|
|
@@ -116,8 +123,9 @@ export async function dmAction(
|
|
|
116
123
|
options: { markdown?: boolean; pretty?: boolean },
|
|
117
124
|
): Promise<void> {
|
|
118
125
|
try {
|
|
119
|
-
const
|
|
120
|
-
|
|
126
|
+
const message = await withWebexClient((client) =>
|
|
127
|
+
client.sendDirectMessage(email, text, { markdown: options.markdown }),
|
|
128
|
+
)
|
|
121
129
|
|
|
122
130
|
const output = {
|
|
123
131
|
id: message.id,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import * as jose from 'node-jose'
|
|
4
4
|
|
|
@@ -11,12 +11,16 @@ const decodeJweHeader = (jwe: string): Record<string, unknown> => {
|
|
|
11
11
|
return JSON.parse(json) as Record<string, unknown>
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const createSerializedKey = async (keyUri: string) => {
|
|
15
15
|
const keystore = jose.JWK.createKeyStore()
|
|
16
16
|
const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
|
|
17
17
|
const jwk = key.toJSON(true)
|
|
18
|
+
return JSON.stringify({ uri: keyUri, jwk })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const createKeyring = async (keyUri: string) => {
|
|
18
22
|
const rawKeys = new Map<string, string>()
|
|
19
|
-
rawKeys.set(keyUri,
|
|
23
|
+
rawKeys.set(keyUri, await createSerializedKey(keyUri))
|
|
20
24
|
return new WebexEncryptionService(rawKeys)
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -51,4 +55,55 @@ describe('WebexEncryptionService', () => {
|
|
|
51
55
|
|
|
52
56
|
expect(plaintext).toBe('round trip')
|
|
53
57
|
})
|
|
58
|
+
|
|
59
|
+
it('getKey returns cached key without calling provider when key is present', async () => {
|
|
60
|
+
const service = await createKeyring(keyUri)
|
|
61
|
+
const provider = { fetchKey: mock(async () => null as string | null) }
|
|
62
|
+
service.setKeyProvider(provider)
|
|
63
|
+
|
|
64
|
+
const key = await service.getKey(keyUri)
|
|
65
|
+
|
|
66
|
+
expect(key).not.toBeNull()
|
|
67
|
+
expect(provider.fetchKey).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('getKey calls provider and returns key when key is missing', async () => {
|
|
71
|
+
const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/0d7a0dfb-0464-40ce-8f3d-e65a33b61561'
|
|
72
|
+
const serializedKey = await createSerializedKey(missingKeyUri)
|
|
73
|
+
const service = new WebexEncryptionService(new Map())
|
|
74
|
+
const provider = { fetchKey: mock(async () => serializedKey) }
|
|
75
|
+
service.setKeyProvider(provider)
|
|
76
|
+
|
|
77
|
+
const key = await service.getKey(missingKeyUri)
|
|
78
|
+
|
|
79
|
+
expect(key).not.toBeNull()
|
|
80
|
+
expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('getKey returns null when provider returns null', async () => {
|
|
84
|
+
const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/13d6256d-f7f1-4b98-8102-4d3d87b2834a'
|
|
85
|
+
const service = new WebexEncryptionService(new Map())
|
|
86
|
+
const provider = { fetchKey: mock(async () => null as string | null) }
|
|
87
|
+
service.setKeyProvider(provider)
|
|
88
|
+
|
|
89
|
+
const key = await service.getKey(missingKeyUri)
|
|
90
|
+
|
|
91
|
+
expect(key).toBeNull()
|
|
92
|
+
expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('getKey reuses provider result from raw keys after first fetch', async () => {
|
|
96
|
+
const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/84afb005-5ba5-49c8-bd46-0c5d7ddf1c30'
|
|
97
|
+
const serializedKey = await createSerializedKey(missingKeyUri)
|
|
98
|
+
const service = new WebexEncryptionService(new Map())
|
|
99
|
+
const provider = { fetchKey: mock(async () => serializedKey) }
|
|
100
|
+
service.setKeyProvider(provider)
|
|
101
|
+
|
|
102
|
+
await service.getKey(missingKeyUri)
|
|
103
|
+
;(service as unknown as { keyCache: Map<string, jose.JWK.Key> }).keyCache.clear()
|
|
104
|
+
const key = await service.getKey(missingKeyUri)
|
|
105
|
+
|
|
106
|
+
expect(key).not.toBeNull()
|
|
107
|
+
expect(provider.fetchKey).toHaveBeenCalledTimes(1)
|
|
108
|
+
})
|
|
54
109
|
})
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import * as jose from 'node-jose'
|
|
2
2
|
|
|
3
|
+
export interface WebexKeyProvider {
|
|
4
|
+
fetchKey(keyUri: string): Promise<string | null>
|
|
5
|
+
close?(): Promise<void>
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
export class WebexEncryptionService {
|
|
4
9
|
private rawKeys: Map<string, string>
|
|
5
10
|
private keyCache: Map<string, jose.JWK.Key> = new Map()
|
|
11
|
+
private keyProvider: WebexKeyProvider | null = null
|
|
6
12
|
|
|
7
13
|
constructor(serializedKeys: Map<string, string>) {
|
|
8
14
|
this.rawKeys = serializedKeys
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
setKeyProvider(provider: WebexKeyProvider): void {
|
|
18
|
+
this.keyProvider = provider
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async close(): Promise<void> {
|
|
22
|
+
await this.keyProvider?.close?.()
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
async getKey(keyUri: string): Promise<jose.JWK.Key | null> {
|
|
12
26
|
const cached = this.keyCache.get(keyUri)
|
|
13
27
|
if (cached) return cached
|
|
14
28
|
|
|
15
|
-
|
|
29
|
+
let raw = this.rawKeys.get(keyUri)
|
|
30
|
+
if (!raw && this.keyProvider) {
|
|
31
|
+
raw = (await this.keyProvider.fetchKey(keyUri)) ?? undefined
|
|
32
|
+
}
|
|
16
33
|
if (!raw) return null
|
|
17
34
|
|
|
18
35
|
try {
|
|
19
36
|
const parsed = JSON.parse(raw) as { jwk: object }
|
|
20
37
|
const joseKey = await jose.JWK.asKey(parsed.jwk)
|
|
38
|
+
this.rawKeys.set(keyUri, raw)
|
|
21
39
|
this.keyCache.set(keyUri, joseKey)
|
|
22
40
|
return joseKey
|
|
23
41
|
} catch {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DeviceManager, KmsClient, MercurySocket, noopLogger } from 'webex-message-handler'
|
|
2
|
+
import WebSocket from 'ws'
|
|
3
|
+
|
|
4
|
+
interface KmsKeyProviderOptions {
|
|
5
|
+
token: string
|
|
6
|
+
logger?: { debug(message: string): void }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Registration {
|
|
10
|
+
webSocketUrl: string
|
|
11
|
+
deviceUrl: string
|
|
12
|
+
userId: string
|
|
13
|
+
encryptionServiceUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type HttpRequest = {
|
|
17
|
+
url: string
|
|
18
|
+
method: string
|
|
19
|
+
headers: Record<string, string>
|
|
20
|
+
body?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class KmsKeyProvider {
|
|
24
|
+
private token: string
|
|
25
|
+
private logger?: { debug(message: string): void }
|
|
26
|
+
private mercury: MercurySocket | null = null
|
|
27
|
+
private kms: KmsClient | null = null
|
|
28
|
+
private readyPromise: Promise<void> | null = null
|
|
29
|
+
|
|
30
|
+
constructor(options: KmsKeyProviderOptions) {
|
|
31
|
+
this.token = options.token
|
|
32
|
+
this.logger = options.logger
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async fetchKey(keyUri: string): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
await this.ensureReady()
|
|
38
|
+
const key = await this.kms?.getKey(keyUri)
|
|
39
|
+
if (!key) return null
|
|
40
|
+
return JSON.stringify({ uri: keyUri, jwk: key.toJSON(true) })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
this.logger?.debug(`Webex KMS key fetch failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
43
|
+
await this.close()
|
|
44
|
+
this.readyPromise = null
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async close(): Promise<void> {
|
|
50
|
+
await this.mercury?.disconnect().catch(() => undefined)
|
|
51
|
+
this.mercury = null
|
|
52
|
+
this.kms = null
|
|
53
|
+
this.readyPromise = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async ensureReady(): Promise<void> {
|
|
57
|
+
this.readyPromise ??= this.initialize()
|
|
58
|
+
await this.readyPromise
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async initialize(): Promise<void> {
|
|
62
|
+
const httpDo = async (req: HttpRequest) => {
|
|
63
|
+
const res = await fetch(req.url, {
|
|
64
|
+
method: req.method,
|
|
65
|
+
headers: req.headers,
|
|
66
|
+
body: req.body,
|
|
67
|
+
})
|
|
68
|
+
return {
|
|
69
|
+
status: res.status,
|
|
70
|
+
ok: res.ok,
|
|
71
|
+
json: () => res.json(),
|
|
72
|
+
text: () => res.text(),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const wsFactory = (url: string) => new WebSocket(url) as never
|
|
76
|
+
const dm = new DeviceManager({ logger: noopLogger, httpDo })
|
|
77
|
+
const reg = (await dm.register(this.token)) as Registration
|
|
78
|
+
const mercury = new MercurySocket({ logger: noopLogger, wsFactory })
|
|
79
|
+
const kms = new KmsClient({
|
|
80
|
+
token: this.token,
|
|
81
|
+
deviceUrl: reg.deviceUrl,
|
|
82
|
+
userId: reg.userId,
|
|
83
|
+
encryptionServiceUrl: reg.encryptionServiceUrl,
|
|
84
|
+
logger: noopLogger,
|
|
85
|
+
httpDo,
|
|
86
|
+
})
|
|
87
|
+
mercury.on('kms:response', (data: unknown) => kms.handleKmsMessage(data))
|
|
88
|
+
await mercury.connect(reg.webSocketUrl, this.token)
|
|
89
|
+
this.mercury = mercury
|
|
90
|
+
try {
|
|
91
|
+
await kms.initialize()
|
|
92
|
+
} catch (error) {
|
|
93
|
+
await mercury.disconnect().catch(() => undefined)
|
|
94
|
+
this.mercury = null
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
this.kms = kms
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export {}
|
|
2
|
+
|
|
3
|
+
declare module 'webex-message-handler' {
|
|
4
|
+
import type * as jose from 'node-jose'
|
|
5
|
+
|
|
6
|
+
type Logger = Record<string, (...args: unknown[]) => void>
|
|
7
|
+
type HttpRequest = { url: string; method: string; headers: Record<string, string>; body?: string }
|
|
8
|
+
type HttpResponse = { status: number; ok: boolean; json(): Promise<unknown>; text(): Promise<string> }
|
|
9
|
+
type HttpDo = (req: HttpRequest) => Promise<HttpResponse>
|
|
10
|
+
|
|
11
|
+
export const noopLogger: Logger
|
|
12
|
+
export const consoleLogger: Logger
|
|
13
|
+
|
|
14
|
+
export class DeviceManager {
|
|
15
|
+
constructor(options: { logger: Logger; httpDo: HttpDo })
|
|
16
|
+
register(token: string): Promise<{
|
|
17
|
+
webSocketUrl: string
|
|
18
|
+
deviceUrl: string
|
|
19
|
+
userId: string
|
|
20
|
+
services: unknown
|
|
21
|
+
encryptionServiceUrl: string
|
|
22
|
+
}>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MercurySocket {
|
|
26
|
+
constructor(options: { logger: Logger; wsFactory: (url: string) => unknown })
|
|
27
|
+
on(event: 'kms:response', handler: (data: unknown) => void): void
|
|
28
|
+
connect(webSocketUrl: string, token: string): Promise<void>
|
|
29
|
+
disconnect(): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class KmsClient {
|
|
33
|
+
constructor(options: {
|
|
34
|
+
token: string
|
|
35
|
+
deviceUrl: string
|
|
36
|
+
userId: string
|
|
37
|
+
encryptionServiceUrl: string
|
|
38
|
+
logger: Logger
|
|
39
|
+
httpDo: HttpDo
|
|
40
|
+
})
|
|
41
|
+
initialize(): Promise<void>
|
|
42
|
+
getKey(keyUri: string): Promise<jose.JWK.Key | null>
|
|
43
|
+
handleKmsMessage(data: unknown): void
|
|
44
|
+
}
|
|
45
|
+
}
|