@@ -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' ;
2324import { join } from 'node:path' ;
2425import { validateAgentId } from './validation.js' ;
2526
@@ -39,6 +40,10 @@ function base64ToBase64url(b64: string): string {
3940
4041const BASE64_RE = / ^ (?: [ A - Z a - z 0 - 9 + / ] { 4 } ) * (?: [ A - Z a - z 0 - 9 + / ] { 2 } = = | [ A - Z a - z 0 - 9 + / ] { 3 } = ) ? $ / ;
4142const 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
4348function 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 */
152212export 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 */
191242export 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 ) ;
0 commit comments