Skip to content

Commit d8f6aa9

Browse files
committed
fix: harden private key file handling
1 parent 0c5fa5b commit d8f6aa9

5 files changed

Lines changed: 112 additions & 27 deletions

File tree

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: nit
33
description: Git for agent identity — one identity, any apps
44
metadata:
5-
version: 0.6.29
5+
version: 0.6.30
66
---
77

88
# nit — Git for Agent Identity

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@newtype-ai/nit",
3-
"version": "0.6.29",
3+
"version": "0.6.30",
44
"description": "Version control for agent cards",
55
"type": "module",
66
"bin": {

src/identity.ts

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
verify,
2020
type KeyObject,
2121
} from 'node:crypto';
22-
import { promises as fs } from 'node:fs';
22+
import { constants as fsConstants, promises as fs } from 'node:fs';
23+
import type { FileHandle } from 'node:fs/promises';
2324
import { join } from 'node:path';
2425
import { validateAgentId } from './validation.js';
2526

@@ -39,6 +40,10 @@ function base64ToBase64url(b64: string): string {
3940

4041
const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
4142
const KEYPAIR_CHECK_MESSAGE = Buffer.from('nit identity key check', 'utf-8');
43+
const PRIVATE_KEY_MODE = 0o600;
44+
const IDENTITY_DIR_MODE = 0o700;
45+
const POSIX_PLATFORM = process.platform !== 'win32';
46+
const PRIVATE_KEY_OPEN_FLAGS = fsConstants.O_RDONLY | (POSIX_PLATFORM ? fsConstants.O_NOFOLLOW : 0);
4247

4348
function decodeRawKey(value: string, label: string): Buffer {
4449
if (!BASE64_RE.test(value)) {
@@ -99,7 +104,8 @@ export async function generateKeypair(
99104
nitDir: string,
100105
): Promise<{ publicKey: string; privateKey: string }> {
101106
const identityDir = join(nitDir, 'identity');
102-
await fs.mkdir(identityDir, { recursive: true });
107+
await fs.mkdir(identityDir, { recursive: true, mode: IDENTITY_DIR_MODE });
108+
await restrictIdentityDir(identityDir);
103109

104110
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
105111

@@ -116,13 +122,67 @@ export async function generateKeypair(
116122

117123
await fs.writeFile(pubPath, pubBase64 + '\n', 'utf-8');
118124
await fs.writeFile(keyPath, privBase64 + '\n', {
119-
mode: 0o600,
125+
mode: PRIVATE_KEY_MODE,
120126
encoding: 'utf-8',
121127
});
128+
await restrictPrivateKeyFile(keyPath);
122129

123130
return { publicKey: pubBase64, privateKey: privBase64 };
124131
}
125132

133+
async function restrictIdentityDir(identityDir: string): Promise<void> {
134+
if (!POSIX_PLATFORM) return;
135+
try {
136+
await fs.chmod(identityDir, IDENTITY_DIR_MODE);
137+
} catch {
138+
// Directory mode is a best-effort hardening step; key file mode is enforced.
139+
}
140+
}
141+
142+
async function restrictPrivateKeyFile(keyPath: string): Promise<void> {
143+
const handle = await fs.open(keyPath, PRIVATE_KEY_OPEN_FLAGS);
144+
try {
145+
const stat = await handle.stat();
146+
if (!stat.isFile()) {
147+
throw new Error('Private key must be a regular file.');
148+
}
149+
if (POSIX_PLATFORM && (stat.mode & 0o077) !== 0) {
150+
await handle.chmod(PRIVATE_KEY_MODE);
151+
}
152+
} finally {
153+
await handle.close();
154+
}
155+
}
156+
157+
async function readPrivateKeyBase64(nitDir: string): Promise<string> {
158+
const keyPath = join(nitDir, 'identity', 'agent.key');
159+
let handle: FileHandle | null = null;
160+
try {
161+
handle = await fs.open(keyPath, PRIVATE_KEY_OPEN_FLAGS);
162+
const stat = await handle.stat();
163+
if (!stat.isFile()) {
164+
throw new Error('Private key must be a regular file.');
165+
}
166+
if (POSIX_PLATFORM && (stat.mode & 0o077) !== 0) {
167+
await handle.chmod(PRIVATE_KEY_MODE);
168+
}
169+
return (await handle.readFile('utf-8')).trim();
170+
} catch (err) {
171+
const code = (err as NodeJS.ErrnoException).code;
172+
if (code === 'ENOENT') {
173+
throw new Error(
174+
'Private key not found at .nit/identity/agent.key. Regenerate with `nit init`.',
175+
);
176+
}
177+
if (code === 'ELOOP') {
178+
throw new Error('Private key must be a regular file, not a symlink.');
179+
}
180+
throw err;
181+
} finally {
182+
await handle?.close();
183+
}
184+
}
185+
126186
// ---------------------------------------------------------------------------
127187
// Key loading
128188
// ---------------------------------------------------------------------------
@@ -151,16 +211,7 @@ export async function loadPublicKey(nitDir: string): Promise<string> {
151211
*/
152212
export async function loadPrivateKey(nitDir: string): Promise<KeyObject> {
153213
const pubBase64 = await loadPublicKey(nitDir);
154-
const keyPath = join(nitDir, 'identity', 'agent.key');
155-
156-
let privBase64: string;
157-
try {
158-
privBase64 = (await fs.readFile(keyPath, 'utf-8')).trim();
159-
} catch {
160-
throw new Error(
161-
'Private key not found at .nit/identity/agent.key. Regenerate with `nit init`.',
162-
);
163-
}
214+
const privBase64 = await readPrivateKeyBase64(nitDir);
164215

165216
decodeRawKey(pubBase64, 'Public key');
166217
decodeRawKey(privBase64, 'Private key');
@@ -190,15 +241,7 @@ export async function loadRawKeyPair(nitDir: string): Promise<Uint8Array> {
190241
*/
191242
export async function loadPrivateSeed(nitDir: string): Promise<Buffer> {
192243
const pubBase64 = await loadPublicKey(nitDir);
193-
const keyPath = join(nitDir, 'identity', 'agent.key');
194-
let privBase64: string;
195-
try {
196-
privBase64 = (await fs.readFile(keyPath, 'utf-8')).trim();
197-
} catch {
198-
throw new Error(
199-
'Private key not found at .nit/identity/agent.key. Regenerate with `nit init`.',
200-
);
201-
}
244+
const privBase64 = await readPrivateKeyBase64(nitDir);
202245
const privateSeed = decodeRawKey(privBase64, 'Private key');
203246
const privateKey = privateKeyObjectFromRaw(pubBase64, privBase64);
204247
assertKeypairMatches(pubBase64, privateKey);

tests/local-regression.test.mjs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import assert from 'node:assert/strict';
2-
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
statSync,
9+
symlinkSync,
10+
unlinkSync,
11+
writeFileSync,
12+
realpathSync,
13+
} from 'node:fs';
314
import { tmpdir } from 'node:os';
415
import { dirname, join, resolve } from 'node:path';
516
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -479,6 +490,37 @@ test('identity loading rejects mismatched agent id and private key', async () =>
479490
);
480491
});
481492

493+
test('private key loading repairs broad file permissions', async () => {
494+
if (process.platform === 'win32') return;
495+
const cwd = workspace('nit-identity-perms-');
496+
initWorkspace(cwd);
497+
const api = await import(pathToFileURL(join(repoRoot, 'dist', 'index.js')).href);
498+
const keyPath = join(cwd, '.nit', 'identity', 'agent.key');
499+
500+
chmodSync(keyPath, 0o644);
501+
await api.sign('hello', { projectDir: cwd });
502+
503+
assert.equal(statSync(keyPath).mode & 0o777, 0o600);
504+
});
505+
506+
test('private key loading rejects symlinked key files', async () => {
507+
if (process.platform === 'win32') return;
508+
const cwd = workspace('nit-identity-symlink-');
509+
initWorkspace(cwd);
510+
const api = await import(pathToFileURL(join(repoRoot, 'dist', 'index.js')).href);
511+
const keyPath = join(cwd, '.nit', 'identity', 'agent.key');
512+
const targetPath = join(cwd, 'agent-key-target');
513+
514+
writeFileSync(targetPath, readFileSync(keyPath, 'utf8'), 'utf8');
515+
unlinkSync(keyPath);
516+
symlinkSync(targetPath, keyPath);
517+
518+
await assert.rejects(
519+
() => api.sign('hello', { projectDir: cwd }),
520+
/Private key must be a regular file, not a symlink/,
521+
);
522+
});
523+
482524
test('init uses Newtype as the default nit skill source', async () => {
483525
const cwd = workspace('nit-skill-default-');
484526
const api = await import(pathToFileURL(join(repoRoot, 'dist', 'index.js')).href);

0 commit comments

Comments
 (0)