보통 저장은 이 판이 만드는 꼴의 비밀 열쇠만 지운다 - 같은 머리글의 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 (관리자 지시 10/21, 15 알림, 데이터)

- 틈: _writeSecrets 가 머리글 (os. · dav. · ai. · sftp. · smb. · ftp.) 만 보고 지금 설정에 없는 열쇠를 지웠다. 새 판이 같은 머리글로
  새 종류의 열쇠를 쓰면 예전 판으로 되돌려 저장할 때 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽)
- SettingsStore.ownsSecret 을 정확한 꼴로: os.key · os.user · os.password · dav.servers ·
  dav.pw.<id> · sftp.pw|key|kp.<id> · smb.pw.<id> · ftp.pw.<id> · ai.key.<id> ([_secretsOf] 가 만드는 것과 같음).
  지우는 것은 이 꼴 중 그 서버 · 항목이 설정에서 없어지거나 비운 것만
- 초기화의 [저장된 실제 비밀번호 · API 키] (clearForeignSecrets) 는 그대로 sec. 밖을 모두 지움
- 시험 settings_store_test: 모르는 꼴 (dav.token.d1 · sftp.cert.s1 · nfs.pw.n1) 은 보통 저장 · 다시 켜 저장 뒤에도 남음,
  서버 s1 을 지우면 s1 의 pw · key · kp 만 없어지고 s2 · 모르는 꼴 · sec. 은 그대로, clearForeignSecrets 는 3개 지우고 sec. 만 남김.
  관련 9파일 81개 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2
RyuJongJin 2026-10-21 01:09:18 +09:00
parent 889a92e919
commit 54eb692001
2 changed files with 44 additions and 4 deletions

View File

@ -1682,11 +1682,14 @@ class SettingsStore {
}); });
} }
/// 이 판의 설정이 맡는 안전 저장소 열쇠 (그 밖은 마스터 · 최상 (sec.) 이나 이 판이 모르는 것 - 새 판이 쓴 열쇠) /// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴).
static bool ownsSecret(String k) => /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를
k.startsWith('os.') || k.startsWith('dav.') || k.startsWith('ai.') || k.startsWith('sftp.') || k.startsWith('smb.') || k.startsWith('ftp.'); /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽).
/// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다
static bool ownsSecret(String k) => _osKeys.containsValue(k) || k == _davServers || _ownForm.hasMatch(k);
static final _ownForm = RegExp(r'^(dav\.pw|sftp\.pw|sftp\.key|sftp\.kp|smb\.pw|ftp\.pw|ai\.key)\..+$');
/// 관리자 판단 (10/21): 이 판이 모르는 비밀 값 (다른 판이 쓴 열쇠) 을 지운다 - 사용자가 초기화 창에서 /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서
/// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.
/// 지운 열쇠 수 /// 지운 열쇠 수
Future<int> clearForeignSecrets() async { Future<int> clearForeignSecrets() async {

View File

@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
void main() { void main() {
@ -25,6 +28,40 @@ void main() {
expect((await SettingsStore(file).load()).homeUrl, 'https://4.example'); expect((await SettingsStore(file).load()).homeUrl, 'https://4.example');
}); });
test('관리자 지시 (10/21): 보통 저장은 이 판이 만드는 꼴의 열쇠만 지운다 - 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 · 지운 서버의 아는 열쇠만', () async {
final tmp = Directory.systemTemp.createTempSync('jj_secret_form_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore()
..values['dav.token.d1'] = 'new-kind' // 새 판이 같은 머리글로 쓴 새 종류
..values['sftp.cert.s1'] = 'new-kind-2'
..values['nfs.pw.n1'] = 'new-server-kind'
..values['sec.master'] = 'hash';
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = await store.load()
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')]
..sftpServers = [
const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'),
const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'),
];
await store.save(s);
expect(
{for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]},
{'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'});
// 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로
s.sftpServers = [s.sftpServers.last];
await store.save(s);
expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']);
expect(secrets.values['sftp.pw.s2'], 'sp2');
expect((secrets.values['dav.token.d1'], secrets.values['nfs.pw.n1'], secrets.values['sec.master']), ('new-kind', 'new-server-kind', 'hash'));
// 다시 읽어 저장해도 (새 SettingsStore = 앱을 다시 켬) 모르는 꼴은 남음
final again = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await again.save(await again.load());
expect((secrets.values['dav.token.d1'], secrets.values['sftp.cert.s1']), ('new-kind', 'new-kind-2'));
// 초기화의 [저장된 실제 비밀번호 · API 키] 쪽 (clearForeignSecrets) 은 모르는 꼴도 지우고 sec. 만 남김
expect(await again.clearForeignSecrets(), 3);
expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']);
});
test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async {
final store = SettingsStore(file); final store = SettingsStore(file);
await store.save(AppSettings()..homeUrl = 'https://good.example'); await store.save(AppSettings()..homeUrl = 'https://good.example');