diff --git a/npmDepsHash b/npmDepsHash index 8e42835b..30ccd5bd 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-ckDhoiFXCuB/abIn/GSYKFHMIwe+7pTHCoTbc3Hsyuo= +sha256-QZafJAHalDT2QQhw4evfJ1fun7saN4Mz08JX8YP4SDQ= diff --git a/package-lock.json b/package-lock.json index e25db967..33299311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", - "polykey": "^2.3.5", + "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", @@ -9561,9 +9561,9 @@ } }, "node_modules/polykey": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.3.5.tgz", - "integrity": "sha512-+UZTxufcnvi8+JRM+/C5AbM779ztzpbLzh61tBUDfvdygGGjVZzGcHtHyRofQsy3z2Lkf0hINbRhGszodvxcBA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.4.0.tgz", + "integrity": "sha512-IWYTjcGns8FV736TLixnaqCip983ZjHbO5cD3MNPxpK6OwTVh/1s3bIP9JJMSsBFld4IAJ+qp2geGNz7eXMQ9g==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^2.0.1", diff --git a/package.json b/package.json index 59dd6650..b44533a4 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", - "polykey": "^2.3.5", + "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", diff --git a/src/auth/CommandAuth.ts b/src/auth/CommandAuth.ts new file mode 100644 index 00000000..40fe8e4e --- /dev/null +++ b/src/auth/CommandAuth.ts @@ -0,0 +1,13 @@ +import CommandLogin from './CommandLogin.js'; +import CommandPolykey from '../CommandPolykey.js'; + +class CommandAuth extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('auth'); + this.description('Authentication operations'); + this.addCommand(new CommandLogin(...args)); + } +} + +export default CommandAuth; diff --git a/src/auth/CommandLogin.ts b/src/auth/CommandLogin.ts new file mode 100644 index 00000000..9203c118 --- /dev/null +++ b/src/auth/CommandLogin.ts @@ -0,0 +1,113 @@ +import type PolykeyClient from 'polykey/PolykeyClient.js'; +import type { + TokenPayloadEncoded, + TokenProtectedHeaderEncoded, + TokenSignatureEncoded, +} from 'polykey/tokens/types.js'; +import type { IdentityRequestData } from 'polykey/client/types.js'; +import CommandPolykey from '../CommandPolykey.js'; +import * as binProcessors from '../utils/processors.js'; +import * as binParsers from '../utils/parsers.js'; +import * as binUtils from '../utils/index.js'; +import * as binOptions from '../utils/options.js'; +import * as errors from '../errors.js'; + +class CommandLogin extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('login'); + this.description('Login to a platform with Polykey identity'); + this.argument( + '', + 'Token provided by platform for logging in', + binParsers.parseCompactJWT, + ); + this.addOption(binOptions.nodeId); + this.addOption(binOptions.clientHost); + this.addOption(binOptions.clientPort); + this.action(async (encodedToken, options) => { + const { default: PolykeyClient } = await import( + 'polykey/PolykeyClient.js' + ); + const tokensUtils = await import('polykey/tokens/utils.js'); + const clientOptions = await binProcessors.processClientOptions( + options.nodePath, + options.nodeId, + options.clientHost, + options.clientPort, + this.fs, + this.logger.getChild(binProcessors.processClientOptions.name), + ); + const meta = await binProcessors.processAuthentication( + options.passwordFile, + this.fs, + ); + + let pkClient: PolykeyClient; + this.exitHandlers.handlers.push(async () => { + if (pkClient != null) await pkClient.stop(); + }); + try { + pkClient = await PolykeyClient.createPolykeyClient({ + nodeId: clientOptions.nodeId, + host: clientOptions.clientHost, + port: clientOptions.clientPort, + options: { + nodePath: options.nodePath, + }, + logger: this.logger.getChild(PolykeyClient.name), + }); + + // Create a JSON representation of the encoded header + const [protectedHeader, payload, signature] = encodedToken; + const incomingTokenEncoded = { + payload: payload as TokenPayloadEncoded, + signatures: [ + { + protected: protectedHeader as TokenProtectedHeaderEncoded, + signature: signature as TokenSignatureEncoded, + }, + ], + }; + + // Get it verified and signed by the agent + const response = await binUtils.retryAuthentication( + (auth) => + pkClient.rpcClient.methods.authSignToken({ + metadata: auth, + ...incomingTokenEncoded, + }), + meta, + ); + + // Send the returned JWT to the returnURL provided by the initial token + const compactHeader = binUtils.jsonToCompactJWT(response); + const incomingPayload = + tokensUtils.parseTokenPayload(payload); + let result: Response; + try { + result = await fetch(incomingPayload.returnURL, { + method: 'POST', + body: JSON.stringify({ token: compactHeader }), + }); + } catch (e) { + throw new errors.ErrorPolykeyCLILoginFailed( + 'Failed to send token to return url', + { cause: e }, + ); + } + + // Handle non-200 response + if (!result.ok) { + throw new errors.ErrorPolykeyCLILoginFailed( + `Return url returned failure with code ${result.status}`, + ); + } + } finally { + if (pkClient! != null) await pkClient.stop(); + } + }); + } +} + +export default CommandLogin; diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 00000000..48a0e02c --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1 @@ +export { default } from './CommandAuth.js'; diff --git a/src/errors.ts b/src/errors.ts index 75744c18..469715ee 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -196,6 +196,16 @@ class ErrorPolykeyCLITouchSecret extends ErrorPolykeyCLI { exitCode = 1; } +class ErrorPolykeyCLIInvalidJWT extends ErrorPolykeyCLI { + static description: 'JWT is not valid'; + exitCode = sysexits.USAGE; +} + +class ErrorPolykeyCLILoginFailed extends ErrorPolykeyCLI { + static description = 'Failed to login using Polykey'; + exitCode = sysexits.SOFTWARE; +} + export { ErrorPolykeyCLI, ErrorPolykeyCLIUncaughtException, @@ -224,4 +234,6 @@ export { ErrorPolykeyCLICatSecret, ErrorPolykeyCLIEditSecret, ErrorPolykeyCLITouchSecret, + ErrorPolykeyCLIInvalidJWT, + ErrorPolykeyCLILoginFailed, }; diff --git a/src/polykey.ts b/src/polykey.ts index 8b19c378..19984c38 100755 --- a/src/polykey.ts +++ b/src/polykey.ts @@ -152,6 +152,7 @@ async function polykeyMain(argv: Array): Promise { const { default: CommandBootstrap } = await import('./bootstrap/index.js'); const { default: CommandAgent } = await import('./agent/index.js'); const { default: CommandAudit } = await import('./audit/index.js'); + const { default: CommandAuth } = await import('./auth/index.js'); const { default: CommandVaults } = await import('./vaults/index.js'); const { default: CommandSecrets } = await import('./secrets/index.js'); const { default: CommandKeys } = await import('./keys/index.js'); @@ -181,6 +182,7 @@ async function polykeyMain(argv: Array): Promise { rootCommand.addCommand(new CommandBootstrap({ exitHandlers, fs })); rootCommand.addCommand(new CommandAgent({ exitHandlers, fs })); rootCommand.addCommand(new CommandAudit({ exitHandlers, fs })); + rootCommand.addCommand(new CommandAuth({ exitHandlers, fs })); rootCommand.addCommand(new CommandNodes({ exitHandlers, fs })); rootCommand.addCommand(new CommandSecrets({ exitHandlers, fs })); rootCommand.addCommand(new CommandKeys({ exitHandlers, fs })); diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 39eec37a..43eff651 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -13,6 +13,7 @@ const vaultNameRegex = /^(?!.*[:])[ -~\t\n]*$/s; const secretPathRegex = /^(?!.*[=])[ -~\t\n]*$/s; const secretPathValueRegex = /^([a-zA-Z_][\w]+)?$/; const environmentVariableRegex = /^([a-zA-Z_]+[a-zA-Z0-9_]*)?$/; +const base64UrlRegex = /^[A-Za-z0-9\-_]+$/; /** * Converts a validation parser to commander argument parser @@ -192,6 +193,30 @@ const parsePort: (data: string) => Port = validateParserToArgParser( const parseSeedNodes: (data: string) => [SeedNodes, boolean] = validateParserToArgParser(nodesUtils.parseSeedNodes); +// Compact JWTs are in xxxx.yyyy.zzzz format where x is the protected +// header, y is the payload, and z is the binary signature. +const parseCompactJWT = (token: string): [string, string, string] => { + // Clean up whitespaces + token = token.trim(); + + // Confirm part amount + const parts = token.split('.'); + if (parts.length !== 3) { + throw new InvalidArgumentError( + 'JWT must contain three dot-separated parts', + ); + } + + // Validate base64 encoding + for (const part of parts) { + if (!part || !base64UrlRegex.test(part)) { + throw new InvalidArgumentError('JWT is not correctly encoded'); + } + } + + return [parts[0], parts[1], parts[2]]; +}; + export { vaultNameRegex, secretPathRegex, @@ -220,4 +245,5 @@ export { parseProviderId, parseIdentityId, parseProviderIdList, + parseCompactJWT, }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 55e8dba8..882a2f93 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,5 @@ -import type { FileSystem } from 'polykey/types.js'; -import type { POJO } from 'polykey/types.js'; +import type { FileSystem, POJO } from 'polykey/types.js'; +import type { SignedTokenEncoded } from 'polykey/tokens/types.js'; import type { TableRow, TableOptions, @@ -637,6 +637,15 @@ async function importFS(fs?: FileSystem): Promise { return fsImported; } +function jsonToCompactJWT(token: SignedTokenEncoded): string { + if (token.signatures.length !== 1) { + throw new errors.ErrorPolykeyCLIInvalidJWT( + 'Too many signatures, expected 1', + ); + } + return `${token.signatures[0].protected}.${token.payload}.${token.signatures[0].signature}`; +} + export { verboseToLogLevel, standardErrorReplacer, @@ -660,6 +669,7 @@ export { generateVersionString, promise, importFS, + jsonToCompactJWT, }; export type { OutputObject }; diff --git a/tests/auth/login.test.ts b/tests/auth/login.test.ts new file mode 100644 index 00000000..d119cb79 --- /dev/null +++ b/tests/auth/login.test.ts @@ -0,0 +1,162 @@ +import type { + IdentityRequestData, + IdentityResponseData, +} from 'polykey/client/types.js'; +import type { + TokenPayloadEncoded, + TokenProtectedHeaderEncoded, + TokenSignatureEncoded, +} from 'polykey/tokens/types.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import fc from 'fast-check'; +import { jest } from '@jest/globals'; +import { test } from '@fast-check/jest'; +import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; +import PolykeyAgent from 'polykey/PolykeyAgent.js'; +import Token from 'polykey/tokens/Token.js'; +import * as keysUtils from 'polykey/keys/utils/index.js'; +import * as nodesUtils from 'polykey/nodes/utils.js'; +import * as testUtils from '../utils/index.js'; +import * as utils from '#utils/utils.js'; + +describe('commandAuthLogin', () => { + const password = 'password'; + const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]); + let dataDir: string; + let polykeyAgent: PolykeyAgent; + + beforeEach(async () => { + jest.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ result: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + dataDir = await fs.promises.mkdtemp( + path.join(globalThis.tmpDir, 'polykey-test-'), + ); + polykeyAgent = await PolykeyAgent.createPolykeyAgent({ + password, + options: { + nodePath: dataDir, + agentServiceHost: '127.0.0.1', + clientServiceHost: '127.0.0.1', + keys: { + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + }, + }, + logger: logger, + }); + }); + afterEach(async () => { + jest.restoreAllMocks(); + await polykeyAgent.stop(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + + test('should succeed with a valid compact JWT', async () => { + // Generate and sign token + const keyPair = keysUtils.generateKeyPair(); + const returnURL = 'test'; + const publicKey = keyPair.publicKey.toString('base64url'); + const token = Token.fromPayload({ + publicKey, + returnURL, + }); + token.signWithPrivateKey(keyPair.privateKey); + const encodedToken = utils.jsonToCompactJWT(token.toEncoded()); + + // Use token to try and login + const command = ['auth', 'login', '-np', dataDir, encodedToken]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + + // Check the received token + const fetchMock = globalThis.fetch as jest.MockedFunction; + expect(fetchMock).toHaveBeenCalled(); + const [url, options] = fetchMock.mock.lastCall!; + expect(url).toBe(returnURL); + expect(options).toBeDefined(); + expect(options!.method).toBe('POST'); + + // Reconstruct the token + expect(typeof options!.body).toBe('string'); + const responseBody: { token: string } = JSON.parse( + options!.body! as string, + ); + const receivedEncodedToken = responseBody.token; + const [header, payload, signature] = receivedEncodedToken.split('.'); + const receivedToken = Token.fromEncoded({ + payload: payload as TokenPayloadEncoded, + signatures: [ + { + protected: header as TokenProtectedHeaderEncoded, + signature: signature as TokenSignatureEncoded, + }, + ], + }); + + // Verify the incoming token. The nodeId is the node's public key. + const nodeId = nodesUtils.decodeNodeId(receivedToken.payload.nodeId); + expect(nodeId).toBeDefined(); + const nodeIdPublicKey = keysUtils.publicKeyFromNodeId(nodeId!); + expect(receivedToken.verifyWithPublicKey(nodeIdPublicKey)).toBeTrue(); + const sentTokenEncoded = receivedToken.payload.requestToken; + const sentToken = Token.fromEncoded(sentTokenEncoded); + expect(sentToken.verifyWithPublicKey(keyPair.publicKey)).toBeTrue(); + }); + + test.prop([fc.string()], { numRuns: 1 })( + 'should fail with an invalid JWT', + async (compactJWT) => { + // Use token to try and login + const command = ['auth', 'login', '-np', dataDir, compactJWT]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).not.toBe(0); + + // We should never even get to a point where the fetch was invoked + const fetchMock = globalThis.fetch as jest.MockedFunction; + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + test('should fail with incorrectly signed JWT', async () => { + // Generate and sign token with different key pairs + let keyPair = keysUtils.generateKeyPair(); + const publicKey = keyPair.publicKey.toString('base64url'); + keyPair = keysUtils.generateKeyPair(); + const returnURL = 'test'; + const token = Token.fromPayload({ + publicKey, + returnURL, + }); + token.signWithPrivateKey(keyPair.privateKey); + const encodedToken = utils.jsonToCompactJWT(token.toEncoded()); + + // Use token to try and login + const command = ['auth', 'login', '-np', dataDir, encodedToken]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).not.toBe(0); + + // We should never even get to a point where the fetch was invoked + const fetchMock = globalThis.fetch as jest.MockedFunction; + expect(fetchMock).not.toHaveBeenCalled(); + }); +});