sotashimozono_obsidian-remo.../plugin/tests/SshKeyGen.test.ts
Souta cda3dac5d9 fix: address PR-222 review comments
Medium fixes (must-have):
- M1: OnboardingModal.onClose now catches markCompleted errors with
  logger.error instead of silently swallowing them (rejected save
  would have silently re-opened the wizard on every launch).
- M2: Coalesce double saveSettings on save-and-test by replacing the
  two-callback constructor (onSave + markCompleted) with a single
  onFinish({profile?, dismissOnboarding}) callback. main.ts now
  pushes the profile + flips the flag in one disk write.

Low fixes:
- L3: Hardcoded fallback filename 'id_ed25519_obsidian_remote'
  lifted to constants.ONBOARDING_FALLBACK_KEY_FILENAME.
- L4/L5: SshKeyGen.generateEd25519KeyPair now uses fs.open(path,
  'wx', 0o600) for atomic exclusive-create — closes the TOCTOU
  window between the previous existsSync check and writeFile, and
  guarantees the mode bit is set at create time (writeFile mode
  was a no-op when the path already existed). The OnboardingModal
  existsSync guard is dropped; EEXIST is translated to a clearer
  Notice. New unit test asserts pre-existing content survives.
- L7: Test for "rejects non-ed25519 SPKI" no longer generates a
  real RSA-2048 key (~100 ms); uses a hand-rolled 64-byte buffer
  that triggers the same length guard.

L6 reconsidered: ProfileForm.ts:31 uses node 'crypto' for randomUUID
(project convention) and the obsidianmd/prefer-active-doc lint rule
fires on globalThis. Kept node crypto for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:26 +09:00

118 lines
5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
generateEd25519KeyPair,
pemPublicKeyToOpenSshEd25519,
} from '../src/ssh/SshKeyGen';
describe('SshKeyGen', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remote-ssh-keygen-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
});
describe('pemPublicKeyToOpenSshEd25519', () => {
it('round-trips a freshly-generated ed25519 PEM into OpenSSH format', () => {
// Generate a real PEM in-test so we exercise the actual SPKI shape
// produced by Node, not a hand-rolled fixture that could drift.
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const ssh = pemPublicKeyToOpenSshEd25519(publicKey, 'test@example');
const parts = ssh.split(' ');
expect(parts).toHaveLength(3);
expect(parts[0]).toBe('ssh-ed25519');
expect(parts[2]).toBe('test@example');
// The base64-decoded wire format must start with a 4-byte length
// prefix containing 11 (length of "ssh-ed25519"), then the
// algorithm name, then a 4-byte length of 32 (raw key length).
const wire = Buffer.from(parts[1], 'base64');
expect(wire.readUInt32BE(0)).toBe(11);
expect(wire.subarray(4, 4 + 11).toString('utf8')).toBe('ssh-ed25519');
expect(wire.readUInt32BE(4 + 11)).toBe(32);
expect(wire.length).toBe(4 + 11 + 4 + 32);
});
it('throws when given a non-ed25519 SPKI key', () => {
// Hand-rolled non-44-byte body so we exercise the length guard
// without the ~100 ms cost of a real RSA-2048 generation. The
// exact bytes don't matter — only that the DER body length
// doesn't equal 44 (12-byte ed25519 SPKI prefix + 32-byte key).
const fakeRsaPem =
'-----BEGIN PUBLIC KEY-----\n' +
Buffer.alloc(64, 0).toString('base64') + '\n' +
'-----END PUBLIC KEY-----\n';
expect(() => pemPublicKeyToOpenSshEd25519(fakeRsaPem, 'x@y'))
.toThrow(/unexpected SPKI ed25519 length/);
});
});
describe('generateEd25519KeyPair', () => {
it('writes private + public key files with correct content + mode', async () => {
const keyPath = path.join(tmpDir, 'id_ed25519_test');
const result = await generateEd25519KeyPair(keyPath, 'me@host');
expect(fs.existsSync(keyPath)).toBe(true);
expect(fs.existsSync(keyPath + '.pub')).toBe(true);
// Private key: PEM-wrapped PKCS#8 ed25519
const privBody = fs.readFileSync(keyPath, 'utf8');
expect(privBody).toMatch(/^-----BEGIN PRIVATE KEY-----/);
expect(privBody).toMatch(/-----END PRIVATE KEY-----\s*$/);
// Public key file: trailing newline, matches returned string
const pubBody = fs.readFileSync(keyPath + '.pub', 'utf8');
expect(pubBody).toBe(result.publicKey + '\n');
expect(pubBody.startsWith('ssh-ed25519 ')).toBe(true);
expect(pubBody.trim().endsWith('me@host')).toBe(true);
});
it('creates the parent directory if missing', async () => {
const keyPath = path.join(tmpDir, 'nested', 'subdir', 'id_test');
await generateEd25519KeyPair(keyPath, 'me@host');
expect(fs.existsSync(keyPath)).toBe(true);
});
it('refuses to overwrite an existing private key file (EEXIST)', async () => {
const keyPath = path.join(tmpDir, 'id_existing');
// Pre-create the path with arbitrary content so 'wx' rejects.
fs.writeFileSync(keyPath, 'pre-existing');
await expect(generateEd25519KeyPair(keyPath, 'x@y'))
.rejects.toMatchObject({ code: 'EEXIST' });
// Pre-existing content must survive — no truncation race.
expect(fs.readFileSync(keyPath, 'utf8')).toBe('pre-existing');
});
it('different invocations produce different keys', async () => {
const a = path.join(tmpDir, 'a');
const b = path.join(tmpDir, 'b');
const ra = await generateEd25519KeyPair(a, 'a@h');
const rb = await generateEd25519KeyPair(b, 'b@h');
// The wire-format base64 (parts[1]) must differ between distinct keys.
const aWire = ra.publicKey.split(' ')[1];
const bWire = rb.publicKey.split(' ')[1];
expect(aWire).not.toBe(bWire);
});
// Skip the perm check on Windows — fs mode bits don't have meaningful
// 0600 / 0644 semantics there; OpenSSH's strict-perms check also no-ops.
const itUnix = process.platform === 'win32' ? it.skip : it;
itUnix('writes private key with mode 0600 and public key with mode 0644', async () => {
const keyPath = path.join(tmpDir, 'id_perm_test');
await generateEd25519KeyPair(keyPath, 'p@h');
expect(fs.statSync(keyPath).mode & 0o777).toBe(0o600);
expect(fs.statSync(keyPath + '.pub').mode & 0o777).toBe(0o644);
});
});
});