Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 同期の設定の QR コードを読み取るときだけ使う。カメラの無いデバイスにも入れられるよう、必須にしない。 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

<application
android:allowBackup="false"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.github.aida0710.sshc;

/**
* Web 画面からのカメラの要求のうち、何を許すかの判断。Android API に依存しないため JVM テストで検証できる。
*
* カメラを使うのは、同期の設定の QR コードを読み取るときだけである。音声や保護されたメディアは
* 画面のどこも使わないので、映像だけの要求のほかは断る。
*/
final class CameraRequest {
/** android.webkit.PermissionRequest.RESOURCE_VIDEO_CAPTURE と同じ値。 */
static final String VIDEO_CAPTURE = "android.webkit.resource.VIDEO_CAPTURE";

private CameraRequest() {}

/** 要求が映像(カメラ)だけか。 */
static boolean asksOnlyForVideo(String[] resources) {
return resources != null && resources.length == 1 && VIDEO_CAPTURE.equals(resources[0]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import android.webkit.ServiceWorkerClient;
import android.webkit.ServiceWorkerController;
import android.webkit.ValueCallback;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
Expand Down Expand Up @@ -56,6 +57,7 @@ public final class MainActivity extends ComponentActivity {
private static final int SAVE_DESTINATION_REQUEST = 21;
private static final int NOTIFICATION_PERMISSION_REQUEST = 22;
private static final int FOLDER_CHOOSER_REQUEST = 23;
private static final int CAMERA_PERMISSION_REQUEST = 24;
private static final String NOTIFICATION_PERMISSION_ASKED = "notification_permission_asked";
/** ページの帯の夜と昼の色。web/src/index.css の --ui-toolbar と同期し、ページ読込前の色差を防ぐ。 */
private static final int CHROME_NIGHT = 0xFF161616;
Expand Down Expand Up @@ -166,6 +168,8 @@ public void setBackTakenByPage(boolean taken) {
private ChosenFolder chosenFolder;
private final TerminalNotifications terminalNotifications = new TerminalNotifications(this);
private boolean notificationPermissionRequestedByWeb;
// カメラの権限の答えを待っている、Web 画面からの要求。答えが出たら許すか断る。
private PermissionRequest pendingCameraRequest;
// 押された通知の tag。Web 画面が takeOpenedNotification で受け取るまで持つ。bridge の呼び出しは
// UI スレッドの外から来るので、読み書きは openedNotificationLock の中で行う。
private final Object openedNotificationLock = new Object();
Expand Down Expand Up @@ -351,10 +355,7 @@ private void showEntrance(String entrance) {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri target = request.getUrl();
if (engineOrigin != null
&& engineOrigin.getScheme().equals(target.getScheme())
&& engineOrigin.getHost().equals(target.getHost())
&& engineOrigin.getPort() == target.getPort()) return false;
if (isEngineOrigin(target)) return false;
if (request.isForMainFrame()
&& !"127.0.0.1".equals(target.getHost())
&& ("https".equals(target.getScheme()) || "http".equals(target.getScheme()))) {
Expand Down Expand Up @@ -417,6 +418,16 @@ public boolean onShowFileChooser(
}
return true;
}

@Override
public void onPermissionRequest(PermissionRequest request) {
answerCameraRequest(request);
}

@Override
public void onPermissionRequestCanceled(PermissionRequest request) {
if (pendingCameraRequest == request) pendingCameraRequest = null;
}
});

webView.loadUrl(entrance);
Expand Down Expand Up @@ -636,9 +647,50 @@ && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager
new TransferNotifications(this).show(status);
}

/** url が、表示中の engine と同じ origin(scheme・host・port)か。 */
private boolean isEngineOrigin(Uri url) {
return engineOrigin != null
&& engineOrigin.getScheme().equals(url.getScheme())
&& engineOrigin.getHost().equals(url.getHost())
&& engineOrigin.getPort() == url.getPort();
}

/**
* Web 画面(QR コードの読み取り)がカメラを求めたときに答える。engine の画面からの、映像だけの要求に限って
* 許す。Android のカメラの権限がまだ無ければ先に尋ね、答えが出るまで要求を持っておく。
*/
private void answerCameraRequest(PermissionRequest request) {
if (!isEngineOrigin(request.getOrigin()) || !CameraRequest.asksOnlyForVideo(request.getResources())) {
request.deny();
return;
}
if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
request.grant(request.getResources());
return;
}
if (pendingCameraRequest != null) pendingCameraRequest.deny();
pendingCameraRequest = request;
requestPermissions(new String[]{Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST);
}

private void finishCameraRequest(boolean granted) {
PermissionRequest request = pendingCameraRequest;
pendingCameraRequest = null;
if (request == null) return;
if (granted) {
request.grant(request.getResources());
} else {
request.deny();
}
}

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == CAMERA_PERMISSION_REQUEST) {
finishCameraRequest(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED);
return;
}
if (requestCode != NOTIFICATION_PERMISSION_REQUEST) return;
if (notificationPermissionRequestedByWeb) {
notificationPermissionRequestedByWeb = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.github.aida0710.sshc;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import org.junit.Test;

public class CameraRequestTest {
@Test
public void aRequestForTheCameraAloneIsForVideoOnly() {
assertTrue(CameraRequest.asksOnlyForVideo(new String[]{"android.webkit.resource.VIDEO_CAPTURE"}));
}

@Test
public void aRequestThatAlsoAsksForTheMicrophoneIsNotForVideoOnly() {
assertFalse(CameraRequest.asksOnlyForVideo(new String[]{
"android.webkit.resource.VIDEO_CAPTURE", "android.webkit.resource.AUDIO_CAPTURE"}));
}

@Test
public void aRequestForAnythingElseIsNotForVideoOnly() {
assertFalse(CameraRequest.asksOnlyForVideo(new String[]{"android.webkit.resource.AUDIO_CAPTURE"}));
assertFalse(CameraRequest.asksOnlyForVideo(new String[]{}));
assertFalse(CameraRequest.asksOnlyForVideo(null));
}
}
60 changes: 60 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,39 @@ paths:
"409": { $ref: "#/components/responses/Problem" }
"500": { $ref: "#/components/responses/Problem" }
"502": { $ref: "#/components/responses/Problem" }
/api/v1/sync/setup/export:
post:
operationId: exportSyncSetup
responses:
"200":
description: The configured target and its credentials sealed with the shared key
content:
application/json:
schema: { $ref: "#/components/schemas/SyncSetupExport" }
"401": { $ref: "#/components/responses/Problem" }
"403": { $ref: "#/components/responses/Problem" }
"409": { $ref: "#/components/responses/Problem" }
"500": { $ref: "#/components/responses/Problem" }
/api/v1/sync/setup/import:
post:
operationId: importSyncSetup
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SyncSetupImportRequest" }
responses:
"200":
description: Exported settings opened with the shared key, verified against the target, and saved atomically
content:
application/json:
schema: { $ref: "#/components/schemas/SyncSetupResponse" }
"400": { $ref: "#/components/responses/Problem" }
"401": { $ref: "#/components/responses/Problem" }
"403": { $ref: "#/components/responses/Problem" }
"409": { $ref: "#/components/responses/Problem" }
"500": { $ref: "#/components/responses/Problem" }
"502": { $ref: "#/components/responses/Problem" }
/api/v1/sync/exclusions:
get:
operationId: getSyncExclusions
Expand Down Expand Up @@ -6274,6 +6307,33 @@ components:
status: { $ref: "#/components/schemas/SyncStatus" }
# Returned once only when the server generated the key.
generatedKey: { type: string, minLength: 1 }
SyncSetupExport:
type: object
additionalProperties: false
required: [document, keyGenerated]
properties:
# 同期先と認証情報を同期鍵で封じた文書。同期鍵が無いと開けないので、ファイルにも
# QRコードにもしてよい。
document: { type: string, minLength: 1, maxLength: 16384 }
# 同期鍵がengineの生成した形か。falseなら、文書を手に入れた人が鍵を手元で
# 試せることを画面が注意する。
keyGenerated: { type: boolean }
# documentのQRコードのマス目。上の行から、各行は "1"(黒)と "0"(白)の並びで、
# 周りの余白は含まない。QRコードに入り切らない長さのときは無い。
qrRows:
type: array
minItems: 21
maxItems: 177
items: { type: string, minLength: 21, maxLength: 177, pattern: "^[01]+$" }
SyncSetupImportRequest:
type: object
additionalProperties: false
required: [document, key, direction]
properties:
document: { type: string, minLength: 1, maxLength: 16384 }
# 文書を開く鍵であり、そのままこのマシンの同期鍵になる。
key: { type: string, minLength: 1, maxLength: 1024 }
direction: { $ref: "#/components/schemas/SyncDirection" }
AutoSync:
type: object
additionalProperties: false
Expand Down
1 change: 1 addition & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@
- push ごとに `snapshots/YYYY-MM-DD-HHMMSS-<origin>-<snapshot>-<sequence>.tar.gz.enc` へ日付付き候補を保存します。同じ秒の複数送信やプロセス再起動でも衝突しないよう、送信元、暗号文のdigest、プロセス内sequenceを含めます。候補を先に書き、その後ライブオブジェクトへ条件付きで書き込むため、ライブオブジェクトが部分更新されることはありません。ライブの条件付き書き込みが競合で拒否された場合は、その試行が作った候補だけを削除します。通信断などでライブ書き込みの成否を確定できない場合は証拠を失わないため候補を残し、次の周期のHEAD確認で再送信を止めます。成功したpushの履歴数にアプリケーション側の上限はなく、古いスナップショットには後から更新・削除した鍵も残ります。必要なら`snapshots/`にバケットのlifecycle ruleを設定してください。
- S3 の認証情報はマスターパスワードで暗号化し、`~/.ssh/sshc/sync-settings` に保存します。同期は `~/.ssh` 配下の通常ファイルを相対パスのまま再帰的に収集するため、ルート直下に手動配置した秘密鍵も対象です。一方、バケットへのアクセス情報、マシン固有の状態、journal・backup・history・trash、一時ファイルは明示的に除外し、symlink・socket・FIFO・device は追跡も転送もしません。`TestASnapshotTravelsBetweenTwoMachines` がルート鍵を含む往復を、`TestASnapshotCarriesTheVaultAndNotTheKeyToItsOwnBucket` が除外を検査します。
- スナップショットはマスターパスワードではなく同期専用鍵で暗号化します。既定では 120 ビット、Crockford base32 の 24 文字を 4 文字ごとに区切った 29 文字(`AB12-CD34-EF56-GH78-JK90-MN12`。区切りも鍵の一部)を生成し、利用者が指定することもできます。鍵は `sync-settings` に保存し、平文では作成時の応答に一度だけ含めます。マシンごとに異なるマスターパスワードを使用できます。過去の暗号化方式を変換する専用操作は提供しません。同期は空の保存先または現行schemaだけの保存先から始め、force pushは現行schemaの別世代を、利用者が確認したETagに対して条件付きで置き換える場合だけに使います。
- 同期の設定は、設定済みのマシンから別のマシンへ渡せます(`POST /api/v1/sync/setup/export`、`POST /api/v1/sync/setup/import`)。書き出しは、同期先(endpoint・bucket・path・region)とS3の認証情報を、Vaultにある同期専用鍵でスナップショットと同じenvelopeに封じ、`{"sshcSyncSettings":1,"sealed":"<base64>"}`の文書にします。同期専用鍵は文書に入れず、書き出すときに入力も求めません。engineは文書と一緒にQRコードのマス目(`rsc.io/qr`、誤り訂正L)を返し、QRコードに入り切らない長さならマス目を返しません。取り込みは、入力された鍵で文書を開き(鍵導出のコストはスナップショットと同じ`OpenRemote`の上限)、中身を手入力の設定と同じ`ValidateTarget`に通し、同期先が既存であることを確かめてから`CompleteSetup`で設定と鍵をまとめて保存します。同期先が空なら`sync_setup_target_empty`で断り、新しい同期先として始めません。送受信の向きはマシンごとの値なので文書に入れず、取り込む画面で選びます。QRコードの読み取りはWebの画面がカメラの映像を`jsqr`で読み、Android版はMainActivityがengineのoriginからの映像だけの要求に限ってカメラを許可します(`CameraRequest`)。
- 空の保存先へ設定を保存するときは、鍵を確かめてから、ファイルを含まないスナップショットをライブオブジェクトとして`If-None-Match: *`で置き、そのあとで設定を保存します(`remotesync.Service.placeSetupSnapshot`)。このマシンが最初に同期する前に別のマシンが同じ保存先を設定しても、そのマシンは既存の保存先と判断して同じ鍵を求め、このスナップショットを復号して確かめます。空のままにすると、別のマシンが新しい鍵を作り、あとで同期したこのマシンは鍵が合わなくなるためです。ファイルを含めないのは、除外の設定と送る内容を利用者が確かめる前に、`~/.ssh`の中身を消せない履歴へ送らないためで、同じ理由で受信専用のマシンも置きます。日付付きの履歴は置きません。ライブの書き込みに失敗したときに、履歴だけが残って参加も新規の設定もできない保存先にしないためです。置いた世代は同期状態に記録しますが、同期した操作は持たないので、画面には「まだ同期していません」と出ます。次のpushはこの世代を親にして置き換え、最初の同期の前に鍵を置き換えるとこの世代も新しい鍵で封じ直します。設定の保存に失敗したら、置いたスナップショットを消し、同期状態を元に戻します。
- Vaultは同期元で復号し、スナップショット内の `sshc/secrets.json` として格納し、同期先でそのマシンのマスターパスワードを使って再暗号化します。Vaultのファイル自体を転送すると、同期先でも同期元のマスターパスワードが必要になるためです。アーカイブ全体は同期専用鍵で暗号化します。空のVaultは同期せず、2 台目の最初の pull で不要な競合が生じないようにします。マニフェスト v6 は内容から検証できるrevision ID、親revision ID、commit message に加え、認証済みの ancestor chain を持ち、日付付き暗号化スナップショットをGit風の履歴として辿れます。読み取りは v6 だけを受け付け、ほかの schema は `snapshot_schema_unsupported` で断ります。v0.24.0 より前の sshc が書いた v5 が最新のまま残っている保存先は、送信できるマシンから強制 push で上書きします。
- 同期状態(`sshc/sync-state.json`)には、前回同期したVault文書とスニペットの文書(起動スニペットのシークレットの入力値を含む)のdigestを、Vaultの鍵で鍵付きにした値(`hmac-sha256:`)だけを残します。マスターパスワードの変更、復旧、resetで鍵が変わるときは、このマシンのVaultやスニペットが前回の同期から変わっていなければ、同じトランザクションでその値を新しい鍵の値へ書き換えます。書き換えないと、ほかのマシンだけがVaultを変えた場合も衝突になるためです。前のバージョンが書いた素のdigestは読み替えません。
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
rsc.io/qr v0.2.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,5 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
8 changes: 6 additions & 2 deletions internal/acceptance/android_shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ func TestTheAndroidShellRoutesModernBackNavigationThroughTheWebView(t *testing.T
}

// WebViewの既定実装ではfile inputをキャンセルするため、Android shellが
// 選択・保存・renderer異常・通知permissionを明示的に仲介する形を固定する。
// 選択・保存・renderer異常・通知permission・カメラの要求を明示的に仲介する形を固定する。
func TestTheAndroidShellBridgesDeviceSpecificWebFeatures(t *testing.T) {
t.Parallel()
manifest := readRepoFile(t, "android", "app", "src", "main", "AndroidManifest.xml")
Expand All @@ -269,7 +269,11 @@ func TestTheAndroidShellBridgesDeviceSpecificWebFeatures(t *testing.T) {
"POST_NOTIFICATIONS",
"requestPermissions(",
"Intent.ACTION_VIEW",
"engineOrigin.getPort() == target.getPort()",
"engineOrigin.getPort() == url.getPort()",
"if (isEngineOrigin(target)) return false;",
"onPermissionRequest(PermissionRequest request)",
"!isEngineOrigin(request.getOrigin()) || !CameraRequest.asksOnlyForVideo(request.getResources())",
"Manifest.permission.CAMERA",
"setSystemBarsAppearance(",
"saveWithAndroid(",
"notifyAndroidTransfer(",
Expand Down
Loading
Loading