diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ce84900..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -test/results -node_modules -package-lock.json diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index ae756d7..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "extends": [ - "airbnb-base" - ], - "rules": { - "func-names": "off", - - "prefer-object-spread": "off", - "import/prefer-default-export": "off", - "import/extensions": "off", - "import/no-unresolved": "off", - "import/no-extraneous-dependencies": "off", - "class-methods-use-this": "off", - "no-await-in-loop": "off", - "eqeqeq": "off", - "no-restricted-syntax": "off", - "max-len": ["error", { "code": 120 }] - }, - "env": { - "node": true - }, - "overrides": [ - { - "files": [ - "*.{ts,tsx}" - ], - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "airbnb-typescript/base" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json", - "ecmaVersion": 2020, - "tsconfigRootDir": "." - }, - "rules": { - "import/prefer-default-export": "off", - - // BEGIN ERRORS - "@typescript-eslint/no-non-null-assertion": "error", - "@typescript-eslint/no-require-imports": "error", - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/restrict-template-expressions": [ - "error", - { "allowNumber": true, "allowBoolean": true, "allowNullish": true } - ], - "@typescript-eslint/restrict-plus-operands": [ - "error", - { "skipCompoundAssignments": true } - ], - - // This prevents declarations like `interface ISomething { ... }` - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": "interface", - "format": ["PascalCase"], - "custom": { - "regex": "^I[A-Z]", - "match": false - } - } - ], - - // BEGIN DISABLED. Some of these are disabled just to prevent warnings. - "@typescript-eslint/explicit-member-accessibility": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/unbound-method": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-redundant-type-constituents": "off" - } - }, - { - "files": [ - "test/**/*.{ts,tsx}", - "*.test.{ts,tsx}" - ], - "plugins": [ - "@typescript-eslint" - ], - "rules": { - // Allow ! in tests. - "@typescript-eslint/no-non-null-assertion": "off", - - // Allow tests to create odd string templates if they want. - "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/ban-ts-comment": "off" - } - } - ] -} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4fb93e0..1875e53 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 6790953..e999464 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -6,20 +6,20 @@ jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v5 with: - node-version: 18.x + node-version: 20.x - run: npm i - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose main: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: - node-version: 18.x + node-version: 20.x - run: npm i - run: npm test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 351e18b..6766146 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,18 +5,23 @@ on: branches: - 'master' +permissions: + id-token: write # Required for OIDC + contents: read jobs: main: runs-on: ubuntu-latest environment: DeployEnv steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v5 with: - node-version: 18 + node-version: 20 + - name: Update npm + run: npm install -g npm@latest - run: npm i - run: npm test - name: Release diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..241d739 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; + +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import { configs, plugins } from 'eslint-config-airbnb-extended'; +import { type Linter } from 'eslint'; + +const gitignorePath = path.resolve('.', '.gitignore'); + +const jsConfig = [ + // ESLint Recommended Rules + { + name: 'js/config', + ...js.configs.recommended, + }, + // Stylistic Plugin + plugins.stylistic, + // Typescript Plugin + plugins.typescriptEslint, + // Import X Plugin + plugins.importX, + // Airbnb Base Recommended Config + ...configs.base.recommended, +]; + +const nodeConfig = [ + // Node Plugin + plugins.node, + // Airbnb Node Recommended Config + ...configs.node.recommended, +]; + +const typescriptConfig = [ + // TypeScript ESLint Plugin + plugins.typescriptEslint, + // Airbnb Base TypeScript Config + ...configs.base.typescript, +]; + +// Equivalent to airbnb-base +const config: Linter.Config[] = [ + // Ignore .gitignore files/folder in eslint + includeIgnoreFile(gitignorePath), + // Javascript Config + ...jsConfig, + // Node Config + ...nodeConfig, + // TypeScript Config + ...typescriptConfig, + { + rules: { + 'func-names': 'off', + 'prefer-object-spread': 'off', + 'import-x/prefer-default-export': 'off', + 'import-x/extensions': 'off', + 'import-x/no-unresolved': 'off', + 'import-x/no-extraneous-dependencies': 'off', + 'class-methods-use-this': 'off', + 'no-await-in-loop': 'off', + eqeqeq: 'off', + 'no-restricted-syntax': 'off', + '@stylistic/max-len': ['error', { code: 200 }], + }, + }, + { + files: ['**/*.ts'], + rules: { + // BEGIN ERRORS + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { allowNumber: true, allowBoolean: true, allowNullish: true }, + ], + '@typescript-eslint/restrict-plus-operands': [ + 'error', + { skipCompoundAssignments: true }, + ], + + // This prevents declarations like `interface ISomething { ... }` + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'interface', + format: ['PascalCase'], + custom: { + regex: '^I[A-Z]', + match: false, + }, + }, + ], + + // BEGIN DISABLED. Some of these are disabled just to prevent warnings. + '@typescript-eslint/explicit-member-accessibility': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + }, + }, + { + files: [ + 'test/**/*.{ts,tsx}', + '*.test.{ts,tsx}', + ], + rules: { + // Allow ! in tests. + '@typescript-eslint/no-non-null-assertion': 'off', + + // Allow tests to create odd string templates if they want. + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + }, + }, +]; + +export default config; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 73f25a8..0000000 --- a/jest.config.js +++ /dev/null @@ -1,33 +0,0 @@ -const inspector = require('inspector'); - -// If we are debugging then extend the timeout to max value, otherwise use the default. -const testTimeout = inspector.url() ? 1e8 : undefined; - -module.exports = { - testEnvironment: 'node', - transform: { - '^.+\\.tsx?$': '@swc/jest', - }, - collectCoverageFrom: ['/src/**/*.ts'], - collectCoverage: true, - coverageThreshold: { - global: { - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - }, - // setupFilesAfterEnv: ['/test/unit/utils/awsSetup.js'], - coveragePathIgnorePatterns: ['/test/', '/node_modules/'], - clearMocks: true, - resetMocks: true, - restoreMocks: true, - testMatch: [ - '/test/unit/**/*.test.js', - '/test/unit/**/*.test.ts', - ], - verbose: true, - maxWorkers: 1, - testTimeout, -}; diff --git a/package.json b/package.json index 85767b2..d57fc9f 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "src/**/*.d.ts" ], "engines": { - "node": ">=14", - "npm": ">=2.14.7" + "node": ">=20" }, "scripts": { "lint": "eslint . --cache", @@ -35,25 +34,27 @@ "@aws-sdk/util-dynamodb": "^3.137.0", "@commitlint/cli": "^17.8.0", "@commitlint/config-conventional": "^17.8.0", + "@eslint/compat": "^1.3.2", + "@eslint/js": "^9.35.0", + "@stylistic/eslint-plugin": "^3.1.0", "@types/debug": "^4.1.9", - "@types/node": "^18.18.5", - "@typescript-eslint/eslint-plugin": "^6.7.5", - "@typescript-eslint/parser": "^6.7.5", - "@vitest/coverage-v8": "^0.34.6", - "aws-sdk-client-mock": "^3.0.0", + "@types/node": "^22.18.1", + "@vitest/coverage-v8": "^3.2.4", + "aws-sdk-client-mock": "^3.1.0", "conventional-changelog-conventionalcommits": "^7.0.2", - "eslint": "^8.51.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-plugin-chai-expect": "^3.0.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jest": "^27.4.2", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", + "eslint": "^9.35.0", + "eslint-config-airbnb-extended": "^2.2.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-n": "^17.21.3", "husky": "^8.0.3", + "jiti": "^2.5.1", "lint-staged": "^15.0.1", - "typescript": "^5.2.2", - "vitest": "^0.34.6" + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", + "vitest": "^3.2.4" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.137.0", diff --git a/src/index.ts b/src/index.ts index 5960980..40c10c2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,7 @@ export class CredStash { async incrementVersion(opts: NameOpts) { const rawVersion = await this.getHighestVersion(opts); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-template-expression if (`${rawVersion}`.match(/^[0-9]+$/)) { const version = Number.parseInt(rawVersion, 10) + 1; return paddedInt(version); diff --git a/src/lib/aesCredstash.ts b/src/lib/aesCredstash.ts index 732f33d..4b514bf 100644 --- a/src/lib/aesCredstash.ts +++ b/src/lib/aesCredstash.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, createHmac } from 'crypto'; +import { createCipheriv, createDecipheriv, createHmac } from 'node:crypto'; import { DEFAULT_DIGEST } from '../defaults'; import { KeyService } from './keyService'; import { SecretRecord } from '../types'; @@ -14,13 +14,13 @@ const getHmacKey = ( .digest() .toString('hex'); -const halveKey = (key: Uint8Array) => { - const half = Math.floor(key.length / 2); - return { - dataKey: key.slice(0, half), - hmacKey: key.slice(half), - }; -}; +const halveKey = ( + key: Uint8Array, + splitPoint = Math.floor(key.length / 2), +) => ({ + dataKey: key.slice(0, splitPoint), + hmacKey: key.slice(splitPoint), +}); const sealAesCtr = ( plaintext: string, @@ -38,6 +38,29 @@ const sealAesCtr = ( }; }; +const isOpenAesCtr = ( + key: Uint8Array, + ciphertext: Uint8Array, + expectedHmac: string, + digestMethod: string, + tryLegacy = false, +) => { + const keyLen = tryLegacy ? 32 : undefined; + + const { dataKey, hmacKey } = halveKey(key, keyLen); + const bits = dataKey.length * 8; + const hmac = getHmacKey(hmacKey, ciphertext, digestMethod); + const isMatch = hmac === expectedHmac; + if (!tryLegacy && !isMatch) { + return isOpenAesCtr(key, ciphertext, expectedHmac, digestMethod, true); + } + return { + isMatch, + bits, + dataKey, + }; +}; + const openAesCtr = ( key: Uint8Array, nonce: Uint8Array, @@ -46,12 +69,11 @@ const openAesCtr = ( digestMethod: string, name: string, ) => { - const { dataKey, hmacKey } = halveKey(key); - const bits = dataKey.length * 8; - const hmac = getHmacKey(hmacKey, ciphertext, digestMethod); + const { bits, dataKey, isMatch } = isOpenAesCtr(key, ciphertext, expectedHmac, digestMethod); - if (hmac !== expectedHmac) { - throw new Error(`Computed HMAC on ${name} does not match stored HMAC`); + if (!isMatch) { + const message = `Computed HMAC on ${name} does not match stored HMAC`; + throw new Error(message); } const decryptor = createDecipheriv(`aes-${bits}-ctr`, dataKey, nonce); @@ -90,7 +112,7 @@ const getHmacAsString = (hmac: string | Uint8Array): string => ( /** * Decrypts secrets stored by `seal_aes_ctr_legacy`. - * Assumes that the plaintext is unicode (non-binary). + * Assumes that the plaintext is Unicode (non-binary). */ export const openAesCtrLegacy = async ( keyService: KeyService, diff --git a/src/lib/dynamoDb.ts b/src/lib/dynamoDb.ts index f2f6ce6..13223d5 100755 --- a/src/lib/dynamoDb.ts +++ b/src/lib/dynamoDb.ts @@ -121,7 +121,7 @@ export class DynamoDB { ({ LastEvaluatedKey } = next); } while (LastEvaluatedKey); - return curr as unknown as { Items: SecretRecord[]; }; + return curr as unknown as { Items: SecretRecord[] }; } async getAllSecretsAndVersions({ limit, tableName = DEFAULT_TABLE }: QueryOpts = {}) { @@ -143,7 +143,7 @@ export class DynamoDB { ({ LastEvaluatedKey } = next); } while (LastEvaluatedKey); - return curr as unknown as { Items: SecretRecord[]; }; + return curr as unknown as { Items: SecretRecord[] }; } getLatestVersion(opts: NameOpts) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2f6c98b..24dfee5 100755 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,6 +16,7 @@ export const sanitizeVersion = (version?: number | string, defaultVersion?: 0 | sanitized = paddedInt(sanitized); } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-template-expression sanitized = (sanitized === undefined) ? sanitized : `${sanitized}`; return sanitized; }; diff --git a/src/types.ts b/src/types.ts index 84e022c..b7714d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ export interface SecretRecord { version: string; digest?: string; contents: string; - hmac: string | Uint8Array | { value: string | Uint8Array; }; + hmac: string | Uint8Array | { value: string | Uint8Array }; } export type KMSOpts = ConstructorParameters[0]; diff --git a/test/unit/getAllSecrets.test.ts b/test/unit/getAllSecrets.test.ts index b51088d..ba30c0d 100644 --- a/test/unit/getAllSecrets.test.ts +++ b/test/unit/getAllSecrets.test.ts @@ -6,7 +6,7 @@ import { GenerateDataKeyCommand, GenerateDataKeyResponse, KMSClient, } from '@aws-sdk/client-kms'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; import { item as item1, credStashKey as item2, StaticData } from './utils/encryption'; import { mockDocClient, mockKms } from './utils/awsSdk'; diff --git a/test/unit/lib/aesCredstash.test.ts b/test/unit/lib/aesCredstash.test.ts index ed454df..de45157 100644 --- a/test/unit/lib/aesCredstash.test.ts +++ b/test/unit/lib/aesCredstash.test.ts @@ -1,7 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { GenerateDataKeyCommand, DecryptCommand, KMSClient } from '@aws-sdk/client-kms'; import { mockKms } from '../utils/awsSdk'; -import { item } from '../utils/encryption'; +import { item, legacyItem } from '../utils/encryption'; import { KeyService } from '../../../src/lib/keyService'; import { sealAesCtrLegacy, openAesCtrLegacy } from '../../../src/lib/aesCredstash'; import { SecretRecord } from '../../../src/types'; @@ -54,3 +54,10 @@ test.each([ record.hmac = { value: uintArray }; await expect(openAesCtrLegacy(keyService, record)).resolves.toBe(item.plainText); }); + +test('will decrypt legacy values', async () => { + mockKms.on(GenerateDataKeyCommand).resolves(legacyItem.kms); + mockKms.on(DecryptCommand).resolves({ Plaintext: legacyItem.kms.Plaintext }); + const record: SecretRecord = Object.assign({}, legacyItem); + await expect(openAesCtrLegacy(keyService, record)).resolves.toBe(legacyItem.plainText); +}); diff --git a/test/unit/lib/dynamo.test.ts b/test/unit/lib/dynamo.test.ts index 4de50c6..f881051 100755 --- a/test/unit/lib/dynamo.test.ts +++ b/test/unit/lib/dynamo.test.ts @@ -4,7 +4,7 @@ import { expect, describe, } from 'vitest'; -import { randomUUID } from 'crypto'; +import { randomUUID } from 'node:crypto'; import { DescribeTableCommand, diff --git a/test/unit/lib/keyService.test.ts b/test/unit/lib/keyService.test.ts index 7c0dc54..81f150d 100644 --- a/test/unit/lib/keyService.test.ts +++ b/test/unit/lib/keyService.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { randomBytes, randomUUID } from 'crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { DecryptCommand, InvalidCiphertextException, diff --git a/test/unit/utils/encryption.ts b/test/unit/utils/encryption.ts index 07fc0b7..08c732a 100644 --- a/test/unit/utils/encryption.ts +++ b/test/unit/utils/encryption.ts @@ -8,7 +8,7 @@ export interface StaticData extends SecretRecord { hmacMd5?: string; hmacSha256?: string; hmacSha512?: string; - kms?: { Plaintext: Uint8Array; CiphertextBlob: Uint8Array; }; + kms?: { Plaintext: Uint8Array; CiphertextBlob: Uint8Array }; } export const item: StaticData = { @@ -35,6 +35,32 @@ export const item: StaticData = { }, }; +export const legacyItem: StaticData = { + name: 'Death Star vulnerability', + digest: 'SHA256', + version: '0000000000000000001', + contents: 'qIwrvluzINIiY/6M', + key: 'NGZkZmQwZjktMDUzOC00MDUxLThjMmEtOWM4NTFhZDc3OTU3bF3jZ1Zj9+8Gv+WptFFAjtrCURVmpp/ShC3oihDXBI/i/6xc7FT7Ji4FaDFb' + + 'tQQdmHJgvF44CIC0WhDGFcPh/ImdvYZPuzFO6eNSwMo3kaU=', + hmac: '23071c4fb00012eccbfb8ba71197f01e5a6b2694b4ade55fcf6ebaaa2007d68b', + + plainText: 'Exhaust vent', + + hmacSha256: '', + hmacMd5: '', + hmacSha512: '', + + kms: { + get Plaintext() { + return Buffer.from('u4Jb5gRL1hPgn+XwKOz2jGxQ8L+D+cEAiZCGM67ySGA=', 'base64'); + }, + get CiphertextBlob() { + return Buffer.from('NGZkZmQwZjktMDUzOC00MDUxLThjMmEtOWM4NTFhZDc3OTU3bF3jZ1Zj9+8Gv+WptFFAjtrCURVmpp/ShC3' + + 'oihDXBI/i/6xc7FT7Ji4FaDFbtQQdmHJgvF44CIC0WhDGFcPh/ImdvYZPuzFO6eNSwMo3kaU=', 'base64'); + }, + }, +}; + export const credStashKey: StaticData = { name: 'some.secret.apiKey', contents: 'RPg3JNZPfZJZ80pq7qQ=', diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d7646dd --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,27 @@ +import inspector from 'node:inspector'; +import { defineConfig } from 'vitest/config'; + +const timeout = inspector.url() ? 1e8 : 5e3; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + enabled: true, + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*'], + clean: true, + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + testTimeout: timeout, + hookTimeout: timeout, + teardownTimeout: timeout, + restoreMocks: true, + mockReset: true, + }, +});