-
Notifications
You must be signed in to change notification settings - Fork 6
fix(cli): harden context graph ids and keystores #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ac5aa3f
4547d89
b4f5ff6
ee87bb3
c887970
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,15 +38,60 @@ let SCRYPT_N = 2 ** 18; | |||||||||||||||||||
| const SCRYPT_R = 8; | ||||||||||||||||||||
| const SCRYPT_P = 1; | ||||||||||||||||||||
| const DKLEN = 32; | ||||||||||||||||||||
| const MIN_SCRYPT_N = 2 ** 15; | ||||||||||||||||||||
| const MIN_SCRYPT_R = 8; | ||||||||||||||||||||
| const MIN_SCRYPT_P = 1; | ||||||||||||||||||||
| const MAX_SCRYPT_MEMORY_BYTES = 256 * 1024 * 1024; | ||||||||||||||||||||
| const MAX_SCRYPT_P = 16; | ||||||||||||||||||||
| const MIN_SALT_BYTES = 16; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** @internal Allow tests to use lighter scrypt params to avoid memory limits */ | ||||||||||||||||||||
| export function _setScryptN(n: number) { SCRYPT_N = n; } | ||||||||||||||||||||
| export function _setScryptN(n: number) { | ||||||||||||||||||||
| const estimatedMemoryBytes = 128 * n * SCRYPT_R; | ||||||||||||||||||||
| if (!Number.isSafeInteger(n) || !isPowerOfTwo(n) || n < MIN_SCRYPT_N || estimatedMemoryBytes > MAX_SCRYPT_MEMORY_BYTES) { | ||||||||||||||||||||
| throw new Error('Unsupported scrypt N for keystore encryption'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| SCRYPT_N = n; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function deriveKey(passphrase: string, salt: Buffer): Buffer { | ||||||||||||||||||||
| function isPowerOfTwo(value: number): boolean { | ||||||||||||||||||||
| return Number.isInteger(value) && value > 0 && Number.isInteger(Math.log2(value)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function assertSafeKdfParams(kdfparams: EncryptedKeystore['crypto']['kdfparams']): void { | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Issue: decryption now rejects
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Symmetry already holds because
Every keystore the writer can emit decrypts under the reader's floor. Closing as not-a-bug; the cross-check is enforced by |
||||||||||||||||||||
| if (!Number.isSafeInteger(kdfparams.n) || !isPowerOfTwo(kdfparams.n) || kdfparams.n < MIN_SCRYPT_N) { | ||||||||||||||||||||
| throw new Error('KDF parameters below minimum: scrypt N too low'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (!Number.isSafeInteger(kdfparams.r) || kdfparams.r < MIN_SCRYPT_R) { | ||||||||||||||||||||
| throw new Error('KDF parameters below minimum: scrypt r too low'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (!Number.isSafeInteger(kdfparams.p) || kdfparams.p < MIN_SCRYPT_P) { | ||||||||||||||||||||
| throw new Error('KDF parameters below minimum: scrypt p too low'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const estimatedMemoryBytes = 128 * kdfparams.n * kdfparams.r; | ||||||||||||||||||||
| if (!Number.isSafeInteger(estimatedMemoryBytes) || estimatedMemoryBytes > MAX_SCRYPT_MEMORY_BYTES) { | ||||||||||||||||||||
| throw new Error('Unsupported keystore KDF parameters: scrypt memory cost too high'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (kdfparams.p > MAX_SCRYPT_P) { | ||||||||||||||||||||
| throw new Error('Unsupported keystore KDF parameters: scrypt p too high'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (kdfparams.dklen !== DKLEN) { | ||||||||||||||||||||
| throw new Error(`Invalid dklen: dklen must be ${DKLEN}`); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if (!/^[0-9a-fA-F]+$/.test(kdfparams.salt) || kdfparams.salt.length % 2 !== 0 || kdfparams.salt.length < MIN_SALT_BYTES * 2) { | ||||||||||||||||||||
| throw new Error(`KDF parameters below minimum: salt too short (minimum ${MIN_SALT_BYTES} bytes)`); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function deriveKey( | ||||||||||||||||||||
| passphrase: string, | ||||||||||||||||||||
| salt: Buffer, | ||||||||||||||||||||
| params: Pick<EncryptedKeystore['crypto']['kdfparams'], 'n' | 'r' | 'p' | 'dklen'>, | ||||||||||||||||||||
| ): Buffer { | ||||||||||||||||||||
| return scryptSync(passphrase, salt, DKLEN, { | ||||||||||||||||||||
| N: SCRYPT_N, | ||||||||||||||||||||
| r: SCRYPT_R, | ||||||||||||||||||||
| p: SCRYPT_P, | ||||||||||||||||||||
| N: params.n, | ||||||||||||||||||||
| r: params.r, | ||||||||||||||||||||
| p: params.p, | ||||||||||||||||||||
| maxmem: 256 * 1024 * 1024, | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
@@ -56,7 +101,12 @@ export async function encryptKeystore( | |||||||||||||||||||
| passphrase: string, | ||||||||||||||||||||
| ): Promise<EncryptedKeystore> { | ||||||||||||||||||||
| const salt = randomBytes(32); | ||||||||||||||||||||
| const key = deriveKey(passphrase, salt); | ||||||||||||||||||||
| const key = deriveKey(passphrase, salt, { | ||||||||||||||||||||
| n: SCRYPT_N, | ||||||||||||||||||||
| r: SCRYPT_R, | ||||||||||||||||||||
| p: SCRYPT_P, | ||||||||||||||||||||
| dklen: DKLEN, | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| const iv = randomBytes(12); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const cipher = createCipheriv('aes-256-gcm', key, iv); | ||||||||||||||||||||
|
|
@@ -96,8 +146,9 @@ export async function decryptKeystore( | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const { kdfparams } = keystore.crypto; | ||||||||||||||||||||
| assertSafeKdfParams(kdfparams); | ||||||||||||||||||||
| const salt = Buffer.from(kdfparams.salt, 'hex'); | ||||||||||||||||||||
| const key = deriveKey(passphrase, salt); | ||||||||||||||||||||
| const key = deriveKey(passphrase, salt, kdfparams); | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Bug: |
||||||||||||||||||||
|
|
||||||||||||||||||||
| const iv = Buffer.from(keystore.crypto.iv, 'hex'); | ||||||||||||||||||||
| const tag = Buffer.from(keystore.crypto.tag, 'hex'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { isValidContextGraphId } from '../src/daemon/http-utils.js'; | ||
|
|
||
| describe('isValidContextGraphId', () => { | ||
| it('rejects traversal path segments', () => { | ||
| for (const id of [ | ||
| '../etc/passwd', | ||
| '../../root', | ||
| './../_private', | ||
| 'legit-cg/../../other-cg', | ||
| 'legit-cg/%2e%2e/other-cg', | ||
| ]) { | ||
| expect(isValidContextGraphId(id)).toBe(false); | ||
| } | ||
| }); | ||
|
|
||
| it('keeps existing slug, DID, URN, and URL-style identifiers valid', () => { | ||
| for (const id of [ | ||
| 'devnet-test', | ||
| 'did:dkg:context-graph:devnet-test', | ||
| 'urn:dkg:project:smart-contracts', | ||
| 'https://example.org/context-graphs/devnet-test', | ||
| 'agent@example.org/context.graph-v1', | ||
| ]) { | ||
| expect(isValidContextGraphId(id)).toBe(true); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -122,6 +122,9 @@ export function validateContextGraphId(id: string): { valid: boolean; reason?: s | |
| if (!id || id.length === 0) return { valid: false, reason: 'Context graph ID cannot be empty' }; | ||
| if (id.length > 256) return { valid: false, reason: 'Context graph ID exceeds 256 characters' }; | ||
| if (!/^[\w:/.@\-]+$/.test(id)) return { valid: false, reason: 'Context graph ID contains disallowed characters (allowed: alphanumeric, _, :, /, ., @, -)' }; | ||
| if (id.split('/').some((segment) => segment === '.' || segment === '..')) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Bug: This only rejects literal
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re-verified empirically against the head of this branch (commit The whitelist regex |
||
| return { valid: false, reason: 'Context graph ID cannot contain path traversal segments' }; | ||
| } | ||
| return { valid: true }; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Bug: This guard never sees malformed percent-encoding, because
decodeURIComponent(addParticipantMatch[1])can throw before validation runs. The daemon's top-level error handler maps thatURIErrorto 500, so these new path-param routes are still crashable with a bad%sequence instead of returning 400. Use the existingsafeDecodeURIComponent(...)helper (or wrap the decode intry/catch) beforevalidateRequiredContextGraphId, and apply the same fix to the other{id}handlers added in this PR.