Compare commits

..
42 Commits
Author SHA1 Message Date
hugo aec3f237a9 Sync Home with pull then push and prepare 1.0.1 build 8 (#124)
Dependency security audit / rustsec (push) Successful in 20s
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 1h15m17s
Tagged release / build-windows (push) Successful in 19m26s
Tagged release / publish-release (push) Successful in 3s
2026-09-12 12:53:30 +02:00
hugo 3c3574b449 Add OTP QR import from TOTP and entry editors (#120)
Dependency security audit / rustsec (push) Successful in 20s
2026-09-12 12:06:15 +02:00
hugo 93a08e9c65 Provision Cargo for complete RustSec registry checks (#122)
Dependency security audit / rustsec (push) Successful in 20s
2026-09-12 10:22:38 +02:00
hugo 5f49df38a0 Fix security audit installation on the ARM64 runner (#122)
Dependency security audit / rustsec (push) Successful in 20s
2026-09-12 10:19:19 +02:00
hugo c2dd826920 Implement audited findings
Dependency security audit / rustsec (push) Failing after 3s
2026-08-27 15:34:31 +02:00
hugo 2feecae70d Refine implementation findings
Dependency security audit / rustsec (push) Has been cancelled
2026-08-27 14:59:17 +02:00
Georg Bauer 4c5ed4baf6 Document prioritized implementation findings
Dependency security audit / rustsec (push) Has been cancelled
2026-08-27 14:41:57 +02:00
hugo b93ae852e0 Reuse native macOS Git credentials
Dependency security audit / rustsec (push) Has been cancelled
2026-08-26 18:53:55 +02:00
Georg Bauer 59cd8951f6 Use standard desktop application menus
Dependency security audit / rustsec (push) Has been cancelled
2026-08-26 15:59:45 +02:00
Georg Bauer 7d21abd5c1 Run desktop startup on blocking pool
Dependency security audit / rustsec (push) Has been cancelled
2026-08-26 11:13:09 +02:00
Georg Bauer 300ccf5f1f Use repository Git remotes and SSH config
Dependency security audit / rustsec (push) Has been cancelled
2026-08-26 09:32:21 +02:00
hugo a3da9fda69 Complete SSH transport release audit (#118)
Dependency security audit / rustsec (push) Has been cancelled
2026-08-25 22:07:47 +02:00
hugo a737e74aae Enable SSH remotes in native frontends (#117) 2026-08-25 21:39:35 +02:00
hugo 76e707f645 Implement SSH receive-pack pushes 2026-08-25 20:56:06 +02:00
hugo 051e14235f Implement SSH upload-pack fetches 2026-08-25 20:31:10 +02:00
hugo 5dbda4bbd2 Implement SSH identity authentication 2026-08-25 19:52:47 +02:00
hugo f636f3b551 Add typed SSH remote endpoints 2026-08-25 19:12:34 +02:00
hugo f0d6a04be1 Prepare App Store build 7 2026-08-17 20:36:25 +02:00
hugo 14c9f8138f Keep confirmed Watch snapshots current 2026-08-17 20:28:18 +02:00
hugo c617430149 Keep mobile storage paths container-relative 2026-08-17 19:43:22 +02:00
hugo 9f262ec80c Prepare App Store build 6 2026-08-17 19:09:24 +02:00
hugo 5d7e8c94c8 Fix TOTP unlock loading presentation 2026-08-17 18:54:49 +02:00
hugo 2a59803b6c Harden Apple Watch snapshot synchronization 2026-08-17 18:06:09 +02:00
hugo 5c88d3ace0 Document reliable App Store export path 2026-08-17 17:13:22 +02:00
hugo 5b70806203 Prepare App Store build 5 2026-08-17 17:07:27 +02:00
hugo a8a59d496b Allow existing GPG keys with cloned stores 2026-08-17 17:00:31 +02:00
hugo 0b7c938b23 Prepare App Store 1.0 build 4 2026-08-17 16:50:30 +02:00
hugo f894d43eb6 Prepare Apple App Store distribution (#59) 2026-08-16 17:55:59 +02:00
hugo b50e47ab73 Refine Apple Watch TOTP presentation (#57) 2026-08-16 15:19:32 +02:00
hugo c2c7a64164 Audit Apple mobile integration (#58) 2026-08-16 14:54:53 +02:00
hugo e54d91a83a Implement Apple Watch TOTP interface (#57) 2026-08-16 14:38:16 +02:00
hugo df1d49339c Build secure Rust watchOS TOTP core (#56) 2026-08-16 14:27:21 +02:00
hugo a055c93b86 Synchronize selected TOTP entries to Apple Watch (#55) 2026-08-16 13:47:31 +02:00
hugo affdb55519 Implement iPhone and Apple Watch preferences (#53) 2026-08-15 22:43:51 +02:00
hugo 81886756a2 Darken the desktop app theme (#86) 2026-08-15 21:45:42 +02:00
hugo 2f0dc8a388 Show password counts on iPhone 2026-08-14 18:38:04 +02:00
hugo cc2c30b0ef Allow locked iPhone Git actions (#51) 2026-08-14 17:53:48 +02:00
hugo 65eb1da71c Present iPhone Git actions (#51) 2026-08-14 17:24:41 +02:00
hugo fcf649e56d Package macOS desktop releases as DMGs (#82)
Tagged release / prepare-release (push) Successful in 5s
Tagged release / build-unix (push) Successful in 1h7m44s
Tagged release / build-windows (push) Successful in 17m33s
Tagged release / publish-release (push) Successful in 3s
2026-08-13 22:56:26 +02:00
hugo 189ae7721e Build tagged releases on Gitea (#79)
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 49m4s
Tagged release / build-windows (push) Successful in 17m53s
Tagged release / publish-release (push) Successful in 3s
2026-08-12 22:27:50 +02:00
hugo 87f9e7be98 Add cached TOTP search (#77) 2026-08-12 20:36:03 +02:00
hugo 1ce7b7814b Show current TOTP lock state (#80) 2026-08-12 20:19:25 +02:00
113 changed files with 42038 additions and 1116 deletions

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Create and publish Gitea releases for tagged IronStorage builds."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import unittest
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
def issue_numbers(message: str) -> list[int]:
return sorted({int(number) for number in re.findall(r"#(\d+)", message)})
def markdown(text: str) -> str:
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
def release_body(issues: list[dict[str, object]], server: str, repository: str, sha: str) -> str:
groups = {"Fixes": [], "Improvements": [], "Other changes": []}
for issue in issues:
labels = {label["name"] for label in issue.get("labels", [])} # type: ignore[index]
group = "Fixes" if "bug" in labels else "Improvements" if "enhancement" in labels else "Other changes"
title = " ".join(str(issue["title"]).split())
groups[group].append(f"- [#{issue['number']} {markdown(title)}]({issue['html_url']})")
sections = [f"## {name}\n" + "\n".join(items) for name, items in groups.items() if items]
if not sections:
sections.append("No closed issues were linked from commits in this release.")
sections.append(f"Built from [{sha[:12]}]({server}/{repository}/commit/{sha}).")
return "\n\n".join(sections)
class Gitea:
def __init__(self) -> None:
self.api_url = os.environ["GITEA_API_URL"].rstrip("/")
self.repository = os.environ["GITEA_REPOSITORY"]
self.token = os.environ["GITEA_TOKEN"]
def request(self, method: str, path: str, payload: object | None = None) -> object:
data = None if payload is None else json.dumps(payload).encode()
request = Request(
f"{self.api_url}{path}",
data=data,
method=method,
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"},
)
with urlopen(request) as response:
content = response.read()
return None if not content else json.loads(content)
def issue(self, number: int) -> dict[str, object]:
return self.request("GET", f"/repos/{self.repository}/issues/{number}") # type: ignore[return-value]
def commit_messages(sha: str) -> str:
try:
previous = subprocess.check_output(
["git", "describe", "--tags", "--abbrev=0", f"{sha}^"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except subprocess.CalledProcessError:
previous = ""
revision = f"{previous}..{sha}" if previous else sha
return subprocess.check_output(["git", "log", "--format=%s%n%b", revision], text=True)
def prepare() -> None:
gitea = Gitea()
tag = os.environ["GITEA_REF_NAME"]
sha = os.environ["GITEA_SHA"]
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
issues = [gitea.issue(number) for number in issue_numbers(commit_messages(sha))]
closed = [issue for issue in issues if issue["state"] == "closed"]
payload = {
"tag_name": tag,
"target_commitish": sha,
"name": tag,
"body": release_body(closed, server, gitea.repository, sha),
"draft": True,
"prerelease": False,
}
try:
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/tags/{quote(tag, safe='')}")
except HTTPError as error:
if error.code != 404:
raise
release = gitea.request("POST", f"/repos/{gitea.repository}/releases", payload)
else:
if not release["draft"]: # type: ignore[index]
raise RuntimeError(f"release {tag} is already published")
release = gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release['id']}", payload) # type: ignore[index]
for asset in release["assets"] or []: # type: ignore[index]
gitea.request(
"DELETE",
f"/repos/{gitea.repository}/releases/{release['id']}/assets/{asset['id']}", # type: ignore[index]
)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"release_id={release['id']}\n") # type: ignore[index]
def publish() -> None:
gitea = Gitea()
release_id = int(os.environ["RELEASE_ID"])
gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release_id}", {"draft": False})
class ReleaseTests(unittest.TestCase):
def test_notes_are_unique_sorted_grouped_and_linked(self) -> None:
self.assertEqual(issue_numbers("Fix #12, refs #3 and #12"), [3, 12])
body = release_body(
[
{
"number": 12,
"title": "Fix [unlock]",
"html_url": "https://example.test/issues/12",
"labels": [{"name": "bug"}],
},
{
"number": 3,
"title": "Add search",
"html_url": "https://example.test/issues/3",
"labels": [{"name": "enhancement"}],
},
],
"https://example.test",
"owner/repo",
"1234567890abcdef",
)
self.assertIn("## Fixes\n- [#12 Fix \\[unlock\\]](https://example.test/issues/12)", body)
self.assertIn("## Improvements\n- [#3 Add search](https://example.test/issues/3)", body)
self.assertIn("[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)", body)
if __name__ == "__main__":
command = sys.argv[1] if len(sys.argv) == 2 else ""
if command == "prepare":
prepare()
elif command == "publish":
publish()
elif command == "test":
unittest.main(argv=[sys.argv[0]])
else:
raise SystemExit("usage: release.py prepare|publish|test")
+197
View File
@@ -0,0 +1,197 @@
name: Tagged release
on:
push:
tags:
- "*"
env:
CARGO_TERM_COLOR: always
jobs:
prepare-release:
runs-on: linux-arm64
outputs:
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Check out the tag
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Test release-note generation
run: python3 .gitea/scripts/release.py test
- name: Create draft release
id: release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: python3 .gitea/scripts/release.py prepare
build-unix:
needs: prepare-release
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Build, package, and upload Unix releases
env:
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: -C link-arg=-Wl,-headerpad,0x1000
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g')
for build in \
'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64' \
'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64' \
'aarch64-unknown-linux-gnu|aarch64-unknown-linux-gnu.2.28|linux-arm64' \
'x86_64-unknown-linux-gnu|x86_64-unknown-linux-gnu.2.28|linux-x64'
do
target=${build%%|*}
rest=${build#*|}
build_target=${rest%%|*}
platform=${rest#*|}
cargo zigbuild --release --locked --target "$build_target" \
--package ironstorage-cli \
--package ironstorage-tui \
--package ironstorage-desktop
cli="ironstorage-cli-${version}-${platform}"
desktop="ironstorage-desktop-${version}-${platform}"
mkdir -p "dist/$cli"
cp "target/$target/release/ironstorage" "dist/$cli/"
cp "target/$target/release/ironstorage-tui" "dist/$cli/"
tar -C dist -czf "dist/${cli}.tar.gz" "$cli"
rm -r "dist/$cli"
case "$platform" in
darwin-*)
desktop_asset="dist/${desktop}.dmg"
cargo run --release --locked --package ironstorage-macos-packager -- \
"$GITEA_REF_NAME" "target/$target/release" \
assets/icon-candidates/vault-classic@2x.png "$desktop_asset"
;;
*)
desktop_asset="dist/${desktop}.tar.gz"
mkdir -p "dist/$desktop"
cp "target/$target/release/ironstorage" "dist/$desktop/"
cp "target/$target/release/ironstorage-tui" "dist/$desktop/"
cp "target/$target/release/ironstorage-desktop" "dist/$desktop/"
tar -C dist -czf "$desktop_asset" "$desktop"
rm -r "dist/$desktop"
;;
esac
for asset in "dist/${cli}.tar.gz" "$desktop_asset"; do
name=$(basename "$asset")
curl --fail --silent --show-error --retry 3 \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
done
done
build-windows:
needs:
- prepare-release
- build-unix
runs-on: linux-arm64
container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
steps:
- name: Check out the tag
env:
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git init
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
git checkout --detach FETCH_HEAD
- name: Install the repository Rust toolchain
run: rustup toolchain install 1.92.0 --profile minimal --target x86_64-pc-windows-msvc
- name: Cross-build release binaries
run: >-
cargo +1.92.0 xwin build --release --locked
--target x86_64-pc-windows-msvc
--package ironstorage-cli
--package ironstorage-tui
--package ironstorage-desktop
- name: Package CLI, TUI, and desktop application
env:
TAG: ${{ gitea.ref_name }}
run: |
set -eu
version=$(printf '%s' "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g')
cli="ironstorage-cli-${version}-windows-x64"
desktop="ironstorage-desktop-${version}-windows-x64"
release=target/x86_64-pc-windows-msvc/release
mkdir -p "dist/$cli" "dist/$desktop"
cp "$release/ironstorage.exe" "$release/ironstorage-tui.exe" "dist/$cli/"
cp "$release/ironstorage.exe" "$release/ironstorage-tui.exe" \
"$release/ironstorage-desktop.exe" "dist/$desktop/"
tar -C dist -czf "dist/${cli}.tar.gz" "$cli"
tar -C dist -czf "dist/${desktop}.tar.gz" "$desktop"
rm -r "dist/$cli" "dist/$desktop"
- name: Upload release packages
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: |
set -eu
for asset in dist/*.tar.gz; do
name=$(basename "$asset")
curl --fail --silent --show-error --retry 3 \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
done
publish-release:
needs:
- prepare-release
- build-unix
- build-windows
runs-on: linux-arm64
steps:
- name: Check out the tag
uses: actions/checkout@v4
- name: Publish complete release
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
run: python3 .gitea/scripts/release.py publish
+27
View File
@@ -0,0 +1,27 @@
name: Dependency security audit
on:
push:
branches:
- main
pull_request:
schedule:
- cron: "17 4 * * 1"
jobs:
rustsec:
runs-on: linux-arm64
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Install Rust and the reviewed cargo-audit release
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af # v2
with:
tool: rust@1.92.0,cargo-audit@0.22.2
fallback: none
- name: Reject new RustSec vulnerabilities
# RustSec's registry checks require Cargo even with a prebuilt audit binary.
# The existing RSA exception is documented in docs/ssh-transport-audit.md.
run: cargo-audit audit --ignore RUSTSEC-2023-0071
+2
View File
@@ -1,3 +1,5 @@
/target/ /target/
/apple/IronStorage.xcodeproj/ /apple/IronStorage.xcodeproj/
/distribution/
.env
.DS_Store .DS_Store
+15 -2
View File
@@ -7,8 +7,21 @@ derived from a password-store repository. This includes filesystem layout,
`.gpg-id` handling, GPG-compatible encryption and key handling, Git, `.gpg-id` handling, GPG-compatible encryption and key handling, Git,
synchronization, conflict handling, entry parsing, password generation, OTP, synchronization, conflict handling, entry parsing, password generation, OTP,
QR payloads, server/application identities, HTTPS Git credentials, and QR payloads, server/application identities, HTTPS Git credentials, and
secure-secret-storage orchestration. Git remotes are HTTPS-only; reject other secure-secret-storage orchestration. Git remotes are typed HTTPS or optional
schemes before entering the Git transport. SSH endpoints; reject local, helper, executable, and unknown transports before
entering Git transport code. Builds without the `ssh` feature must still parse
SSH endpoints and return a typed unsupported-transport error before connection
or repository mutation. When a password-store repository contains Git remotes,
those names and URLs are authoritative; use application configuration only as
the fallback for a repository without remotes.
The SSH feature must keep its algorithm allowlist, strict known-host checking,
single configured identity, bounded channel/diagnostic limits, and ambiguous
push outcome rules in `crates/storage`. The in-process SSH configuration reader
may honor only the connection directives `Host`, `HostName`, `User`, `Port`,
and `IdentityFile`. Do not add `Include`, `Match`, proxy commands, password or
keyboard-interactive authentication, host-key bypasses, or frontend transport
policy.
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
input, invoke the Rust API, and present Rust-provided state. They must not input, invoke the Rust API, and present Rust-provided state. They must not
Generated
+2302 -135
View File
File diff suppressed because it is too large. Load diff
+19 -2
View File
@@ -5,18 +5,26 @@ members = [
"apps/tui", "apps/tui",
"crates/apple", "crates/apple",
"crates/storage", "crates/storage",
"crates/watch-apple",
"tools/apple-release",
"tools/macos-packager",
] ]
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]
version = "0.1.0" version = "1.0.1"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
license = "MIT"
repository = "https://git.rfc1437.de/hugo/IronStorage"
[workspace.dependencies] [workspace.dependencies]
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] }
apple-bundles = "0.21"
apple-codesign = { version = "0.29", default-features = false }
apple-dmg = "0.5"
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
ashpd = { version = "0.13", default-features = false, features = ["tokio"] } ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] }
cap-std = "4.0" cap-std = "4.0"
cap-tempfile = "4.0" cap-tempfile = "4.0"
clap = { version = "4.6", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
@@ -29,27 +37,36 @@ gix = { version = "0.86", default-features = false, features = ["blocking-http-t
gix-config = "0.59" gix-config = "0.59"
hmac = "0.12" hmac = "0.12"
iced = { version = "0.14", features = ["canvas", "tokio"] } iced = { version = "0.14", features = ["canvas", "tokio"] }
iced_aw = { version = "0.14.1", default-features = false, features = ["menu"] }
icns = "0.4"
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] }
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
keyring-core = "1.0" keyring-core = "1.0"
keepass = "0.13.20" keepass = "0.13.20"
muda = "0.19" muda = "0.19"
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }
percent-encoding = "2.3"
qrcode = { version = "0.14", default-features = false } qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13" regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
rustls = { version = "0.23", default-features = false }
rfd = { version = "0.17", default-features = false } rfd = { version = "0.17", default-features = false }
rqrr = { version = "0.10", default-features = false } rqrr = { version = "0.10", default-features = false }
rpassword = "7.5" rpassword = "7.5"
russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
security-framework = "3.7" security-framework = "3.7"
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
shlex = "1.3" shlex = "1.3"
simple-file-manifest = "0.11"
toml = "0.9" toml = "0.9"
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
tokio-stream = { version = "0.1", features = ["net"] }
uniffi = "0.32" uniffi = "0.32"
url = { version = "2.5", default-features = false } url = { version = "2.5", default-features = false }
windows-native-keyring-store = { version = "1.1", default-features = false } windows-native-keyring-store = { version = "1.1", default-features = false }
+34 -13
View File
@@ -1,13 +1,14 @@
# Dependency and license review # Dependency and license review
Reviewed 2026-08-09. The OpenPGP backend decision is complete. The project Reviewed 2026-08-25. The OpenPGP backend decision and release packaging review
license remains intentionally unset pending the full transitive license audit are complete. IronStorage is licensed under the MIT License. Every package in
and packaging review described below. the Rust workspace declares that license, and the iPhone release bundles the
exact third-party license files from its Cargo dependency graph.
## License direction ## License direction
The preferred implementation stack permits IronStorage itself to use The implementation stack permits IronStorage to use the MIT License. The
`MIT OR Apache-2.0`. That is the provisional choice, not yet a final license. project license is recorded in `LICENSE` and in every workspace package.
The current direct dependencies are: The current direct dependencies are:
@@ -17,11 +18,12 @@ The current direct dependencies are:
| [clap 4.6](https://crates.io/crates/clap/4.6.4), [clap_complete 4.6](https://crates.io/crates/clap_complete/4.6.9) | CLI parsing and in-process shell completion generation | MIT OR Apache-2.0 | | [clap 4.6](https://crates.io/crates/clap/4.6.4), [clap_complete 4.6](https://crates.io/crates/clap_complete/4.6.9) | CLI parsing and in-process shell completion generation | MIT OR Apache-2.0 |
| [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT | | [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT |
| [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT |
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0), [iced_aw 0.14](https://crates.io/crates/iced_aw/0.14.1) | Desktop UI and standard in-window application menus | MIT |
| [muda 0.19](https://crates.io/crates/muda/0.19.3) | Safe native macOS application menus and standard roles | MIT OR Apache-2.0 | | [muda 0.19](https://crates.io/crates/muda/0.19.3) | Safe native macOS application menus and standard roles | MIT OR Apache-2.0 |
| [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT | | [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT |
| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 | | [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 |
| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 | | [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 |
| [russh 0.63.1](https://crates.io/crates/russh/0.63.1), [Tokio 1.53](https://crates.io/crates/tokio/1.53.1) | Optional pure-Rust SSH transport, key/agent authentication, and protocol runtime | Apache-2.0; MIT |
| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 | | [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 |
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
| [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT | | [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT |
@@ -50,13 +52,14 @@ decision.
| Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. | | Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. |
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. | | GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. | | Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. | | SSH client and agent protocol | [`russh` 0.63.1](https://crates.io/crates/russh/0.63.1) | Apache-2.0 | Exactly pinned behind the optional `ssh` feature with default features off and Ring/RSA enabled. Storage owns an explicit modern algorithm allowlist, selects exactly one configured key or agent fingerprint, and never invokes OpenSSH or another process. |
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject; it links libgit2 and is unnecessary for the embedded pure-Rust HTTPS and SSH transports. |
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. | | Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | | Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. | | Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
| TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. | | TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. |
| Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. | | Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. |
| Native macOS menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3) | MIT OR Apache-2.0 | Selected only on macOS. Its safe `NSApp` adapter supplies standard roles and accelerators; Linux and Windows use the same action registry through an in-window Iced menu bar, avoiding GTK and Win32 integration dependencies. | | Desktop menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3), [`iced_aw` 0.14](https://crates.io/crates/iced_aw/0.14.1) | MIT OR Apache-2.0; MIT | `muda` supplies native macOS roles and accelerators. Linux and Windows use `iced_aw`'s standard Iced menu bar over the same action registry, avoiding a separate GTK or Win32 window integration. |
| Native folder picker | [`rfd` 0.17](https://crates.io/crates/rfd/0.17.2), [`ashpd` 0.13](https://crates.io/crates/ashpd/0.13.13) | MIT | `rfd` is selected without default features for safe native macOS and Windows panels. Linux uses `ashpd` directly over the XDG Desktop Portal so the application never takes `rfd`'s `zenity` subprocess fallback. | | Native folder picker | [`rfd` 0.17](https://crates.io/crates/rfd/0.17.2), [`ashpd` 0.13](https://crates.io/crates/ashpd/0.13.13) | MIT | `rfd` is selected without default features for safe native macOS and Windows panels. Linux uses `ashpd` directly over the XDG Desktop Portal so the application never takes `rfd`'s `zenity` subprocess fallback. |
| CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. | | CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. |
| Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. | | Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. |
@@ -67,13 +70,31 @@ With this path, the central crate needs no third-party native GPG, Git, OTP, or
QR library. Apple Security/LocalAuthentication, Windows Credential Manager, QR library. Apple Security/LocalAuthentication, Windows Credential Manager,
Linux Secret Service, and Apple camera APIs remain operating-system boundaries. Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
## Release gate ## Security audit
Before choosing and adding the project license: `cargo audit` 0.22.2 was run against the 2026-08-25 lockfile. It reports
RUSTSEC-2023-0071 for `rsa` 0.9.10 (through `pgp`) and 0.10.0-rc.18 (through
Russh); no fixed release is available. The Marvin advisory concerns RSA
decryption timing. The SSH dependency is used for signatures, while the
existing OpenPGP dependency can decrypt RSA material, so the finding remains a
tracked risk rather than an ignored or suppressed advisory. The audit also
reports allowed unmaintained/unsound warnings in transitive UI and utility
dependencies. The scheduled Gitea audit passes only the named no-fix RSA
advisory on the command line and fails every other vulnerability; the full
manual audit remains unfiltered. The lifecycle, algorithm, feature-graph, and
size evidence is recorded in `docs/ssh-transport-audit.md`.
1. Lock the storage dependencies and run a full transitive license audit. ## Release packaging
2. Confirm the required notices/source offers for MPL-2.0 dependencies in every
distributed app package. `ironstorage-apple-release licenses` traverses the iPhone Rust library's locked,
target-filtered Cargo graph and records every dependency's name, version, SPDX
license, source, and shipped license/notice files in
`apple/Resources/App/ThirdPartyLicenses.txt`. If a published crate omits a
separate license file, the report flags that fact and retains its SPDX and
upstream source record. The Apple target bundles that attribution alongside
IronStorage's MIT license. The committed Cargo lockfile identifies the exact
versions, including MPL-2.0 components whose source remains available from the
recorded upstream packages.
The checked-in compatibility suite completes the earlier OpenPGP backend gate: The checked-in compatibility suite completes the earlier OpenPGP backend gate:
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited `pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 rfc1437
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18 -5
View File
@@ -12,8 +12,12 @@ and the first-party `pass` command behavior. `pass-otp` compatibility adds
The compatibility target includes init, list/show, find/grep, insert/edit, The compatibility target includes init, list/show, find/grep, insert/edit,
generate, remove, move/copy, and Git-backed commit and synchronization flows. generate, remove, move/copy, and Git-backed commit and synchronization flows.
Remote synchronization is HTTPS-only and uses server/application credentials Remote endpoints are typed in the storage crate as credential-free HTTPS or
kept in the operating system's secure store. feature-gated SSH. HTTPS synchronization uses server/application credentials
kept in the operating system's secure store; SSH transport dependencies and
runtime are compiled only with the optional `ssh` Cargo feature. The standard
CLI, TUI, and desktop applications enable it; the Apple bridge keeps it
disabled unless an Apple target explicitly opts in.
The central `ironstorage` Rust crate owns repository access, Git, The central `ironstorage` Rust crate owns repository access, Git,
GPG-compatible encryption and key handling, entries, OTP, synchronization, GPG-compatible encryption and key handling, entries, OTP, synchronization,
@@ -21,13 +25,16 @@ and platform-secure credential orchestration. Applications are presentation
and interaction adapters only. Runtime subprocesses—including `pass`, `git`, and interaction adapters only. Runtime subprocesses—including `pass`, `git`,
and `gpg`—are forbidden; compatibility is implemented in Rust. and `gpg`—are forbidden; compatibility is implemented in Rust.
Candidate libraries and the pending project-license decision are tracked in IronStorage is MIT licensed. Dependency licenses and the completed packaging
[`DEPENDENCIES.md`](DEPENDENCIES.md). review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
The shared TOML schema, path rules, editor precedence, and HTTPS remote format The shared TOML schema, path rules, editor precedence, and Git remote formats
are documented in [`docs/configuration.md`](docs/configuration.md). are documented in [`docs/configuration.md`](docs/configuration.md).
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
The complete SSH lifecycle, failure, algorithm, dependency, target, and release
matrix is documented in
[`docs/ssh-transport-audit.md`](docs/ssh-transport-audit.md).
Native credential storage, opaque secret references, user-presence policy, and Native credential storage, opaque secret references, user-presence policy, and
bounded caching are documented in bounded caching are documented in
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md). [`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
@@ -86,6 +93,10 @@ cd apple
xcodegen generate xcodegen generate
``` ```
Apple App Store metadata, archive verification, submission, and paired iPhone
and Watch release checks are documented in
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
## Building ## Building
Install a stable Rust toolchain satisfying the workspace manifest (Rust 1.92 Install a stable Rust toolchain satisfying the workspace manifest (Rust 1.92
@@ -129,6 +140,8 @@ The first bundle command writes `target/release/IronStorage.app`. The install
form replaces `~/Applications/IronStorage.app` with the freshly built bundle. form replaces `~/Applications/IronStorage.app` with the freshly built bundle.
The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries; The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries;
Settings can install links to them in `~/.local/bin`. Settings can install links to them in `~/.local/bin`.
All three bundled applications support configured HTTPS, `ssh://`, and scp-like
SSH remotes through the same storage-owned transport selection.
Build only the shared storage library or the Rust Apple bridge: Build only the shared storage library or the Rust Apple bridge:
+86
View File
@@ -0,0 +1,86 @@
# Apple App Store distribution
Use `distribution/` only as ignored local staging. Never store Apple credentials,
signing material, review credentials, GPG material, application tokens, or
private diagnostics in the repository or release logs.
The Apple Rust bridge intentionally excludes the optional storage `ssh`
feature. Before archiving, confirm `cargo tree -p ironstorage-apple -i russh -e
features` reports no matching package. Do not enable SSH for an Apple archive
without a separate Apple host-trust/key-interaction design; the CLI, TUI, and
desktop release artifacts enable SSH independently. The reviewed contract is
recorded in `../../docs/ssh-transport-audit.md`.
## Release identity
- iPhone application: `de.rfc1437.ironstorage`
- Apple Watch companion: `de.rfc1437.ironstorage.watch`
- Apple Watch compatibility: watchOS 26; the archive carries Apple's required
arm64 and arm64_32 slices
- Apple Developer team: `MU22FMRGK8`
The submitted archive must embed the Watch application beneath the iPhone
application. An iPhone-only archive is not a releasable IronStorage build.
## Prepare and submit
1. Generate the project and release attribution, then run the release verifier:
```sh
rustup toolchain install nightly --component rust-src
cargo metadata --format-version 1 --filter-platform aarch64-apple-ios \
> /private/tmp/ironstorage-ios-metadata.json
cargo run -p ironstorage-apple-release -- licenses \
/private/tmp/ironstorage-ios-metadata.json \
apple/Resources/App/ThirdPartyLicenses.txt
cargo run -p ironstorage-apple-release -- verify .
```
2. Run the repository gates and build both simulator applications. Validate the
local-first flow with synthetic data: generate or import a GPG key, create
folders and encrypted entries, read TOTP codes, select Watch entries, and
verify the Watch list and selected-code progress display.
3. Create the production archive with normal App Store distribution signing:
```sh
cd apple
xcodegen generate
xcodebuild -project IronStorage.xcodeproj -scheme IronStorage \
-configuration Release -destination 'generic/platform=iOS' \
-archivePath /private/tmp/IronStorage.xcarchive \
-allowProvisioningUpdates archive
```
4. Inspect the archive before upload. The iPhone and Watch bundle
identifiers and versions must match the metadata; the Watch app must be
embedded; privacy manifests and license resources must be present; the
archive must use distribution signing and must not contain `get-task-allow`,
simulator artifacts, synthetic demo data, private endpoints, or secrets.
5. Export and upload with the checked-in App Store Connect export options:
```sh
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin \
xcodebuild -exportArchive \
-archivePath /private/tmp/IronStorage.xcarchive \
-exportPath /private/tmp/IronStorage-AppStore \
-exportOptionsPlist AppStore/ExportOptions.plist \
-allowProvisioningUpdates
```
Keep this restricted `PATH`: Xcode's system `rsync` otherwise starts a
Homebrew `rsync` server, which interprets `-E` differently and fails with
`exportArchive Copy failed` and `--extended-attributes: unknown option`.
6. In App Store Connect, complete the listing from `metadata.toml`, upload the
reviewed required-size iPhone screenshots and Watch screenshots, answer
privacy and encryption questions, provide
local-first review instructions, select the processed build, and submit it
for review. Do not provide a production password store or real credentials.
7. After approval, install the public release from the App Store on the paired
iPhone and Apple Watch. Verify local onboarding, key overwrite confirmation,
local Git history, folders, encrypted entries, Face ID, TOTP, optional HTTPS
pull and push, Watch snapshot delivery, the Watch entry-only list, and the
larger selected code with its validity progress indicator.
8. Verify an App Store update in place. Verify the documented uninstall and
reinstall Keychain lifecycle only with disposable test data; never erase the
paired physical device or its unrelated IronStorage data.
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>upload</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>MU22FMRGK8</string>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>rfc1437 Apps</title>
<link rel="stylesheet" href="/assets/pico.pumpkin.min.css">
<link rel="stylesheet" href="/assets/bds.css">
<style>.app-header{display:flex;align-items:center;gap:1rem}.app-header img{width:5rem;height:5rem;border-radius:1.1rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}figure{margin:0}figure img{display:block;width:100%;height:auto;border-radius:1.5rem}figcaption{margin-top:.5rem;color:var(--pico-muted-color)}</style>
</head>
<body><main>
<header><h1 class="archive-heading">rfc1437 Apps</h1><p>Small, focused apps published by <a href="https://rfc1437.de/">rfc1437</a>.</p></header>
<nav class="blog-menu" aria-label="Site navigation"><ul class="blog-menu-list"><li class="blog-menu-item"><a class="blog-menu-link" href="https://rfc1437.de/">Home</a></li><li class="blog-menu-item"><span class="blog-menu-link" aria-current="page">Apps</span></li></ul></nav>
<article class="post">
<h2>Install</h2>
<p>Gotcha is available through the rfc1437 AltStore PAL source at <code>https://rfc1437.de/apps/source.json</code>.</p>
<p>IronStorage is being prepared for the Apple App Store so its iPhone app and Apple Watch companion install together through the supported App Store path.</p>
</article>
<section><h2>Apps</h2>
<article class="post">
<header class="app-header"><img src="gotcha/icon.png" alt="Gotcha app icon"><div><h3>Gotcha</h3><p>Native Gitea and Forgejo on iPhone.</p></div></header>
<p>Follow repositories, issues, pull requests, commits, diffs, and milestones on servers you control.</p>
<nav><a href="gotcha/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/Gotcha">Source</a> · <a href="https://git.rfc1437.de/hugo/Gotcha/issues">Support</a></nav>
<div class="gallery">
<figure><img src="gotcha/screenshots/01-home-activity.png" alt="Gotcha activity"><figcaption>Activity</figcaption></figure>
<figure><img src="gotcha/screenshots/02-issues.png" alt="Gotcha issues"><figcaption>Issues</figcaption></figure>
<figure><img src="gotcha/screenshots/03-commit-history.png" alt="Gotcha commits"><figcaption>Commits</figcaption></figure>
<figure><img src="gotcha/screenshots/04-diff.png" alt="Gotcha diff"><figcaption>Diffs</figcaption></figure>
<figure><img src="gotcha/screenshots/05-pull-request.png" alt="Gotcha pull request"><figcaption>Pull requests</figcaption></figure>
<figure><img src="gotcha/screenshots/06-milestones.png" alt="Gotcha milestones"><figcaption>Milestones</figcaption></figure>
</div>
</article>
<article class="post">
<header class="app-header"><img src="ironstorage/icon.png" alt="IronStorage app icon"><div><h3>IronStorage</h3><p>Native pass and pass-otp on iPhone and Apple Watch.</p></div></header>
<p>Generate or import a protected GPG key, keep local Git history, create encrypted entries and TOTP codes, and add optional HTTPS synchronization later.</p>
<p><strong>Apple App Store release in preparation.</strong></p>
<nav><a href="ironstorage/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
<div class="gallery">
<figure><img src="ironstorage/screenshots/01-home.png" alt="IronStorage local Git home"><figcaption>Local Git status</figcaption></figure>
<figure><img src="ironstorage/screenshots/02-passwords.png" alt="IronStorage password folders"><figcaption>Passwords and folders</figcaption></figure>
<figure><img src="ironstorage/screenshots/03-totp.png" alt="IronStorage TOTP list"><figcaption>TOTP and Watch selection</figcaption></figure>
<figure><img src="ironstorage/screenshots/04-totp-detail.png" alt="IronStorage TOTP detail"><figcaption>Live TOTP detail</figcaption></figure>
<figure><img src="ironstorage/screenshots/05-preferences.png" alt="IronStorage preferences"><figcaption>Local-first preferences</figcaption></figure>
<figure><img src="ironstorage/screenshots/06-search.png" alt="IronStorage search"><figcaption>Password search</figcaption></figure>
<figure><img src="ironstorage/screenshots/watch-01-list.png" alt="IronStorage Watch entry list"><figcaption>Watch entry list</figcaption></figure>
<figure><img src="ironstorage/screenshots/watch-02-detail.png" alt="IronStorage Watch code detail"><figcaption>Watch code and progress</figcaption></figure>
</div>
</article>
</section>
<footer><small>Both apps are free and open-source software. Each listing identifies its supported distribution channel.</small></footer>
</main></body></html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>IronStorage</title>
<style>body{font:17px system-ui;line-height:1.55;max-width:64rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}header{display:flex;align-items:center;gap:1rem}header img{width:6rem;border-radius:1.3rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}.gallery img{width:100%;border-radius:1.5rem}nav{margin:1.5rem 0}</style>
<header><img src="icon.png" alt="IronStorage app icon"><div><h1>IronStorage</h1><p>Native pass and pass-otp for iPhone and Apple Watch.</p></div></header>
<p>Start locally with a protected GPG key and versioned password store. Create folders and encrypted entries on your iPhone, generate TOTP codes, and optionally connect an HTTPS Git remote later.</p>
<p>The Apple Watch companion receives only the TOTP entries you explicitly share.</p>
<p><strong>Apple App Store release in preparation.</strong></p>
<nav><a href="privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source code</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
<div class="gallery">
<img src="screenshots/01-home.png" alt="Local Git status">
<img src="screenshots/02-passwords.png" alt="Password entries and folders">
<img src="screenshots/03-totp.png" alt="TOTP entries selected for Apple Watch">
<img src="screenshots/04-totp-detail.png" alt="Live TOTP detail">
<img src="screenshots/05-preferences.png" alt="Local-first preferences">
<img src="screenshots/06-search.png" alt="Password search">
</div>
<h2>Apple Watch</h2>
<div class="gallery">
<img src="screenshots/watch-01-list.png" alt="Apple Watch TOTP entry list">
<img src="screenshots/watch-02-detail.png" alt="Apple Watch selected code and progress bar">
</div>
</html>
+77
View File
@@ -0,0 +1,77 @@
[app]
name = "IronStorage"
bundle_identifier = "de.rfc1437.ironstorage"
developer_name = "rfc1437"
subtitle = "Native pass on iPhone"
description = "IronStorage is a native iPhone and Apple Watch client for pass and pass-otp password stores. Start entirely on-device with a protected GPG key and local Git history, then add optional HTTPS Git synchronization whenever you want it."
keywords = "password,gpg,pass,totp,git,security,watch"
icon_file = "apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
category = "utilities"
privacy_url = "https://rfc1437.de/apps/ironstorage/privacy/"
support_url = "https://git.rfc1437.de/hugo/IronStorage/issues"
marketing_url = "https://rfc1437.de/apps/ironstorage/"
age_rating = "4+"
copyright = "2026 rfc1437"
review_notes = "IronStorage works entirely on-device without an account or remote service. On first launch, generate a local GPG key and choose a passphrase. You can then create folders without unlocking, and create encrypted password or TOTP entries after entering that passphrase. Git history is initialized locally; HTTPS synchronization is optional and is not needed for review. Entries appear on Apple Watch only after selecting them for Watch sharing on iPhone."
[release]
version = "1.0.1"
build = "8"
minimum_ios = "17.0"
minimum_watchos = "26.0"
notes = "Pull down on Home to pull remote changes and then push local commits. Pull failures stop synchronization and are shown immediately. Includes OTP QR import improvements and the Apple Watch companion."
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/home.png"
caption = "Local Git history works before a sync remote is configured"
width = 1284
height = 2778
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/passwords.png"
caption = "Password entries and folders stay organized on device"
width = 1284
height = 2778
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/totp.png"
caption = "TOTP entries are shared with Apple Watch only when selected"
width = 1284
height = 2778
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/totp-detail.png"
caption = "Live TOTP codes include an exact validity indicator"
width = 1284
height = 2778
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/preferences.png"
caption = "Generate or import a GPG key and add optional HTTPS sync later"
width = 1284
height = 2778
[[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/search.png"
caption = "Search encrypted password entries by name or folder"
width = 1284
height = 2778
[[watch_screenshots]]
file = "apple/AppStore/screenshots/watch-list.png"
caption = "The Watch list shows only explicitly shared TOTP entries"
width = 416
height = 496
[[watch_screenshots]]
file = "apple/AppStore/screenshots/watch-detail.png"
caption = "The selected Watch code is large with a validity progress bar"
width = 416
height = 496
[content]
advertising = false
account_creation = false
digital_purchases = false
tracking = false
user_generated_content = false
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IronStorage Privacy</title>
<style>body{font:17px system-ui;line-height:1.55;max-width:44rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}h1,h2{line-height:1.2}</style>
<h1>IronStorage Privacy</h1>
<p>Last updated: 16 August 2026</p>
<p>IronStorage does not collect analytics, advertising identifiers, diagnostics, or personal data for the developer, and it does not track you.</p>
<h2>Data on your device</h2>
<p>Your local Git repository and encrypted password entries remain on your device. Imported or generated private-key material stays in the app container and is protected by its GPG passphrase. Application tokens and biometric unlock data use Apple Keychain storage. Camera frames used to scan a key-transfer QR code are processed on the device and are not retained by IronStorage.</p>
<h2>Network access</h2>
<p>IronStorage works without a network service. If you later configure an HTTPS Git server, it connects only to that server using the credentials you provide to pull and push your password store. The developer does not operate an IronStorage service and does not receive this traffic.</p>
<h2>Deletion</h2>
<p>Deleting IronStorage removes its local repository and key files from the app container. Remove a configured application token and disable biometric unlock in Preferences before uninstalling when you also want those Keychain records removed immediately. Keychain items otherwise follow Apple’s secure-storage lifecycle and may survive an app reinstall.</p>
<h2>Support</h2>
<p>Questions and issues can be filed at <a href="https://git.rfc1437.de/hugo/IronStorage/issues">the IronStorage issue tracker</a>.</p>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -5,6 +5,12 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
} }
], ],
"info" : { "info" : {
+4 -2
View File
@@ -17,9 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XPC!</string> <string>XPC!</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
File diff suppressed because it is too large. Load diff
+251 -9
View File
@@ -253,11 +253,21 @@ uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle,
void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_acknowledge_watch_snapshot(uint64_t ptr, RustBuffer receipt, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_otp_image(uint64_t ptr, uint64_t editor, RustBuffer image, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry(uint64_t ptr, RustBuffer directory, RustBuffer name, RustBuffer passphrase, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry(uint64_t ptr, RustBuffer directory, RustBuffer name, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
@@ -288,6 +298,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_fi
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
void uniffi_ironstorage_apple_fn_method_mobileauthentication_create_directory(uint64_t ptr, RustBuffer parent, RustBuffer name, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status
@@ -303,6 +318,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor(
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_fail_watch_snapshot(uint64_t ptr, uint64_t revision, RustBuffer detail, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status
@@ -318,16 +338,36 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity(
void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(uint64_t ptr, int8_t watch_supported, int8_t watch_paired, int8_t watch_app_installed, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_watch_snapshot(uint64_t ptr, RustBuffer platform_pairing_identity, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
void uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status
@@ -353,6 +393,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
@@ -363,11 +413,21 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance(uint64_t ptr, RustBuffer appearance, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_watch_snapshot_unavailable(uint64_t ptr, int8_t unpaired, RustBuffer detail, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
@@ -403,6 +463,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(u
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_watch_snapshot_status(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
@@ -423,6 +488,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached(uint64_
void uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_commit(uint64_t ptr, RustBuffer message, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_fetch(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status
@@ -433,9 +508,9 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint6
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_push(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
@@ -443,6 +518,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_sync(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status
@@ -485,7 +565,27 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_add_frame(
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, int8_t overwrite, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
uint64_t uniffi_ironstorage_apple_fn_clone_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
void uniffi_ironstorage_apple_fn_free_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
void uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
RustBuffer uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_generate(uint64_t ptr, RustBuffer user_id, RustBuffer passphrase, int8_t replace_existing_key, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
@@ -503,6 +603,11 @@ void uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(uint64_t handle,
void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_connect_local_store(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status
@@ -546,14 +651,25 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus *
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus *_Nonnull out_status uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustBuffer authentication, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
uint64_t uniffi_ironstorage_apple_fn_func_mobile_key_transfer(RustCallStatus *_Nonnull out_status uint64_t uniffi_ironstorage_apple_fn_func_mobile_key_transfer(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_key_transfer_importer(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_onboarding_operation(RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
@@ -880,6 +996,18 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_key_transfer(void uint16_t uniffi_ironstorage_apple_checksum_func_mobile_key_transfer(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_key_transfer_importer(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_onboarding_operation(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
@@ -934,12 +1062,24 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_acknowledge_watch_snapshot(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_otp_image(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
@@ -976,6 +1116,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entr
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_create_directory(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
@@ -994,6 +1140,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_edi
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_fail_watch_snapshot(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
@@ -1012,18 +1164,42 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_ident
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_watch_snapshot(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
@@ -1054,6 +1230,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_en
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
@@ -1066,12 +1254,24 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biome
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_watch_snapshot_unavailable(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
@@ -1114,6 +1314,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_to
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_watch_snapshot_status(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
@@ -1126,6 +1332,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached(voi
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel(void uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_commit(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_fetch(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS
@@ -1140,9 +1358,9 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_pull(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(void uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_push(void
); );
#endif #endif
@@ -1150,6 +1368,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(vo
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_sync(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT
@@ -1180,12 +1404,30 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_fr
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import(void uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_cancel(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_generate(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_connect_local_store(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
@@ -0,0 +1,7 @@
module ironstorage_watch {
header "ironstorage_watchFFI.h"
export *
use "Darwin"
use "_Builtin_stdbool"
use "_Builtin_stdint"
}
File diff suppressed because it is too large. Load diff
+633
View File
@@ -0,0 +1,633 @@
// This file was autogenerated by some hot garbage in the `uniffi` crate.
// Trust me, you don't want to mess with it!
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
// The following structs are used to implement the lowest level
// of the FFI, and thus useful to multiple uniffied crates.
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
#ifdef UNIFFI_SHARED_H
// We also try to prevent mixing versions of shared uniffi header structs.
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
#ifndef UNIFFI_SHARED_HEADER_V4
#error Combining helper code from multiple versions of uniffi is not supported
#endif // ndef UNIFFI_SHARED_HEADER_V4
#else
#define UNIFFI_SHARED_H
#define UNIFFI_SHARED_HEADER_V4
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
typedef struct RustBuffer
{
uint64_t capacity;
uint64_t len;
uint8_t *_Nullable data;
} RustBuffer;
typedef struct ForeignBytes
{
int32_t len;
const uint8_t *_Nullable data;
} ForeignBytes;
// Error definitions
typedef struct RustCallStatus {
int8_t code;
RustBuffer errorBuf;
} RustCallStatus;
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
#endif // def UNIFFI_SHARED_H
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
typedef void (*UniffiForeignFutureDroppedCallback)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
typedef uint64_t (*UniffiCallbackInterfaceClone)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
typedef struct UniffiForeignFutureDroppedCallbackStruct {
uint64_t handle;
UniffiForeignFutureDroppedCallback _Nonnull free;
} UniffiForeignFutureDroppedCallbackStruct;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
typedef struct UniffiForeignFutureResultU8 {
uint8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureResultU8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
typedef struct UniffiForeignFutureResultI8 {
int8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureResultI8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
typedef struct UniffiForeignFutureResultU16 {
uint16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureResultU16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
typedef struct UniffiForeignFutureResultI16 {
int16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureResultI16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
typedef struct UniffiForeignFutureResultU32 {
uint32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureResultU32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
typedef struct UniffiForeignFutureResultI32 {
int32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureResultI32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
typedef struct UniffiForeignFutureResultU64 {
uint64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureResultU64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
typedef struct UniffiForeignFutureResultI64 {
int64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureResultI64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
typedef struct UniffiForeignFutureResultF32 {
float returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultF32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureResultF32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
typedef struct UniffiForeignFutureResultF64 {
double returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultF64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureResultF64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
typedef struct UniffiForeignFutureResultRustBuffer {
RustBuffer returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultRustBuffer;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureResultRustBuffer
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
typedef struct UniffiForeignFutureResultVoid {
RustCallStatus callStatus;
} UniffiForeignFutureResultVoid;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_CLONE_WATCHCORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_CLONE_WATCHCORE
uint64_t uniffi_ironstorage_watch_fn_clone_watchcore(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FREE_WATCHCORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FREE_WATCHCORE
void uniffi_ironstorage_watch_fn_free_watchcore(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_authoritative_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
void uniffi_ironstorage_watch_fn_method_watchcore_no_persisted_snapshot(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_presentation_at(uint64_t ptr, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
void uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
void uniffi_ironstorage_watch_fn_method_watchcore_sync_failed(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
void uniffi_ironstorage_watch_fn_method_watchcore_sync_finished(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
void uniffi_ironstorage_watch_fn_method_watchcore_sync_started(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
void uniffi_ironstorage_watch_fn_method_watchcore_sync_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE
uint64_t uniffi_ironstorage_watch_fn_func_watch_core(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_ALLOC
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_ALLOC
RustBuffer ffi_ironstorage_watch_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FROM_BYTES
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FROM_BYTES
RustBuffer ffi_ironstorage_watch_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FREE
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FREE
void ffi_ironstorage_watch_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_RESERVE
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_RESERVE
RustBuffer ffi_ironstorage_watch_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U8
void ffi_ironstorage_watch_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U8
void ffi_ironstorage_watch_rust_future_cancel_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U8
void ffi_ironstorage_watch_rust_future_free_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U8
uint8_t ffi_ironstorage_watch_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I8
void ffi_ironstorage_watch_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I8
void ffi_ironstorage_watch_rust_future_cancel_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I8
void ffi_ironstorage_watch_rust_future_free_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I8
int8_t ffi_ironstorage_watch_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U16
void ffi_ironstorage_watch_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U16
void ffi_ironstorage_watch_rust_future_cancel_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U16
void ffi_ironstorage_watch_rust_future_free_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U16
uint16_t ffi_ironstorage_watch_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I16
void ffi_ironstorage_watch_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I16
void ffi_ironstorage_watch_rust_future_cancel_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I16
void ffi_ironstorage_watch_rust_future_free_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I16
int16_t ffi_ironstorage_watch_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U32
void ffi_ironstorage_watch_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U32
void ffi_ironstorage_watch_rust_future_cancel_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U32
void ffi_ironstorage_watch_rust_future_free_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U32
uint32_t ffi_ironstorage_watch_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I32
void ffi_ironstorage_watch_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I32
void ffi_ironstorage_watch_rust_future_cancel_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I32
void ffi_ironstorage_watch_rust_future_free_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I32
int32_t ffi_ironstorage_watch_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U64
void ffi_ironstorage_watch_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U64
void ffi_ironstorage_watch_rust_future_cancel_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U64
void ffi_ironstorage_watch_rust_future_free_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U64
uint64_t ffi_ironstorage_watch_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I64
void ffi_ironstorage_watch_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I64
void ffi_ironstorage_watch_rust_future_cancel_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I64
void ffi_ironstorage_watch_rust_future_free_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I64
int64_t ffi_ironstorage_watch_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F32
void ffi_ironstorage_watch_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F32
void ffi_ironstorage_watch_rust_future_cancel_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F32
void ffi_ironstorage_watch_rust_future_free_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F32
float ffi_ironstorage_watch_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F64
void ffi_ironstorage_watch_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F64
void ffi_ironstorage_watch_rust_future_cancel_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F64
void ffi_ironstorage_watch_rust_future_free_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F64
double ffi_ironstorage_watch_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_RUST_BUFFER
void ffi_ironstorage_watch_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_RUST_BUFFER
void ffi_ironstorage_watch_rust_future_cancel_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_RUST_BUFFER
void ffi_ironstorage_watch_rust_future_free_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_RUST_BUFFER
RustBuffer ffi_ironstorage_watch_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_VOID
void ffi_ironstorage_watch_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_VOID
void ffi_ironstorage_watch_rust_future_cancel_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_VOID
void ffi_ironstorage_watch_rust_future_free_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_VOID
void ffi_ironstorage_watch_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_FUNC_WATCH_CORE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_FUNC_WATCH_CORE
uint16_t uniffi_ironstorage_watch_checksum_func_watch_core(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_authoritative_snapshot(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_SNAPSHOT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_snapshot(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_presentation_at(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_protected_data_unavailable(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_failed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_finished(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_started(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_unavailable(void
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_UNIFFI_CONTRACT_VERSION
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_UNIFFI_CONTRACT_VERSION
uint32_t ffi_ironstorage_watch_uniffi_contract_version(void
);
#endif
+4 -4
View File
@@ -17,15 +17,15 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSCameraUsageDescription</key>
<string>Scan OTP and GPG key transfer QR codes that you choose to import.</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Unlock your GPG key for protected password operations.</string> <string>Unlock your GPG key for protected password operations.</string>
<key>NSCameraUsageDescription</key>
<string>Scan GPG key transfer QR codes that you choose to import.</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
@@ -0,0 +1 @@
#include "Generated/ironstorage_watchFFI.h"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 rfc1437
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
+343 -1
View File
@@ -1,10 +1,352 @@
import Combine
import Security
import SwiftUI import SwiftUI
import WatchConnectivity
private enum SecureSnapshotStore {
private static let service = "de.rfc1437.ironstorage.watch.snapshot"
private static let account = "selected-totp"
static func load() throws -> Data? {
var result: CFTypeRef?
let status = SecItemCopyMatching([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
] as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw SnapshotStoreError(status: status)
}
return data
}
static func replace(_ snapshot: Data) throws {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
] as CFDictionary
let status = SecItemUpdate(query, [kSecValueData: snapshot] as CFDictionary)
if status == errSecItemNotFound {
let added = SecItemAdd([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: snapshot,
kSecAttrAccessible: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
] as CFDictionary, nil)
guard added == errSecSuccess else { throw SnapshotStoreError(status: added) }
} else if status != errSecSuccess {
throw SnapshotStoreError(status: status)
}
}
static func delete() throws {
let status = SecItemDelete([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
] as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SnapshotStoreError(status: status)
}
}
}
private struct SnapshotStoreError: Error {
let status: OSStatus
}
@MainActor
private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessionDelegate {
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let receiptKey = "de.rfc1437.ironstorage.watch.delivered"
@Published private(set) var presentation: WatchPresentation?
private let core = watchCore()
override init() {
super.init()
restoreSnapshot()
guard WCSession.isSupported() else {
try? core.syncUnavailable()
refreshPresentation()
return
}
let session = WCSession.default
session.delegate = self
try? core.syncStarted()
refreshPresentation()
session.activate()
}
func sceneBecameActive() {
restoreSnapshot()
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.activate()
if session.activationState == .activated {
receive(session.receivedApplicationContext, session: session, authoritative: true)
}
}
func sceneBecameInactive() {
try? core.protectedDataUnavailable()
refreshPresentation()
}
private func restoreSnapshot() {
do {
if var snapshot = try SecureSnapshotStore.load() {
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
let update = try core.applySnapshot(snapshot: snapshot)
if update.persistence == .delete { try SecureSnapshotStore.delete() }
} else {
try core.noPersistedSnapshot()
}
refreshPresentation()
} catch {
try? core.syncFailed()
refreshPresentation()
}
}
private func receive(
_ applicationContext: [String: Any],
session: WCSession,
authoritative: Bool = false
) {
try? core.syncStarted()
guard var snapshot = applicationContext[Self.snapshotKey] as? Data else {
try? core.syncFinished()
refreshPresentation()
return
}
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
do {
let update = if authoritative {
try core.applyAuthoritativeSnapshot(snapshot: snapshot)
} else {
try core.applySnapshot(snapshot: snapshot)
}
switch update.persistence {
case .keep:
break
case .replace:
try SecureSnapshotStore.replace(snapshot)
case .delete:
try SecureSnapshotStore.delete()
}
refreshPresentation()
guard !update.receipt.isEmpty else { return }
acknowledge(update.receipt, through: session)
} catch {
try? core.syncFailed()
refreshPresentation()
}
}
private func acknowledge(_ receipt: Data, through session: WCSession) {
let acknowledgement = [Self.receiptKey: receipt]
for transfer in session.outstandingUserInfoTransfers
where transfer.userInfo[Self.receiptKey] != nil {
transfer.cancel()
}
session.transferUserInfo(acknowledgement)
if session.isReachable {
session.sendMessage(acknowledgement, replyHandler: nil) { _ in }
}
}
func refreshPresentation(at date: Date = .now) {
presentation = try? core.presentationAt(
unixSeconds: UInt64(date.timeIntervalSince1970)
)
}
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
guard let self else { return }
guard activationState == .activated, error == nil else {
try? self.core.syncUnavailable()
self.refreshPresentation()
return
}
self.receive(
session.receivedApplicationContext,
session: session,
authoritative: true
)
}
}
nonisolated func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
Task { @MainActor [weak self] in
self?.receive(applicationContext, session: session, authoritative: true)
}
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Task { @MainActor [weak self] in
self?.receive(message, session: session)
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor [weak self] in
self?.receive(userInfo, session: session)
}
}
}
private struct WatchRootView: View {
@ObservedObject var transport: WatchSnapshotTransport
private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
if let presentation = transport.presentation {
if presentation.records.isEmpty {
WatchStateView(presentation: presentation)
} else {
List {
if presentation.state == .stale {
WatchStatusRow(presentation: presentation)
}
ForEach(presentation.records, id: \.path) { record in
NavigationLink {
WatchTotpDetail(path: record.path, transport: transport)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(record.issuer ?? record.account)
.font(.headline)
if record.issuer != nil {
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle(presentation.title)
}
}
}
.onReceive(clock) { transport.refreshPresentation(at: $0) }
}
}
private struct WatchTotpDetail: View {
let path: String
@ObservedObject var transport: WatchSnapshotTransport
private var record: WatchTotpRecord? {
transport.presentation?.records.first { $0.path == path }
}
var body: some View {
if let record {
ScrollView {
VStack(spacing: 8) {
Text(record.issuer ?? record.account)
.font(.headline)
.multilineTextAlignment(.center)
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Text(record.code)
.font(.system(size: 42, weight: .bold, design: .rounded))
.monospacedDigit()
.minimumScaleFactor(0.7)
.lineLimit(1)
.privacySensitive()
ProgressView(
value: Double(min(record.remaining, record.period)),
total: Double(record.period)
)
.accessibilityLabel("Code validity")
.accessibilityValue("\(record.remaining) seconds remaining")
Text("\(record.remaining)s")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.navigationTitle(record.account)
} else if let presentation = transport.presentation {
WatchStateView(presentation: presentation)
}
}
}
private struct WatchStateView: View {
let presentation: WatchPresentation
var body: some View {
ContentUnavailableView(
presentation.title,
systemImage: icon,
description: Text(presentation.detail)
)
}
private var icon: String {
switch presentation.state {
case .ready: "timer"
case .empty: "iphone.and.arrow.forward"
case .syncing: "arrow.triangle.2.circlepath"
case .stale: "clock.badge.exclamationmark"
case .locked: "lock"
case .unavailable: "iphone.slash"
case .error: "exclamationmark.triangle"
}
}
}
private struct WatchStatusRow: View {
let presentation: WatchPresentation
var body: some View {
Label {
VStack(alignment: .leading) {
Text(presentation.title).font(.headline)
Text(presentation.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "clock.badge.exclamationmark")
}
}
}
@main @main
struct IronStorageWatchApp: App { struct IronStorageWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var transport = WatchSnapshotTransport()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentUnavailableView("No TOTP Codes", systemImage: "timer") WatchRootView(transport: transport)
.onChange(of: scenePhase) { _, phase in
if phase == .active {
transport.sceneBecameActive()
} else {
transport.sceneBecameInactive()
}
}
} }
} }
} }
+114
View File
@@ -0,0 +1,114 @@
# Apple mobile integration audit
This is the source-of-truth coverage and release-test checklist for the iPhone
and Apple Watch milestone. Automated evidence is required on every change; a
manual result is evidence only when the named device or simulator flow was
actually exercised.
## Coverage matrix
| Area | Implemented boundary | Automated evidence | Manual release evidence |
| --- | --- | --- | --- |
| Home tab, local history, and optional remote activity | UIKit renders Rust `MobileHomePage`; pulling down calls Rust sync (pull, then push only on success). Explicit Commit/Fetch/Pull/Push actions remain available. | `mobile_home` unit tests, `git_embedded` move/back and failed-pull regressions, and `crates/apple` bridge tests | Verify refresh and all four actions, including expired application-token recovery. Pull failures must show an error and prevent push. |
| Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery |
| Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts |
| TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown |
| Preferences and biometric unlock | Rust owns preference/authentication state; LocalAuthentication and Keychain remain isolated Apple adapters | `authentication_leases`, `secret_store`, and mobile authentication tests | Enroll once, then test match, cancel, rejection, and changed enrollment using the documented sequence below |
| GPG key QR transfer | Rust frames, validates, reconstructs, authenticates, and atomically stores keys; UIKit only scans/displays frames | `mobile_key_transfer` unit tests and `crypto_compatibility` | Test public/private transfer, interruption, duplicates, and capture shielding |
| OTP creation and QR import | TOTP Add and editor Add Field share the native camera/photo/URI input; Rust decodes QR, validates URI, and creates the OTP field | `mobile_otp_import_creates_or_appends_one_validated_field`, bounded QR image checks | In the existing simulator test store, create from TOTP, append from the editor, cancel, reject invalid/duplicate input, and save/reopen. Use synthetic QR images: the simulator has no camera. Validate physical camera capture later through the release process. |
| Watch synchronization | Rust owns selected snapshots, pairing identity, revisions, replay rejection, receipts, and revocation | `replacement_snapshots_reject_replays_conflicts_and_pairing_changes` | On a paired device, verify background delivery, replacement, replay rejection, revoke, and pairing change |
| Watch display | Rust owns OTP calculation, timing, and ready/empty/syncing/stale/locked/error presentation; SwiftUI renders it | `watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked` plus ARM64 Watch target build | Test offline and stale-snapshot behavior, wrist-down privacy, VoiceOver, Dynamic Type, and supported Watch sizes |
| Lifecycle and disclosure controls | Rust drops protected state; UIKit/SwiftUI clear views, time-limit pasteboard values, and shield private QR/captured content | authentication, presentation, TOTP, and Watch lifecycle tests; source audit in `apple_mobile_audit` | Background/foreground, lock, screenshot/capture, pasteboard expiry, and protected-data loss |
| iPhone layouts and input | Native UIKit navigation, tables, alerts, refresh controls, semantic text styles, and hardware-keyboard behavior | iPhone simulator compile for the supported target | Exercise supported iPhone sizes, largest Dynamic Type, VoiceOver, contrast, and hardware keyboard |
The following are intentionally outside this milestone and must not be stubbed
into the Apple apps: full CLI/TUI parity, command mode or command palette, SSH
Git UI, non-TOTP Watch features, and production AutoFill behavior. The Apple
Rust bridge explicitly compiles storage without the optional `ssh` feature;
typed SSH configuration therefore fails as unsupported before any connection
until a future Apple target opts in.
The cross-target and artifact evidence for this exclusion is maintained in
`../docs/ssh-transport-audit.md`.
## Security boundary audit
- `crates/storage` owns repository access, parsing, Git, OTP, authentication,
snapshot transfer, conflict handling, and secret policy.
- The Apple Rust crates are mechanical UniFFI projections. Swift may collect
input and call those APIs; it does not open a password-store repository,
calculate OTP, parse snapshots, or launch processes.
- The shipped Apple bridge rejects non-HTTPS Git transport before connection;
the shared typed storage model may still parse SSH configuration. The Watch
stores only the selected opaque snapshot in a passcode-protected, device-only
Keychain item.
- Apple production sources contain no logging calls. Secret values use typed
redaction/zeroization in Rust; pasteboard values are removed after their Rust
deadline; private QR and authenticated content are hidden during capture or
lifecycle loss.
- The iPhone and AutoFill privacy manifests declare in-container file metadata
access (`C617.1`) and elapsed-time/timer use (`35F9.1`). The Watch manifest
declares no required-reason API because its minimal Rust core does not open
files or use a monotonic timer. No target declares tracking or developer data
collection.
- Face ID and camera access have purpose strings in `project.yml`. No custom
entitlement is currently required: default signing supplies each target's
application identifier and Keychain group, WatchConnectivity needs no added
capability, and no App Group is used.
`cargo test -p ironstorage --test apple_mobile_audit` enforces the source and privacy-manifest
parts of this review so those boundaries cannot silently regress.
## Required automated gates
From the repository root:
```sh
cargo fmt --all -- --check
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
For Apple changes, regenerate the project and compile the exact affected iPhone
and ARM64 Watch simulator targets. Confirm each built app bundle contains its
own `PrivacyInfo.xcprivacy`.
## Manual biometric sequence
Keep the existing booted simulator, app container, bundle identifier, and Face
ID enrollment. For first setup only, choose **Features > Face ID > Enrolled**,
enable Biometric Unlock in IronStorage, unlock one entry, and enter the GPG
passphrase once. Do not toggle enrollment again during normal validation.
For each normal check, tap the entry's **Unlock** control first and then choose
**Features > Face ID > Matching Face**. Verify cancellation and rejection
without changing enrollment. A changed-enrollment check is destructive because
`biometryCurrentSet` invalidates the protected Keychain item; run it only when
the tester is ready to enter the passphrase again.
## Manual evidence still required
Issue #124 / version 1.0.1 build 8: iPhone and Watch simulator builds passed;
the existing iPhone simulator installation was updated in place and Home opened.
Automated Git tests cover move-and-move-back commits, network/authentication
failures, and merge conflicts without a subsequent push. Live account sync is
deferred to the user's TestFlight validation; no physical iPhone was updated.
Issue #120 was exercised on 2026-09-12 using the existing iPhone 17 Pro / iOS
26.5 simulator (`FB5C6929-C10D-40C8-9A94-5CC592037FDB`) and its simulated store.
TOTP Add imported a synthetic QR into `otp/Simulator Panel OTP`; the normal
editor appended a different synthetic QR to `Example Portal`. Both saved,
appeared in the TOTP list, and displayed the expected issuer/account and codes
after reopening. The existing portal username and URL remained unchanged.
Invalid non-OTP QR input was rejected, and Scan QR Code presented the expected
camera-unavailable fallback. Physical camera capture remains for later release
testing; no paired iPhone installation or real local store was used.
The final in-place simulator update preserved saved entries and every untouched
ciphertext file; interrupting an unsaved imported draft created no entry.
Record the device model/OS, build commit, and pass/fail result for each manual
row above. Simulator validation is required during implementation. The final
distribution issue remains open until the approved App Store build is installed
and verified on the paired physical iPhone and Apple Watch. Never overwrite that
installation with a development-signed build, and do not describe a simulator
build or an opened Xcode project as physical-device validation.
+6 -2
View File
@@ -17,9 +17,13 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key> <key>WKCompanionAppBundleIdentifier</key>
<string>de.rfc1437.ironstorage</string> <string>de.rfc1437.ironstorage</string>
</dict> </dict>
+30 -11
View File
@@ -2,37 +2,56 @@
set -euo pipefail set -euo pipefail
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo" export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
export RUSTC="$(rustup which --toolchain stable rustc)"
export RUSTDOC="$(rustup which --toolchain stable rustdoc)"
if [[ "$CONFIGURATION" == "Debug" ]]; then if [[ "$CONFIGURATION" == "Debug" ]]; then
profile=debug profile=debug
else else
profile=release profile=release
fi fi
if [[ "${PLATFORM_NAME:-}" == watch* ]]; then
package=ironstorage-watch-apple
library=ironstorage_watch
else
package=ironstorage-apple
library=ironstorage_apple
fi
libraries=() libraries=()
for arch in $ARCHS; do for arch in $ARCHS; do
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then toolchain=stable
cargo_args=(--locked)
if [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
target=aarch64-apple-watchos-sim
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64" ]]; then
target=aarch64-apple-watchos
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64_32" ]]; then
target=arm64_32-apple-watchos
toolchain=nightly
cargo_args=(-Z build-std=std,panic_abort --locked)
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "x86_64" ]]; then
target=x86_64-apple-watchos-sim
elif [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
target=aarch64-apple-ios-sim target=aarch64-apple-ios-sim
elif [[ "$arch" == "arm64" ]]; then elif [[ "$arch" == "arm64" ]]; then
target=aarch64-apple-ios target=aarch64-apple-ios
elif [[ "$arch" == "x86_64" ]]; then elif [[ "$arch" == "x86_64" ]]; then
target=x86_64-apple-ios target=x86_64-apple-ios
else else
echo "Unsupported iOS architecture: $arch" >&2 echo "Unsupported ${PLATFORM_NAME:-Apple} architecture: $arch" >&2
exit 1 exit 1
fi fi
export RUSTC="$(rustup which --toolchain "$toolchain" rustc)"
export RUSTDOC="$(rustup which --toolchain "$toolchain" rustdoc)"
if [[ "$profile" == "debug" ]]; then if [[ "$profile" == "debug" ]]; then
rustup run stable cargo build --locked \ rustup run "$toolchain" cargo build "${cargo_args[@]}" \
--target "$target" --package ironstorage-apple --lib --target "$target" --package "$package" --lib
else else
rustup run stable cargo build --locked --release \ rustup run "$toolchain" cargo build "${cargo_args[@]}" --release \
--target "$target" --package ironstorage-apple --lib --target "$target" --package "$package" --lib
fi fi
libraries+=("$CARGO_TARGET_DIR/$target/$profile/libironstorage_apple.a") libraries+=("$CARGO_TARGET_DIR/$target/$profile/lib$library.a")
done done
mkdir -p "$DERIVED_FILE_DIR/rust" mkdir -p "$DERIVED_FILE_DIR/rust"
lipo -create -output "$DERIVED_FILE_DIR/rust/libironstorage_apple.a" "${libraries[@]}" lipo -create -output "$DERIVED_FILE_DIR/rust/lib$library.a" "${libraries[@]}"
+37 -4
View File
@@ -4,6 +4,10 @@ options:
settings: settings:
ENABLE_USER_SCRIPT_SANDBOXING: NO ENABLE_USER_SCRIPT_SANDBOXING: NO
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: MU22FMRGK8
MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: "8"
targets: targets:
IronStorage: IronStorage:
type: application type: application
@@ -11,8 +15,6 @@ targets:
deploymentTarget: "17.0" deploymentTarget: "17.0"
settings: settings:
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Bridging-Header.h SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Bridging-Header.h
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust" LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
@@ -21,16 +23,24 @@ targets:
path: Info.plist path: Info.plist
properties: properties:
CFBundleDisplayName: IronStorage CFBundleDisplayName: IronStorage
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations. NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
NSCameraUsageDescription: Scan GPG key transfer QR codes that you choose to import. NSCameraUsageDescription: Scan OTP and GPG key transfer QR codes that you choose to import.
UILaunchScreen: {} UILaunchScreen: {}
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
sources: sources:
- Assets.xcassets - Assets.xcassets
- Resources/App/License.txt
- Resources/App/PrivacyInfo.xcprivacy
- Resources/App/ThirdPartyLicenses.txt
- Sources/App - Sources/App
- Generated/ironstorage_apple.swift - Generated/ironstorage_apple.swift
dependencies:
- target: IronStorageWatch
embed: true
preBuildScripts: preBuildScripts:
- name: Build Rust core - name: Build Rust core
basedOnDependencyAnalysis: false basedOnDependencyAnalysis: false
@@ -53,10 +63,14 @@ targets:
path: AutoFill-Info.plist path: AutoFill-Info.plist
properties: properties:
CFBundleDisplayName: IronStorage AutoFill CFBundleDisplayName: IronStorage AutoFill
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption: false
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController
sources: sources:
- Resources/AutoFill/PrivacyInfo.xcprivacy
- Sources/AutoFill - Sources/AutoFill
- Generated/ironstorage_apple.swift - Generated/ironstorage_apple.swift
preBuildScripts: preBuildScripts:
@@ -69,15 +83,34 @@ targets:
IronStorageWatch: IronStorageWatch:
type: application type: application
platform: watchOS platform: watchOS
deploymentTarget: "10.0" deploymentTarget: "26.0"
settings: settings:
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch
PRODUCT_NAME: IronStorage Watch PRODUCT_NAME: IronStorage Watch
SKIP_INSTALL: YES SKIP_INSTALL: YES
"ARCHS[sdk=watchos*]": "arm64 arm64_32"
"EXCLUDED_ARCHS[sdk=watchsimulator*]": x86_64
SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Watch-Bridging-Header.h
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
OTHER_LDFLAGS: "$(inherited) -lironstorage_watch"
info: info:
path: Watch-Info.plist path: Watch-Info.plist
properties: properties:
CFBundleDisplayName: IronStorage CFBundleDisplayName: IronStorage
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption: false
WKApplication: true
WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage
sources: sources:
- Assets.xcassets
- Resources/Watch/PrivacyInfo.xcprivacy
- Sources/Watch - Sources/Watch
- Generated/ironstorage_watch.swift
preBuildScripts:
- name: Build Rust Watch core
basedOnDependencyAnalysis: false
script: |
./build_rust_core.bash
outputFiles:
- $(DERIVED_FILE_DIR)/rust/libironstorage_watch.a
+3 -1
View File
@@ -4,6 +4,8 @@ description = "Pass-compatible command-line frontend for IronStorage"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[[bin]] [[bin]]
@@ -12,7 +14,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
ctrlc.workspace = true ctrlc.workspace = true
ironstorage.workspace = true ironstorage = { workspace = true, features = ["ssh"] }
rpassword.workspace = true rpassword.workspace = true
tempfile = "3" tempfile = "3"
+177 -24
View File
@@ -22,10 +22,13 @@ use ironstorage::{
InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text, InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text,
otp_version_text, parse_from, version_text, otp_version_text, parse_from, version_text,
}, },
config::Config, config::{Config, GitRemote, SshFingerprint},
crypto::KeyStore, crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator}, generate::{GeneratorConfig, PasswordGenerator},
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, git::{
GitChangeKind, GitError, GitIdentity, GitRemoteCredentialOverride,
GitRemoteCredentialProvider, GitRepository, PullOutcome,
},
kdbx::KdbxImporter, kdbx::KdbxImporter,
mutation::{ mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
@@ -718,7 +721,7 @@ fn execute_secure_with_services<
Some(configured) => configured, Some(configured) => configured,
None => { None => {
stderr stderr
.write_all(b"the requested HTTPS Git remote is not configured\n") .write_all(b"the requested Git remote is not configured\n")
.map_err(|_| ())?; .map_err(|_| ())?;
return Ok(EXIT_CONFIG); return Ok(EXIT_CONFIG);
} }
@@ -727,30 +730,86 @@ fn execute_secure_with_services<
Ok(git) => git, Ok(git) => git,
Err(error) => return operation_error(stderr, error), Err(error) => return operation_error(stderr, error),
}; };
let result = match request { let mut supplied_passphrase = None;
GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None), let mut confirmed_host = false;
GitRequest::Pull { branch, .. } => git let result = loop {
.pull(configured, branch.as_deref(), secrets) let credentials = supplied_passphrase.as_ref().map_or_else(
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())), || GitRemoteCredentialOverride::new(&*secrets),
GitRequest::Push { branch, .. } => git |(fingerprint, passphrase)| {
.push(configured, branch.as_deref(), secrets) GitRemoteCredentialOverride::with_ssh_passphrase(
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))), &*secrets,
GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| { fingerprint,
Some(format!( passphrase,
"{}; {} {}", )
pull_outcome_name(pull), },
push.remote(), );
push.new_id() match run_remote_git(&git, configured, request, &credentials) {
)) Err(GitError::UnknownSshHostKey { host_key }) if !confirmed_host => {
}), let decision = interaction.confirm(
_ => unreachable!("local Git requests do not require secret access"), &format!(
"Trust SSH host {}:{} {} {}?",
host_key.host(),
host_key.port(),
host_key.algorithm(),
host_key.fingerprint()
),
stderr,
);
match decision {
Ok(OverwriteDecision::Allow) => {
if let Err(error) =
ironstorage::git::confirm_ssh_host(configured, &host_key)
{
break Err(error);
}
confirmed_host = true;
}
Ok(OverwriteDecision::Decline) => break Err(GitError::Cancelled),
Err(_) => break Err(GitError::Cancelled),
}
}
Err(GitError::SshKeyPassphraseUnavailable { fingerprint })
if supplied_passphrase.is_none() =>
{
let passphrase = match interaction.read_ssh_passphrase(&fingerprint) {
Ok(passphrase) => passphrase,
Err(_) => {
break Err(GitError::SshKeyPassphraseCancelled { fingerprint });
}
};
supplied_passphrase = Some((fingerprint, passphrase));
}
result => break result,
}
}; };
match result { match result {
Ok(Some(message)) => { Ok(Some(message)) => {
if let Some((fingerprint, passphrase)) = supplied_passphrase
&& let Err(error) =
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
{
writeln!(
stderr,
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
)
.map_err(|_| ())?;
}
writeln!(stdout, "{message}").map_err(|_| ())?; writeln!(stdout, "{message}").map_err(|_| ())?;
Ok(EXIT_SUCCESS) Ok(EXIT_SUCCESS)
} }
Ok(None) => Ok(EXIT_SUCCESS), Ok(None) => {
if let Some((fingerprint, passphrase)) = supplied_passphrase
&& let Err(error) =
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
{
writeln!(
stderr,
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
)
.map_err(|_| ())?;
}
Ok(EXIT_SUCCESS)
}
Err(error) => operation_error(stderr, error), Err(error) => operation_error(stderr, error),
} }
} }
@@ -1068,6 +1127,11 @@ trait CliInteraction: OtpInteraction {
plaintext: &SecretBytes, plaintext: &SecretBytes,
config: &Config, config: &Config,
) -> Result<(SecretBytes, String), CliInteractionError>; ) -> Result<(SecretBytes, String), CliInteractionError>;
fn read_ssh_passphrase(
&mut self,
fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError>;
} }
struct NativeOtpInteraction; struct NativeOtpInteraction;
@@ -1217,6 +1281,18 @@ impl CliInteraction for NativeOtpInteraction {
.map_err(CliInteractionError::Editor)?; .map_err(CliInteractionError::Editor)?;
Ok((replacement, name)) Ok((replacement, name))
} }
fn read_ssh_passphrase(
&mut self,
fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError> {
if !std::io::stdin().is_terminal() {
return Err(CliInteractionError::Input);
}
rpassword::prompt_password(format!("Enter SSH key passphrase for {fingerprint}: "))
.map(|value| SecretBytes::new(value.into_bytes()))
.map_err(|_| CliInteractionError::Input)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1269,6 +1345,13 @@ impl CliInteraction for UnavailableOtpInteraction {
) -> Result<(SecretBytes, String), CliInteractionError> { ) -> Result<(SecretBytes, String), CliInteractionError> {
Err(CliInteractionError::Input) Err(CliInteractionError::Input)
} }
fn read_ssh_passphrase(
&mut self,
_fingerprint: &SshFingerprint,
) -> Result<SecretBytes, CliInteractionError> {
Err(CliInteractionError::Input)
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -1548,6 +1631,32 @@ fn select_remote<'a>(
} }
} }
fn run_remote_git(
git: &GitRepository,
configured: &GitRemote,
request: &GitRequest,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<Option<String>, GitError> {
match request {
GitRequest::Fetch { .. } => git.fetch(configured, credentials).map(|_| None),
GitRequest::Pull { branch, .. } => git
.pull(configured, branch.as_deref(), credentials)
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
GitRequest::Push { branch, .. } => git
.push(configured, branch.as_deref(), credentials)
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
GitRequest::Sync { .. } => git.sync(configured, credentials).map(|(pull, push)| {
Some(format!(
"{}; {} {}",
pull_outcome_name(pull),
push.remote(),
push.new_id()
))
}),
_ => unreachable!("local Git requests do not require secret access"),
}
}
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> { fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
writeln!(stderr, "{error}").map_err(|_| ())?; writeln!(stderr, "{error}").map_err(|_| ())?;
Ok(EXIT_FAILURE) Ok(EXIT_FAILURE)
@@ -1652,6 +1761,7 @@ mod tests {
insert_inputs: VecDeque<ironstorage::write::InsertContent>, insert_inputs: VecDeque<ironstorage::write::InsertContent>,
edit_replacements: VecDeque<SecretBytes>, edit_replacements: VecDeque<SecretBytes>,
kdbx_passwords: VecDeque<SecretBytes>, kdbx_passwords: VecDeque<SecretBytes>,
ssh_passphrases: VecDeque<SecretBytes>,
decisions: VecDeque<OverwriteDecision>, decisions: VecDeque<OverwriteDecision>,
plans: Vec<InputPlan>, plans: Vec<InputPlan>,
prompts: Vec<String>, prompts: Vec<String>,
@@ -1715,6 +1825,15 @@ mod tests {
.map(|replacement| (replacement, "fixture-editor".to_owned())) .map(|replacement| (replacement, "fixture-editor".to_owned()))
.ok_or(super::CliInteractionError::Input) .ok_or(super::CliInteractionError::Input)
} }
fn read_ssh_passphrase(
&mut self,
_fingerprint: &super::SshFingerprint,
) -> Result<SecretBytes, super::CliInteractionError> {
self.ssh_passphrases
.pop_front()
.ok_or(super::CliInteractionError::Input)
}
} }
impl CliPresentation for MemoryPresentation { impl CliPresentation for MemoryPresentation {
@@ -1747,6 +1866,39 @@ mod tests {
} }
} }
#[test]
fn cli_selects_typed_ssh_remotes_and_keeps_prompted_passphrases_redacted() -> TestResult {
let temporary = tempfile::tempdir()?;
fs::create_dir(temporary.path().join("vault"))?;
fs::create_dir(temporary.path().join("keys"))?;
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'scp'\nurl = 'git@example.test:team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n[[git.remotes]]\nname = 'uri'\nurl = 'ssh://git@example.test/team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n",
)?;
let config = Config::load(Some(&config_path))?;
for name in ["scp", "uri"] {
let remote = super::select_remote(&config, Some(name)).expect("configured SSH remote");
assert_eq!(
remote.endpoint().transport(),
ironstorage::config::RemoteTransport::Ssh
);
}
let fingerprint =
super::SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
let mut interaction = MemoryOtpInteraction::default();
interaction
.ssh_passphrases
.push_back(SecretBytes::new(b"terminal secret".to_vec()));
assert!(!format!("{:?}", interaction.ssh_passphrases).contains("terminal secret"));
assert_eq!(
super::CliInteraction::read_ssh_passphrase(&mut interaction, &fingerprint)?.expose(),
b"terminal secret"
);
Ok(())
}
impl SecretStoreBackend for MemoryBackend { impl SecretStoreBackend for MemoryBackend {
fn create( fn create(
&self, &self,
@@ -1939,15 +2091,16 @@ mod tests {
assert!(stderr.is_empty()); assert!(stderr.is_empty());
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS remote");
secrets.create( secrets.create(
&SecretReference::https_git_credential( &SecretReference::https_git_credential(
remote.server_id().as_str(), server_id.as_str(),
remote.application_id().as_str(), application_id.as_str(),
"fixture-account", "fixture-account",
)?, )?,
SecretBytes::new(b"fixture-token".to_vec()), SecretBytes::new(b"fixture-token".to_vec()),
)?; )?;
let credential = secrets.credential(remote.server_id(), remote.application_id())?; let credential = secrets.credential(server_id, application_id)?;
assert_eq!(credential.username(), "fixture-account"); assert_eq!(credential.username(), "fixture-account");
assert_eq!(credential.password(), b"fixture-token"); assert_eq!(credential.password(), b"fixture-token");
Ok(()) Ok(())
+5 -1
View File
@@ -4,6 +4,8 @@ description = "Iced desktop frontend for IronStorage"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[package.metadata.packager] [package.metadata.packager]
@@ -27,7 +29,9 @@ path = "src/main.rs"
[dependencies] [dependencies]
iced.workspace = true iced.workspace = true
ironstorage.workspace = true iced_aw.workspace = true
ironstorage = { workspace = true, features = ["ssh"] }
tokio.workspace = true
zeroize.workspace = true zeroize.workspace = true
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
+3 -3
View File
@@ -60,7 +60,7 @@ async fn pick_file(
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> { pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
use ashpd::{ use ashpd::{
PortalError, PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError}, desktop::{ResponseError, file_chooser::SelectedFiles},
}; };
let mut request = SelectedFiles::open_file() let mut request = SelectedFiles::open_file()
@@ -93,7 +93,7 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> { pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
use ashpd::{ use ashpd::{
PortalError, PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError}, desktop::{ResponseError, file_chooser::SelectedFiles},
}; };
let response = SelectedFiles::open_file() let response = SelectedFiles::open_file()
@@ -134,7 +134,7 @@ pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, String> { async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, String> {
use ashpd::{ use ashpd::{
PortalError, PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError}, desktop::{ResponseError, file_chooser::SelectedFiles},
}; };
let response = SelectedFiles::open_file() let response = SelectedFiles::open_file()
+377 -64
View File
@@ -31,12 +31,14 @@ use iced::{
}, },
window, window,
}; };
use iced_aw::menu::{Item, Menu, MenuBar};
use ironstorage::{ use ironstorage::{
authentication::{ authentication::{
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
NativeAuthenticationHandle, NativeAuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession,
}, },
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
desktop::{ desktop::{
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult, DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
@@ -50,7 +52,7 @@ use ironstorage::{
generate::GeneratorConfig, generate::GeneratorConfig,
git::{ git::{
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl, GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
GitProgressPhase, GitSnapshot, GitProgressPhase, GitSnapshot, SshHostKey,
}, },
kdbx::{KdbxImportMode, KdbxImportRequest}, kdbx::{KdbxImportMode, KdbxImportRequest},
mutation::{MutationAction, MutationOutcome, MutationSelection}, mutation::{MutationAction, MutationOutcome, MutationSelection},
@@ -97,7 +99,6 @@ struct RecipientSuccess {
enum Message { enum Message {
Action(UiAction), Action(UiAction),
FieldAction(EntryFieldId, UiAction), FieldAction(EntryFieldId, UiAction),
ToggleMenu(MenuGroup),
PaletteQueryChanged(String), PaletteQueryChanged(String),
PaletteCancel, PaletteCancel,
PaletteInvoke(UiAction), PaletteInvoke(UiAction),
@@ -160,6 +161,9 @@ enum Message {
result: Box<Result<MutationOutcome, String>>, result: Box<Result<MutationOutcome, String>>,
}, },
RunGit(DesktopGitRequest), RunGit(DesktopGitRequest),
GitPassphraseChanged(Zeroizing<String>),
SubmitGitInteraction,
CancelGitInteraction,
ChooseGitConflict(usize, GitConflictChoice), ChooseGitConflict(usize, GitConflictChoice),
ResolveGitConflicts, ResolveGitConflicts,
CancelGit, CancelGit,
@@ -354,7 +358,6 @@ struct App {
generation_form: Option<GenerateForm>, generation_form: Option<GenerateForm>,
conflict: bool, conflict: bool,
status: String, status: String,
open_menu: Option<MenuGroup>,
utility: Option<UtilityView>, utility: Option<UtilityView>,
context_target: Option<TreeNodeId>, context_target: Option<TreeNodeId>,
palette: CommandPalette, palette: CommandPalette,
@@ -521,13 +524,55 @@ struct GitConflictSelection {
choice: Option<GitConflictChoice>, choice: Option<GitConflictChoice>,
} }
#[derive(Clone, Debug, Default)] enum GitInteraction {
ConfirmHost {
request: DesktopGitRequest,
host_key: SshHostKey,
},
Passphrase {
request: DesktopGitRequest,
fingerprint: SshFingerprint,
value: Zeroizing<String>,
},
}
impl std::fmt::Debug for GitInteraction {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfirmHost { request, host_key } => formatter
.debug_struct("ConfirmHost")
.field("request", request)
.field("host_key", host_key)
.finish(),
Self::Passphrase {
request,
fingerprint,
..
} => formatter
.debug_struct("Passphrase")
.field("request", request)
.field("fingerprint", fingerprint)
.field("value", &"[REDACTED]")
.finish(),
}
}
}
#[derive(Debug, Default)]
struct GitForm { struct GitForm {
snapshot: Option<GitSnapshot>, snapshot: Option<GitSnapshot>,
progress: Option<GitProgressPhase>, progress: Option<GitProgressPhase>,
running: bool, running: bool,
error: Option<String>, error: Option<String>,
conflicts: Vec<GitConflictSelection>, conflicts: Vec<GitConflictSelection>,
active_request: Option<DesktopGitRequest>,
interaction: Option<GitInteraction>,
}
enum GitRetry {
Normal,
ConfirmHost(SshHostKey),
Passphrase(SshFingerprint, SecretBytes),
} }
impl GitForm { impl GitForm {
@@ -939,11 +984,37 @@ fn main() -> iced::Result {
iced::application(App::new, App::update, App::view) iced::application(App::new, App::update, App::view)
.title(ironstorage::PRODUCT_NAME) .title(ironstorage::PRODUCT_NAME)
.subscription(App::subscription) .subscription(App::subscription)
.theme(app_theme)
.exit_on_close_request(false) .exit_on_close_request(false)
.window(window_settings()) .window(window_settings())
.run() .run()
} }
const APP_BACKGROUND: Color = Color::from_rgb8(0x18, 0x18, 0x18);
const SURFACE_BACKGROUND: Color = Color::from_rgb8(0x20, 0x20, 0x20);
const HOVER_BACKGROUND: Color = Color::from_rgb8(0x2A, 0x2A, 0x2A);
const BORDER_COLOR: Color = Color::from_rgb8(0x3A, 0x3A, 0x3A);
const FOCUS_COLOR: Color = Color::from_rgb8(0x00, 0x7F, 0xD4);
const TEXT_COLOR: Color = Color::from_rgb8(0xD0, 0xD0, 0xD0);
const PRIMARY_COLOR: Color = Color::from_rgb8(0x0E, 0x63, 0x9C);
const SUCCESS_COLOR: Color = Color::from_rgb8(0x2E, 0x7D, 0x32);
const WARNING_COLOR: Color = Color::from_rgb8(0xC9, 0x8A, 0x20);
const DANGER_COLOR: Color = Color::from_rgb8(0xB6, 0x23, 0x24);
fn app_theme(_app: &App) -> Theme {
Theme::custom(
"IronStorage".to_owned(),
iced::theme::Palette {
background: APP_BACKGROUND,
text: TEXT_COLOR,
primary: PRIMARY_COLOR,
success: SUCCESS_COLOR,
warning: WARNING_COLOR,
danger: DANGER_COLOR,
},
)
}
fn window_settings() -> window::Settings { fn window_settings() -> window::Settings {
window::Settings { window::Settings {
size: Size::new(960.0, 680.0), size: Size::new(960.0, 680.0),
@@ -992,7 +1063,6 @@ impl App {
generation_form: None, generation_form: None,
conflict: false, conflict: false,
status: "Loading shared configuration…".to_owned(), status: "Loading shared configuration…".to_owned(),
open_menu: None,
utility: None, utility: None,
context_target: None, context_target: None,
palette: CommandPalette::default(), palette: CommandPalette::default(),
@@ -1025,9 +1095,6 @@ impl App {
} }
return self.invoke_action(action); return self.invoke_action(action);
} }
Message::ToggleMenu(group) => {
self.open_menu = (self.open_menu != Some(group)).then_some(group);
}
Message::PaletteQueryChanged(query) => { Message::PaletteQueryChanged(query) => {
self.palette.update_query(query); self.palette.update_query(query);
return iced::widget::operation::snap_to( return iced::widget::operation::snap_to(
@@ -1648,6 +1715,56 @@ impl App {
self.execute_action(pending) self.execute_action(pending)
}; };
} }
Message::GitPassphraseChanged(value) => {
if let Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::Passphrase { value: current, .. }),
running: false,
..
})) = &mut self.utility
{
*current = value;
}
}
Message::SubmitGitInteraction => {
let interaction = match &mut self.utility {
Some(UtilityView::Git(form)) if !form.running => form.interaction.take(),
_ => None,
};
match interaction {
Some(GitInteraction::ConfirmHost { request, host_key }) => {
return self.begin_git_retry(request, GitRetry::ConfirmHost(host_key));
}
Some(GitInteraction::Passphrase {
request,
fingerprint,
value,
}) if !value.is_empty() => {
return self.begin_git_retry(
request,
GitRetry::Passphrase(
fingerprint,
SecretBytes::new(value.as_bytes().to_vec()),
),
);
}
Some(interaction) => {
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.interaction = Some(interaction);
form.error = Some("Enter the SSH key passphrase.".to_owned());
}
}
None => {}
}
}
Message::CancelGitInteraction => {
if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running
&& form.interaction.take().is_some()
{
form.error = None;
self.status = "SSH authentication cancelled; repository unchanged.".to_owned();
}
}
Message::ChooseGitConflict(index, choice) => { Message::ChooseGitConflict(index, choice) => {
if let Some(UtilityView::Git(form)) = &mut self.utility if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running && !form.running
@@ -1713,10 +1830,34 @@ impl App {
form.running = false; form.running = false;
form.error = None; form.error = None;
form.conflicts.clear(); form.conflicts.clear();
form.active_request = None;
form.interaction = None;
} }
self.status = git_outcome_message(&outcome); self.status = git_outcome_message(&outcome);
} }
Err(error) => { Err(error) => {
let interaction = if let Some(UtilityView::Git(form)) = &mut self.utility {
let request = form.active_request.take();
match (request, error.git_error()) {
(Some(request), Some(GitError::UnknownSshHostKey { host_key })) => {
Some(GitInteraction::ConfirmHost {
request,
host_key: (**host_key).clone(),
})
}
(
Some(request),
Some(GitError::SshKeyPassphraseUnavailable { fingerprint }),
) => Some(GitInteraction::Passphrase {
request,
fingerprint: fingerprint.clone(),
value: Zeroizing::new(String::new()),
}),
_ => None,
}
} else {
None
};
let conflicts = error let conflicts = error
.conflicts() .conflicts()
.iter() .iter()
@@ -1730,10 +1871,27 @@ impl App {
if let Some(UtilityView::Git(form)) = &mut self.utility { if let Some(UtilityView::Git(form)) = &mut self.utility {
form.progress = None; form.progress = None;
form.running = false; form.running = false;
form.error = Some(message.clone()); form.error = interaction.is_none().then(|| message.clone());
form.conflicts = conflicts; form.conflicts = conflicts;
form.interaction = interaction;
} }
self.status = if error.kind() == DesktopErrorKind::Conflict { self.status = if matches!(
&self.utility,
Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::ConfirmHost { .. }),
..
}))
) {
"Confirm the unknown SSH host fingerprint before retrying.".to_owned()
} else if matches!(
&self.utility,
Some(UtilityView::Git(GitForm {
interaction: Some(GitInteraction::Passphrase { .. }),
..
}))
) {
"Enter the encrypted SSH key passphrase to retry.".to_owned()
} else if error.kind() == DesktopErrorKind::Conflict {
format!("Git requires explicit conflict resolution: {message}") format!("Git requires explicit conflict resolution: {message}")
} else { } else {
format!("Git operation failed: {message}") format!("Git operation failed: {message}")
@@ -2517,7 +2675,6 @@ impl App {
} }
fn invoke_action(&mut self, action: UiAction) -> Task<Message> { fn invoke_action(&mut self, action: UiAction) -> Task<Message> {
self.open_menu = None;
if !action::enabled(action, self.action_context()) { if !action::enabled(action, self.action_context()) {
if self.content_mode == ContentMode::Editor if self.content_mode == ContentMode::Editor
&& matches!( && matches!(
@@ -2707,7 +2864,6 @@ impl App {
self.status = reason.to_owned(); self.status = reason.to_owned();
return Task::none(); return Task::none();
} }
self.open_menu = None;
self.utility = None; self.utility = None;
self.palette.open(); self.palette.open();
iced::widget::operation::focus(command_palette_input_id()) iced::widget::operation::focus(command_palette_input_id())
@@ -2978,6 +3134,10 @@ impl App {
} }
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> { fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> {
self.begin_git_retry(request, GitRetry::Normal)
}
fn begin_git_retry(&mut self, request: DesktopGitRequest, retry: GitRetry) -> Task<Message> {
let Some(storage) = self.storage.clone() else { let Some(storage) = self.storage.clone() else {
return Task::none(); return Task::none();
}; };
@@ -3001,15 +3161,34 @@ impl App {
form.progress = Some(GitProgressPhase::Validating); form.progress = Some(GitProgressPhase::Validating);
form.running = true; form.running = true;
form.error = None; form.error = None;
form.interaction = None;
form.active_request = Some(request.clone());
} }
self.status = format!("Git {}…", git_request_name(&request)); self.status = format!("Git {}…", git_request_name(&request));
Task::perform( Task::perform(
async move { async move {
Arc::new(Mutex::new(Some(storage.git_operation( let result = match retry {
GitRetry::Normal => storage.git_operation(handle.as_ref(), &request, &control),
GitRetry::ConfirmHost(host_key) => storage
.confirm_ssh_host(&host_key)
.and_then(|()| storage.git_operation(handle.as_ref(), &request, &control)),
GitRetry::Passphrase(fingerprint, passphrase) => {
let result = storage.git_operation_with_ssh_passphrase(
handle.as_ref(), handle.as_ref(),
&request, &request,
&control, &control,
)))) Some((&fingerprint, &passphrase)),
);
if result.is_ok()
&& let Some(handle) = handle.as_ref()
{
let _ =
handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
}
result
}
};
Arc::new(Mutex::new(Some(result)))
}, },
move |completion| Message::GitFinished { move |completion| Message::GitFinished {
generation, generation,
@@ -3569,13 +3748,18 @@ impl App {
} }
fn subscription(&self) -> Subscription<Message> { fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = vec![ let subscriptions = vec![
time::every(Duration::from_secs(1)).map(|_| Message::Tick), time::every(Duration::from_secs(1)).map(|_| Message::Tick),
event::listen_with(|event, _status, _window| event_message(&event)), event::listen_with(|event, _status, _window| event_message(&event)),
window::close_requests().map(Message::RequestClose), window::close_requests().map(Message::RequestClose),
]; ];
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
subscriptions.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu)); let subscriptions = {
let mut subscriptions = subscriptions;
subscriptions
.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu));
subscriptions
};
Subscription::batch(subscriptions) Subscription::batch(subscriptions)
} }
@@ -3966,14 +4150,14 @@ fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static,
.into() .into()
} }
const ENTRY_AREA_BACKGROUND: Color = Color::from_rgb8(18, 18, 20); const ENTRY_AREA_BACKGROUND: Color = APP_BACKGROUND;
const ENTRY_FIELD_BACKGROUND: Color = Color::from_rgb8(24, 24, 27); const ENTRY_FIELD_BACKGROUND: Color = SURFACE_BACKGROUND;
const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = Color::from_rgb8(47, 47, 52); const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = HOVER_BACKGROUND;
const ENTRY_INPUT_BACKGROUND: Color = Color::from_rgb8(12, 12, 14); const ENTRY_INPUT_BACKGROUND: Color = APP_BACKGROUND;
const ENTRY_TEXT: Color = Color::from_rgb8(235, 235, 238); const ENTRY_TEXT: Color = Color::from_rgb8(0xE4, 0xE4, 0xE4);
const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(165, 165, 172); const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(0x7D, 0x7D, 0x7D);
const ENTRY_BORDER: Color = Color::from_rgb8(72, 72, 78); const ENTRY_BORDER: Color = BORDER_COLOR;
const ENTRY_FOCUSED_BORDER: Color = Color::from_rgb8(145, 145, 152); const ENTRY_FOCUSED_BORDER: Color = FOCUS_COLOR;
const ENTRY_LABEL_WIDTH: f32 = 128.0; const ENTRY_LABEL_WIDTH: f32 = 128.0;
fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> { fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> {
@@ -4149,41 +4333,44 @@ fn platform_menu_bar(app: &App) -> Element<'_, Message> {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
return container(row![]).height(Length::Fixed(0.0)).into(); return container(row![]).height(Length::Fixed(0.0)).into();
} }
let mut headers = row![].spacing(2).padding([0, 8]);
for group in MenuGroup::ALL {
headers = headers.push(
button(group.label())
.on_press(Message::ToggleMenu(group))
.style(if app.open_menu == Some(group) {
button::primary
} else {
button::text
}),
);
}
let mut menu = column![headers].spacing(4);
if let Some(group) = app.open_menu {
let context = app.action_context(); let context = app.action_context();
let mut actions = row![].spacing(4).padding([6, 8]); let mut menus = Vec::new();
for spec in action::actions_in(group) { for group in MenuGroup::ALL {
let width = match group {
MenuGroup::Window => 140.0,
MenuGroup::Help => 180.0,
_ => 240.0,
};
let items = action::actions_in(group)
.map(|spec| {
let label = action::shortcut_label(spec.action).map_or_else( let label = action::shortcut_label(spec.action).map_or_else(
|| spec.label.to_owned(), || spec.label.to_owned(),
|key| format!("{} {key}", spec.label), |key| format!("{} {key}", spec.label),
); );
let item = button(text(label)); let item = button(
actions = actions.push(if action::enabled(spec.action, context) { text(label)
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Left),
)
.width(width)
.padding([3, 8])
.style(button::text);
Item::new(if action::enabled(spec.action, context) {
item.on_press(Message::Action(spec.action)) item.on_press(Message::Action(spec.action))
} else { } else {
item item
}); })
})
.collect();
menus.push(Item::with_menu(
button(group.label()).padding([3, 8]).style(button::text),
Menu::new(items).width(width),
));
} }
menu = menu.push( MenuBar::new(menus)
scrollable(actions).direction(scrollable::Direction::Horizontal( .close_on_item_click(true)
scrollable::Scrollbar::default(), .close_on_background_click(true)
)), .into()
);
}
container(menu).width(Length::Fill).into()
} }
fn utility_title(utility: &UtilityView) -> &'static str { fn utility_title(utility: &UtilityView) -> &'static str {
@@ -4610,7 +4797,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
} }
UtilityView::Git(form) => { UtilityView::Git(form) => {
content = content.push(text( content = content.push(text(
"All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.", "All repository, transport, credential, host-trust, merge, and conflict decisions are owned by crates/storage. No git, SSH, or credential-helper process is launched.",
)); ));
if let Some(phase) = form.progress { if let Some(phase) = form.progress {
content = content.push(text(format!("Progress: {}", git_phase_name(phase)))); content = content.push(text(format!("Progress: {}", git_phase_name(phase))));
@@ -4618,20 +4805,57 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
if let Some(error) = &form.error { if let Some(error) = &form.error {
content = content.push(text(format!("Git error: {error}"))); content = content.push(text(format!("Git error: {error}")));
} }
match &form.interaction {
Some(GitInteraction::ConfirmHost { host_key, .. }) => {
content = content
.push(text("Unknown SSH host key").size(20))
.push(text(format!(
"Host: {}:{}",
host_key.host(),
host_key.port()
)))
.push(text(format!("Algorithm: {}", host_key.algorithm())))
.push(text(format!("Fingerprint: {}", host_key.fingerprint())))
.push(text(
"Verify this fingerprint through a trusted channel. Confirmation appends only this observed key; changed keys cannot be confirmed here.",
));
}
Some(GitInteraction::Passphrase {
fingerprint, value, ..
}) => {
content = content
.push(text("Encrypted SSH private key").size(20))
.push(text(format!("Key fingerprint: {fingerprint}")))
.push(
text_input("SSH key passphrase", value)
.secure(true)
.on_input(|value| {
Message::GitPassphraseChanged(Zeroizing::new(value))
})
.on_submit(Message::SubmitGitInteraction)
.style(entry_input_style),
)
.push(text(
"The passphrase is supplied to crates/storage for this retry and is never shown, logged, copied, or added to command history.",
));
}
None => {}
}
if let Some(snapshot) = &form.snapshot { if let Some(snapshot) = &form.snapshot {
content = content content = content
.push(text(format!("Repository: {}", snapshot.root().display()))) .push(text(format!("Repository: {}", snapshot.root().display())))
.push(text(format!("Branch: {}", snapshot.branch()))); .push(text(format!("Branch: {}", snapshot.branch())));
if let Some(remote) = snapshot.remote() { if let Some(remote) = snapshot.remote() {
content = content.push(text(format!( content = content.push(text(format!(
"HTTPS remote: {} · {} · {} ahead / {} behind", "{} remote: {} · {} · {} ahead / {} behind",
remote.transport(),
remote.name(), remote.name(),
remote.url(), remote.url(),
remote.ahead(), remote.ahead(),
remote.behind() remote.behind()
))); )));
} else { } else {
content = content.push(text("No HTTPS remote is configured.")); content = content.push(text("No Git remote is configured."));
} }
let status = snapshot.status(); let status = snapshot.status();
content = content.push(text(if status.is_clean() { content = content.push(text(if status.is_clean() {
@@ -4884,7 +5108,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
)) ))
.push(text("Git actions").size(22)) .push(text("Git actions").size(22))
.push(text( .push(text(
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.", "Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation for configured HTTPS and SSH remotes. Unknown SSH hosts show their fingerprint for explicit confirmation; changed keys cannot be bypassed. Encrypted key passphrases use masked input. Conflicts require an explicit choice and are never silently discarded.",
)) ))
.push(text("OTP actions").size(22)) .push(text("OTP actions").size(22))
.push(text( .push(text(
@@ -4961,6 +5185,16 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
UtilityView::Git(form) => { UtilityView::Git(form) => {
if form.running { if form.running {
row![utility_button("Cancel Git operation").on_press(Message::CancelGit)] row![utility_button("Cancel Git operation").on_press(Message::CancelGit)]
} else if let Some(interaction) = &form.interaction {
row![
utility_button(match interaction {
GitInteraction::ConfirmHost { .. } => "Trust host and retry",
GitInteraction::Passphrase { .. } => "Unlock key and retry",
})
.on_press(Message::SubmitGitInteraction),
utility_button("Cancel").on_press(Message::CancelGitInteraction),
]
.spacing(8)
} else { } else {
let mut actions = row![ let mut actions = row![
utility_icon_control( utility_icon_control(
@@ -5847,8 +6081,8 @@ fn git_request_name(request: &DesktopGitRequest) -> &'static str {
fn git_phase_name(phase: GitProgressPhase) -> &'static str { fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase { match phase {
GitProgressPhase::Validating => "validating the repository and HTTPS remote", GitProgressPhase::Validating => "validating the repository and remote",
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials", GitProgressPhase::Authenticating => "verifying remote trust and credentials",
GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects", GitProgressPhase::Sending => "sending local objects",
@@ -5896,11 +6130,52 @@ fn git_failure_message(error: &DesktopError) -> String {
"No embedded Git repository exists for this password store.".to_owned() "No embedded Git repository exists for this password store.".to_owned()
} }
Some(GitError::ForbiddenRemoteUrl) => { Some(GitError::ForbiddenRemoteUrl) => {
"The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned() "The repository remote must match the configured credential-free Git URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
} }
Some(GitError::RemoteNotFound { name }) => { Some(GitError::RemoteNotFound { name }) => {
format!("The configured HTTPS remote {name} is missing from the repository.") format!("The configured Git remote {name} is missing from the repository.")
} }
Some(GitError::UnsupportedRemoteTransport { transport }) => {
format!("This build does not include the {transport} Git transport.")
}
Some(GitError::SshAuthenticationNotConfigured) => {
"The SSH remote needs one configured identity file or exact agent fingerprint."
.to_owned()
}
Some(GitError::SshIdentityMissing { path }) => {
format!("The configured SSH identity file is missing: {}.", path.display())
}
Some(GitError::SshIdentityInvalid { path }) => {
format!("The configured SSH identity file is invalid: {}.", path.display())
}
Some(GitError::SshKeyPassphraseDenied { .. }) => {
"Access to the stored SSH key passphrase was denied.".to_owned()
}
Some(GitError::SshKeyPassphraseCancelled { .. }) => {
"The SSH key passphrase request was cancelled.".to_owned()
}
Some(GitError::SshKeyPassphraseRejected { .. }) => {
"The supplied SSH key passphrase did not unlock the configured key.".to_owned()
}
Some(GitError::SshAgentUnavailable) => {
"The configured SSH agent socket is unavailable.".to_owned()
}
Some(GitError::SshAgentIdentityMissing { fingerprint }) => {
format!("The SSH agent does not contain the configured identity {fingerprint}.")
}
Some(GitError::SshAuthenticationRejected) => {
"The SSH server rejected the configured public-key identity.".to_owned()
}
Some(GitError::ChangedSshHostKey { host_key, line }) => format!(
"SSH host key changed for {}:{} ({} at known-hosts line {line}). This cannot be bypassed; verify and repair known hosts outside this prompt.",
host_key.host(),
host_key.port(),
host_key.fingerprint()
),
Some(GitError::SshKnownHostsUnavailable { path }) => format!(
"The SSH known-hosts file is unavailable: {}.",
path.display()
),
Some(GitError::CredentialsUnavailable) => { Some(GitError::CredentialsUnavailable) => {
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned() "HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned()
} }
@@ -5914,7 +6189,7 @@ fn git_failure_message(error: &DesktopError) -> String {
"The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned() "The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
} }
Some(GitError::NetworkUnavailable) => { Some(GitError::NetworkUnavailable) => {
"The HTTPS Git server is unreachable; check the network and retry.".to_owned() "The Git server is unreachable; check the network and retry.".to_owned()
} }
Some(GitError::TlsFailed) => { Some(GitError::TlsFailed) => {
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned() "TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
@@ -5996,9 +6271,13 @@ fn parse_generation_length(value: &str) -> Result<Option<NonZeroUsize>, String>
async fn load_authentication() async fn load_authentication()
-> Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String> { -> Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String> {
tokio::task::spawn_blocking(|| {
DesktopStorage::system() DesktopStorage::system()
.map(|bootstrap| bootstrap.into_parts()) .map(|bootstrap| bootstrap.into_parts())
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
})
.await
.map_err(|error| format!("desktop startup task failed: {error}"))?
} }
fn load_document( fn load_document(
@@ -6215,9 +6494,22 @@ mod tests {
let _view = app.view(); let _view = app.view();
} }
#[test]
fn application_uses_the_deep_dark_palette() {
let app = test_app(None);
let theme = app_theme(&app);
assert_eq!(theme.palette().background, APP_BACKGROUND);
assert_eq!(APP_BACKGROUND, Color::from_rgb8(0x18, 0x18, 0x18));
assert_eq!(ENTRY_FIELD_BACKGROUND, SURFACE_BACKGROUND);
assert_eq!(ENTRY_FIELD_ACTIVE_BACKGROUND, HOVER_BACKGROUND);
assert_eq!(ENTRY_INPUT_BACKGROUND, APP_BACKGROUND);
assert_ne!(theme.palette().primary, theme.palette().danger);
}
#[test] #[test]
fn utility_panels_share_the_compact_entry_palette() { fn utility_panels_share_the_compact_entry_palette() {
let theme = Theme::Dark; let app = test_app(None);
let theme = app_theme(&app);
assert_eq!( assert_eq!(
entry_area_style(&theme).background, entry_area_style(&theme).background,
Some(Background::Color(ENTRY_AREA_BACKGROUND)) Some(Background::Color(ENTRY_AREA_BACKGROUND))
@@ -6438,7 +6730,6 @@ mod tests {
generation_form: None, generation_form: None,
conflict: false, conflict: false,
status: String::new(), status: String::new(),
open_menu: None,
utility: None, utility: None,
context_target: None, context_target: None,
palette: CommandPalette::default(), palette: CommandPalette::default(),
@@ -7490,7 +7781,7 @@ mod tests {
} }
#[test] #[test]
fn desktop_git_status_is_typed_https_only_and_cancellation_safe() { fn desktop_git_status_is_transport_typed_and_cancellation_safe() {
let (temporary, _initial) = fixture_storage(); let (temporary, _initial) = fixture_storage();
let config_path = temporary.path().join("config.toml"); let config_path = temporary.path().join("config.toml");
let mut config = fs::read_to_string(&config_path).expect("configuration"); let mut config = fs::read_to_string(&config_path).expect("configuration");
@@ -7520,6 +7811,10 @@ mod tests {
result.snapshot().remote().expect("remote").url(), result.snapshot().remote().expect("remote").url(),
"https://example.test/store.git" "https://example.test/store.git"
); );
assert_eq!(
result.snapshot().remote().expect("remote").transport(),
ironstorage::config::RemoteTransport::Https
);
assert_eq!( assert_eq!(
*phases.lock().expect("progress"), *phases.lock().expect("progress"),
[GitProgressPhase::Validating] [GitProgressPhase::Validating]
@@ -7552,12 +7847,12 @@ mod tests {
&DesktopGitRequest::Refresh, &DesktopGitRequest::Refresh,
&GitOperationControl::default(), &GitOperationControl::default(),
) )
.expect_err("non-HTTPS remote rejection"); .expect_err("configured remote mismatch");
assert_eq!(error.kind(), DesktopErrorKind::Git); assert_eq!(error.kind(), DesktopErrorKind::Git);
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl)); assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
assert_eq!( assert_eq!(
error.to_string(), error.to_string(),
"Git remotes must use credential-free HTTPS URLs" "the Git remote URL is invalid or forbidden"
); );
assert!(DesktopGitRequest::Pull.requires_authentication()); assert!(DesktopGitRequest::Pull.requires_authentication());
@@ -7565,6 +7860,24 @@ mod tests {
assert!(!DesktopGitRequest::Push.changes_worktree()); assert!(!DesktopGitRequest::Push.changes_worktree());
} }
#[test]
fn desktop_ssh_passphrase_state_never_renders_or_debugs_secret_text() {
let fingerprint =
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
let form = GitForm {
interaction: Some(GitInteraction::Passphrase {
request: DesktopGitRequest::Sync,
fingerprint: fingerprint.clone(),
value: Zeroizing::new("desktop secret".to_owned()),
}),
..GitForm::default()
};
let debug = format!("{form:?}");
assert!(debug.contains(fingerprint.as_str()));
assert!(!debug.contains("desktop secret"));
}
#[test] #[test]
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() { fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
let (_temporary, storage) = fixture_storage(); let (_temporary, storage) = fixture_storage();
+3 -1
View File
@@ -4,6 +4,8 @@ description = "Ratatui frontend for IronStorage"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[[bin]] [[bin]]
@@ -12,7 +14,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
crossterm.workspace = true crossterm.workspace = true
ironstorage.workspace = true ironstorage = { workspace = true, features = ["ssh"] }
ratatui.workspace = true ratatui.workspace = true
zeroize.workspace = true zeroize.workspace = true
+101 -3
View File
@@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant};
use ironstorage::{ use ironstorage::{
command::{ command::{
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, help_text, otp_version_text, version_text, Presentation, help_text, otp_version_text, version_text,
}, },
config::Config, config::Config,
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId}, document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot}, git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
otp::OtpCodeValidity, otp::OtpCodeValidity,
presentation::{ClipboardDisposition, ClipboardError, QrMatrix}, presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
read::{FindResults, GrepResults, TreeModel}, read::{FindResults, GrepResults, TreeModel},
@@ -223,6 +224,14 @@ pub enum AsyncPayload {
conflicts: Vec<GitConflict>, conflicts: Vec<GitConflict>,
details: Option<SecretBytes>, details: Option<SecretBytes>,
}, },
GitHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
GitPassphraseRequired {
request: GitRequest,
fingerprint: SshFingerprint,
},
OtpCodeFinished { OtpCodeFinished {
entry: String, entry: String,
field: Option<EntryFieldId>, field: Option<EntryFieldId>,
@@ -301,6 +310,15 @@ pub enum AppEffect {
}, },
AuthenticateWorkflow(Box<WorkflowSubmission>), AuthenticateWorkflow(Box<WorkflowSubmission>),
AuthenticateGit(ironstorage::command::GitRequest), AuthenticateGit(ironstorage::command::GitRequest),
RetryGitAfterHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
RetryGitWithPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
CancelGit, CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>), ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest), AuthenticateOtp(OtpUiRequest),
@@ -536,6 +554,14 @@ impl App {
self.status = format!("{label} queued for secure-storage authentication…"); self.status = format!("{label} queued for secure-storage authentication…");
} }
fn begin_git_retry(&mut self) {
self.git_pending = true;
self.workflow_pending = false;
self.workflow = None;
self.transition(Transition::Dismiss);
self.status = "Retrying Git operation with confirmed SSH input…".to_owned();
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> { pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease self.remaining_lease
} }
@@ -861,6 +887,26 @@ impl App {
self.focus = PaneFocus::Main; self.focus = PaneFocus::Main;
self.status = message; self.status = message;
} }
Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_host(request, host_key));
self.workflow_pending = false;
self.status =
"Verify the SSH host fingerprint through a trusted channel before confirming."
.to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
}) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint));
self.workflow_pending = false;
self.status =
"Enter the encrypted SSH key passphrase; input stays masked.".to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::OtpCodeFinished { Ok(AsyncPayload::OtpCodeFinished {
entry, entry,
field, field,
@@ -1337,6 +1383,22 @@ impl App {
} }
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)), WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() { WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
Ok(WorkflowSubmission::SshHost { request, host_key }) => {
self.begin_git_retry();
Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key })
}
Ok(WorkflowSubmission::SshPassphrase {
request,
fingerprint,
passphrase,
}) => {
self.begin_git_retry();
Some(AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
})
}
Ok(submission) => { Ok(submission) => {
self.workflow_pending = true; self.workflow_pending = true;
self.status = "Authenticating before applying the workflow…".to_owned(); self.status = "Authenticating before applying the workflow…".to_owned();
@@ -2126,7 +2188,7 @@ impl App {
fn git_phase_name(phase: GitProgressPhase) -> &'static str { fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase { match phase {
GitProgressPhase::Validating => "validating repository", GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials", GitProgressPhase::Authenticating => "verifying remote and credentials",
GitProgressPhase::Receiving => "receiving remote objects", GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes", GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects", GitProgressPhase::Sending => "sending local objects",
@@ -2744,4 +2806,40 @@ mod tests {
assert!(rows.contains("••••••••")); assert!(rows.contains("••••••••"));
assert!(!rows.contains("NEVER-RENDER")); assert!(!rows.contains("NEVER-RENDER"));
} }
#[test]
fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() {
let mut app = App::new();
app.git_pending = true;
let token = app.begin_request();
let fingerprint =
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
assert_eq!(
app.apply_result(AsyncResult {
token,
payload: Ok(AsyncPayload::GitPassphraseRequired {
request: GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint: fingerprint.clone(),
}),
}),
ResultDisposition::Applied
);
assert_eq!(app.mode(), Mode::Dialog);
assert!(!app.git_pending());
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains(fingerprint.as_str()));
assert!(!rows.contains("secret"));
app.handle_workflow_input(
crossterm::event::KeyCode::Char('Q'),
crossterm::event::KeyModifiers::NONE,
);
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains("••••••••"));
assert!(!rows.contains('Q'));
assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None));
assert_eq!(app.mode(), Mode::Browser);
}
} }
+188 -50
View File
@@ -390,6 +390,57 @@ fn apply_app_effect(
); );
} }
} }
AppEffect::RetryGitAfterHostConfirmation { request, host_key } => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
let remote = config
.git_remote(git_requested_remote(&request))
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
ironstorage::git::confirm_ssh_host(remote, &host_key)
.map_err(|error| error.to_string())?;
execute_git(&config, request, Some(handle), None, &control)
});
}
AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
} => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(
&config,
request,
Some(handle),
Some((fingerprint, passphrase)),
&control,
)
});
}
AppEffect::AuthenticateOtp(request) => { AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() { if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request); coordinator.request_otp(request);
@@ -557,7 +608,9 @@ fn apply_app_effect(
let control = let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone()); *git_control = Some(control.clone());
executor.submit(token, move || execute_git(&config, request, None, &control)); executor.submit(token, move || {
execute_git(&config, request, None, None, &control)
});
} }
} }
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp( AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
@@ -623,15 +676,72 @@ fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bo
) )
} }
fn git_requested_remote(request: &ironstorage::command::GitRequest) -> Option<&str> {
match request {
ironstorage::command::GitRequest::Fetch { remote }
| ironstorage::command::GitRequest::Pull { remote, .. }
| ironstorage::command::GitRequest::Push { remote, .. }
| ironstorage::command::GitRequest::Sync { remote } => remote.as_deref(),
_ => None,
}
}
fn remote_credentials<'a>(
handle: &'a ironstorage::authentication::NativeAuthenticationHandle,
supplied: Option<&'a (
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
) -> ironstorage::git::GitRemoteCredentialOverride<
'a,
ironstorage::authentication::NativeAuthenticationHandle,
> {
supplied.map_or_else(
|| ironstorage::git::GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
ironstorage::git::GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
)
}
fn git_interaction_or_error(
request: ironstorage::command::GitRequest,
error: ironstorage::git::GitError,
) -> Result<AsyncPayload, String> {
match error {
ironstorage::git::GitError::UnknownSshHostKey { host_key } => {
Ok(AsyncPayload::GitHostConfirmation {
request,
host_key: *host_key,
})
}
ironstorage::git::GitError::SshKeyPassphraseUnavailable { fingerprint } => {
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
})
}
error => Err(error.to_string()),
}
}
fn execute_git( fn execute_git(
config: &ironstorage::config::Config, config: &ironstorage::config::Config,
request: ironstorage::command::GitRequest, request: ironstorage::command::GitRequest,
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>, mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
ssh_passphrase: Option<(
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
control: &ironstorage::git::GitOperationControl, control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> { ) -> Result<AsyncPayload, String> {
use ironstorage::{ use ironstorage::{
command::{GitConfigRequest, GitRemoteRequest, GitRequest}, command::{GitConfigRequest, GitRemoteRequest, GitRequest},
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport}, git::{GitError, GitIdentity, GitRepository},
repository::{Repository, SecretBytes}, repository::{Repository, SecretBytes},
}; };
@@ -744,20 +854,20 @@ fn execute_git(
} }
}, },
GitRequest::Fetch { remote } => { GitRequest::Fetch { remote } => {
let retry = GitRequest::Fetch {
remote: remote.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; .ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git fetch requires authentication".to_owned())?; .ok_or_else(|| "Git fetch requires authentication".to_owned())?;
let outcome = git let provider = remote_credentials(handle, ssh_passphrase.as_ref());
.fetch_with_transport_controlled( let outcome = match git.fetch_controlled(configured, &provider, control) {
configured, Ok(outcome) => outcome,
handle, Err(error) => return git_interaction_or_error(retry, error),
&EmbeddedFetchTransport, };
control,
)
.map_err(|error| error.to_string())?;
format!( format!(
"Fetched {}{}", "Fetched {}{}",
outcome.remote(), outcome.remote(),
@@ -769,19 +879,18 @@ fn execute_git(
) )
} }
GitRequest::Pull { remote, branch } => { GitRequest::Pull { remote, branch } => {
let retry = GitRequest::Pull {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; .ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git pull requires authentication".to_owned())?; .ok_or_else(|| "Git pull requires authentication".to_owned())?;
match git.pull_with_transport_controlled( let provider = remote_credentials(handle, ssh_passphrase.as_ref());
configured, match git.pull_controlled(configured, branch.as_deref(), &provider, control) {
branch.as_deref(),
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => { Ok(outcome) => {
changed_snapshot = true; changed_snapshot = true;
format!("Git pull completed: {outcome:?}") format!("Git pull completed: {outcome:?}")
@@ -801,25 +910,26 @@ fn execute_git(
details: None, details: None,
}); });
} }
Err(error) => return Err(error.to_string()), Err(error) => return git_interaction_or_error(retry, error),
} }
} }
GitRequest::Push { remote, branch } => { GitRequest::Push { remote, branch } => {
let retry = GitRequest::Push {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; .ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git push requires authentication".to_owned())?; .ok_or_else(|| "Git push requires authentication".to_owned())?;
let outcome = git let provider = remote_credentials(handle, ssh_passphrase.as_ref());
.push_with_transport_controlled( let outcome =
configured, match git.push_controlled(configured, branch.as_deref(), &provider, control) {
branch.as_deref(), Ok(outcome) => outcome,
handle, Err(error) => return git_interaction_or_error(retry, error),
&ReqwestGitTransport, };
control,
)
.map_err(|error| error.to_string())?;
format!( format!(
"Pushed {} {}", "Pushed {} {}",
outcome.remote(), outcome.remote(),
@@ -827,19 +937,17 @@ fn execute_git(
) )
} }
GitRequest::Sync { remote } => { GitRequest::Sync { remote } => {
let retry = GitRequest::Sync {
remote: remote.clone(),
};
let configured = config let configured = config
.git_remote(remote.as_deref()) .git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?; .ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials let handle = credentials
.as_ref() .as_ref()
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?; .ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
let pull = match git.pull_with_transport_controlled( let provider = remote_credentials(handle, ssh_passphrase.as_ref());
configured, let (pull, push) = match git.sync_controlled(configured, &provider, control) {
None,
handle,
&EmbeddedFetchTransport,
control,
) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(GitError::MergeConflicts { conflicts }) => { Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git let snapshot = git
@@ -856,17 +964,8 @@ fn execute_git(
details: None, details: None,
}); });
} }
Err(error) => return Err(error.to_string()), Err(error) => return git_interaction_or_error(retry, error),
}; };
let push = git
.push_with_transport_controlled(
configured,
None,
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
changed_snapshot = true; changed_snapshot = true;
format!( format!(
"Git synchronization completed: {pull:?}; pushed {}", "Git synchronization completed: {pull:?}; pushed {}",
@@ -874,6 +973,11 @@ fn execute_git(
) )
} }
}; };
if let Some((fingerprint, passphrase)) = ssh_passphrase
&& let Some(handle) = credentials.as_ref()
{
let _ = handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
}
control control
.report(ironstorage::git::GitProgressPhase::Refreshing) .report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@@ -906,7 +1010,7 @@ fn execute_git_resolution(
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let configured = config let configured = config
.git_remote(None) .git_remote(None)
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?; .ok_or_else(|| "no Git remote is configured".to_owned())?;
let repository = ironstorage::repository::Repository::open(config.vault()) let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage()) let git = GitRepository::open(&repository, GitIdentity::ironstorage())
@@ -1117,7 +1221,7 @@ fn apply_authentication_event(
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token)); ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone()); *git_control = Some(control.clone());
executor.submit(token, move || { executor.submit(token, move || {
execute_git(&config, request, Some(handle), &control) execute_git(&config, request, Some(handle), None, &control)
}); });
} }
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => { AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
@@ -1203,6 +1307,9 @@ fn execute_workflow(
let mut presentation = None; let mut presentation = None;
let mut refresh_tree = true; let mut refresh_tree = true;
let (entry, mut status) = match submission { let (entry, mut status) = match submission {
WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => {
unreachable!("SSH interactions are routed before storage workflows")
}
WorkflowSubmission::Init(request) => { WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default(); let directory = request.path.as_deref().unwrap_or_default();
let mut committer = let mut committer =
@@ -1597,6 +1704,37 @@ mod tests {
} }
} }
#[test]
fn typed_ssh_authentication_errors_only_prompt_for_missing_passphrases() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let request = ironstorage::command::GitRequest::Fetch {
remote: Some("origin".to_owned()),
};
assert!(matches!(
git_interaction_or_error(
request.clone(),
ironstorage::git::GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
},
),
Ok(AsyncPayload::GitPassphraseRequired {
request: prompted,
fingerprint: prompted_fingerprint,
}) if prompted == request && prompted_fingerprint == fingerprint
));
assert_eq!(
git_interaction_or_error(
request,
ironstorage::git::GitError::SshAuthenticationRejected,
)
.expect_err("authentication rejection is not a passphrase prompt"),
"SSH public-key authentication was rejected"
);
}
#[test] #[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() { fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new(); let mut app = App::new();
+54
View File
@@ -210,6 +210,7 @@ impl AsyncExecutor {
where where
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static, F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
{ {
self.reap_finished();
let sender = self.sender.clone(); let sender = self.sender.clone();
let task = thread::spawn(move || { let task = thread::spawn(move || {
let _ignored = sender.send(AsyncResult { let _ignored = sender.send(AsyncResult {
@@ -223,6 +224,34 @@ impl AsyncExecutor {
.push(task); .push(task);
} }
fn reap_finished(&self) {
let mut tasks = self
.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut completed = Vec::new();
let mut index = 0;
while index < tasks.len() {
if tasks[index].is_finished() {
completed.push(tasks.swap_remove(index));
} else {
index += 1;
}
}
drop(tasks);
for task in completed {
let _ignored = task.join();
}
}
#[cfg(test)]
fn retained_task_count(&self) -> usize {
self.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn progress_reporter( pub fn progress_reporter(
&self, &self,
token: RequestToken, token: RequestToken,
@@ -253,6 +282,7 @@ impl AsyncExecutor {
} }
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ { pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
self.reap_finished();
self.receiver.try_iter() self.receiver.try_iter()
} }
} }
@@ -313,4 +343,28 @@ mod tests {
.expect("worker result"); .expect("worker result");
assert_eq!(app.apply_result(result), ResultDisposition::Applied); assert_eq!(app.apply_result(result), ResultDisposition::Applied);
} }
#[test]
fn completed_task_handles_are_reaped_during_normal_activity() {
let executor = AsyncExecutor::new();
let mut app = App::new();
let submitted = 256;
for _ in 0..submitted {
let token = app.begin_request();
executor.submit(token, || Err("complete".to_owned()));
}
for _ in 0..submitted {
executor
.receiver
.recv_timeout(Duration::from_secs(2))
.expect("worker result");
}
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while executor.retained_task_count() != 0 && std::time::Instant::now() < deadline {
let _ = executor.drain().count();
thread::yield_now();
}
assert_eq!(executor.retained_task_count(), 0);
}
} }
+2 -1
View File
@@ -974,7 +974,8 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
]; ];
if let Some(remote) = snapshot.remote() { if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!( lines.push(Line::raw(format!(
"remote: {} {}", "remote: {} {} {}",
remote.transport(),
remote.name(), remote.name(),
remote.url() remote.url()
))); )));
+131 -3
View File
@@ -5,11 +5,13 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{ use ironstorage::{
command::{ command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput, CopyRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest, InsertInput, InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource,
RemoveRequest, OtpInsertRequest, RemoveRequest,
}, },
config::SshFingerprint,
crypto::KeyInfo, crypto::KeyInfo,
git::SshHostKey,
kdbx::{KdbxImportMode, KdbxImportRequest}, kdbx::{KdbxImportMode, KdbxImportRequest},
otp::OtpInput, otp::OtpInput,
repository::SecretBytes, repository::SecretBytes,
@@ -167,6 +169,20 @@ pub struct OtpForm {
focus: usize, focus: usize,
} }
#[derive(Debug)]
pub struct SshHostForm {
request: GitRequest,
host_key: SshHostKey,
confirmed: bool,
}
#[derive(Debug)]
pub struct SshPassphraseForm {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretText,
}
#[derive(Debug)] #[derive(Debug)]
pub enum WorkflowForm { pub enum WorkflowForm {
Init(InitForm), Init(InitForm),
@@ -178,6 +194,8 @@ pub enum WorkflowForm {
Move(TransferForm), Move(TransferForm),
Copy(TransferForm), Copy(TransferForm),
Otp(OtpForm), Otp(OtpForm),
SshHost(SshHostForm),
SshPassphrase(SshPassphraseForm),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -217,6 +235,15 @@ pub enum WorkflowSubmission {
OtpValidate { OtpValidate {
uri: OtpInput, uri: OtpInput,
}, },
SshHost {
request: GitRequest,
host_key: SshHostKey,
},
SshPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -228,6 +255,22 @@ pub enum WorkflowInput {
} }
impl WorkflowForm { impl WorkflowForm {
pub fn ssh_host(request: GitRequest, host_key: SshHostKey) -> Self {
Self::SshHost(SshHostForm {
request,
host_key,
confirmed: false,
})
}
pub fn ssh_passphrase(request: GitRequest, fingerprint: SshFingerprint) -> Self {
Self::SshPassphrase(SshPassphraseForm {
request,
fingerprint,
passphrase: SecretText::default(),
})
}
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self { pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm { Self::Otp(OtpForm {
kind, kind,
@@ -409,6 +452,8 @@ impl WorkflowForm {
OtpFormKind::Append => "Append OTP URI", OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate OTP URI", OtpFormKind::Validate => "Validate OTP URI",
}, },
Self::SshHost(_) => "Confirm SSH host key",
Self::SshPassphrase(_) => "Unlock SSH private key",
} }
} }
@@ -579,6 +624,28 @@ impl WorkflowForm {
), ),
] ]
} }
Self::SshHost(form) => vec![
row(
false,
"Host",
&format!("{}:{}", form.host_key.host(), form.host_key.port()),
),
row(false, "Algorithm", form.host_key.algorithm()),
row(false, "Fingerprint", form.host_key.fingerprint().as_str()),
row(true, "Trust this observed key", yes_no(form.confirmed)),
],
Self::SshPassphrase(form) => vec![
row(false, "Key fingerprint", form.fingerprint.as_str()),
row(
true,
"Passphrase",
if form.passphrase.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
],
} }
} }
@@ -789,6 +856,25 @@ impl WorkflowForm {
} }
} }
} }
Self::SshHost(form) => {
if !form.confirmed {
return Err("Explicitly confirm the verified SSH host fingerprint".to_owned());
}
Ok(WorkflowSubmission::SshHost {
request: form.request.clone(),
host_key: form.host_key.clone(),
})
}
Self::SshPassphrase(form) => {
if form.passphrase.is_empty() {
return Err("SSH key passphrase is required".to_owned());
}
Ok(WorkflowSubmission::SshPassphrase {
request: form.request.clone(),
fingerprint: form.fingerprint.clone(),
passphrase: SecretBytes::new(form.passphrase.bytes()),
})
}
} }
} }
@@ -801,6 +887,7 @@ impl WorkflowForm {
Self::Kdbx(_) => 5, Self::Kdbx(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4, Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4, Self::Otp(_) => 4,
Self::SshHost(_) | Self::SshPassphrase(_) => 1,
} }
} }
@@ -815,6 +902,7 @@ impl WorkflowForm {
Self::Remove(form) => &mut form.focus, Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus, Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus, Self::Otp(form) => &mut form.focus,
Self::SshHost(_) | Self::SshPassphrase(_) => return,
}; };
*focus = if forward { *focus = if forward {
(*focus + 1) % count (*focus + 1) % count
@@ -863,6 +951,7 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true, Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
Self::Otp(form) if form.focus == 2 => form.force ^= true, Self::Otp(form) if form.focus == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true, Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
Self::SshHost(form) => form.confirmed ^= true,
_ => self.character(' '), _ => self.character(' '),
} }
} }
@@ -919,6 +1008,7 @@ impl WorkflowForm {
Self::Otp(form) if form.focus == 1 => { Self::Otp(form) if form.focus == 1 => {
form.uri.pop(); form.uri.pop();
} }
Self::SshPassphrase(form) => form.passphrase.pop(),
_ => {} _ => {}
} }
} }
@@ -954,6 +1044,7 @@ impl WorkflowForm {
} }
Self::Otp(form) if form.focus == 0 => form.entry.push(character), Self::Otp(form) if form.focus == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character), Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
Self::SshPassphrase(form) if character != '\n' => form.passphrase.push(character),
_ => {} _ => {}
} }
} }
@@ -1052,6 +1143,43 @@ mod tests {
} }
} }
#[test]
fn ssh_passphrase_prompt_is_masked_and_returns_typed_secret_input() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let mut form = WorkflowForm::ssh_passphrase(
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint.clone(),
);
type_text(&mut form, "terminal secret");
let rendered = format!("{:?} {:?}", form.title(), form.rows());
assert!(rendered.contains(fingerprint.as_str()));
assert!(rendered.contains("••••••••"));
assert!(!rendered.contains("terminal secret"));
let debug = format!("{form:?}");
assert!(!debug.contains("terminal secret"));
let WorkflowSubmission::SshPassphrase {
request,
fingerprint: submitted,
passphrase,
} = form.submission().expect("submission")
else {
panic!("SSH passphrase submission")
};
assert_eq!(
request,
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned())
}
);
assert_eq!(submitted, fingerprint);
assert_eq!(passphrase.expose(), b"terminal secret");
}
#[test] #[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() { fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None); let mut form = WorkflowForm::insert(None);
+3 -1
View File
@@ -4,6 +4,8 @@ description = "UniFFI boundary between IronStorage and its Apple frontends"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[lib] [lib]
@@ -11,5 +13,5 @@ name = "ironstorage_apple"
crate-type = ["lib", "staticlib", "cdylib"] crate-type = ["lib", "staticlib", "cdylib"]
[dependencies] [dependencies]
ironstorage.workspace = true ironstorage = { path = "../storage", default-features = false, features = ["full"] }
uniffi.workspace = true uniffi.workspace = true
+418 -15
View File
@@ -10,13 +10,15 @@ use std::{
}; };
use ironstorage::{ use ironstorage::{
config::ConfigError, config::{ConfigError, MobileAppearance as StorageMobileAppearance},
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
mobile_authentication::{ mobile_authentication::{
MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationError as StorageAuthenticationError,
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
MobileAuthenticationState as StorageAuthenticationState, MobileAuthenticationState as StorageAuthenticationState,
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation, MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
MobilePreferences as StorageMobilePreferences,
MobileWatchPreferenceState as StorageWatchPreferenceState,
}, },
mobile_entry::{ mobile_entry::{
MobileEntryEditorFieldKind as StorageEntryEditorFieldKind, MobileEntryEditorFieldKind as StorageEntryEditorFieldKind,
@@ -26,7 +28,8 @@ use ironstorage::{
MobileEntrySectionKind as StorageEntrySectionKind, MobileEntrySectionKind as StorageEntrySectionKind,
}, },
mobile_home::{ mobile_home::{
self, MobileHomeChangeKind as StorageHomeChangeKind, self, MobileHomeActionKind as StorageHomeActionKind,
MobileHomeChangeKind as StorageHomeChangeKind,
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError, MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness, MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness,
MobileHomePhase as StorageHomePhase, MobileHomePhase as StorageHomePhase,
@@ -56,8 +59,11 @@ use ironstorage::{
MobileTotpDetail as StorageTotpDetail, MobileTotpDetail as StorageTotpDetail,
MobileTotpDiscoveryPhase as StorageTotpDiscoveryPhase, MobileTotpDiscoveryPhase as StorageTotpDiscoveryPhase,
MobileTotpOperation as StorageTotpOperation, MobileTotpPage as StorageTotpPage, MobileTotpOperation as StorageTotpOperation, MobileTotpPage as StorageTotpPage,
},
mobile_watch::{
MobileWatchSnapshotState as StorageWatchSnapshotState, MobileWatchSnapshotState as StorageWatchSnapshotState,
MobileWatchSnapshotStatus as StorageWatchSnapshotStatus, MobileWatchSnapshotStatus as StorageWatchSnapshotStatus,
WatchSnapshotTransfer as StorageWatchSnapshotTransfer,
}, },
}; };
@@ -189,6 +195,32 @@ pub enum MobileHomeChangeStatus {
Deleted, Deleted,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileHomeActionKind {
Commit,
Fetch,
Pull,
Push,
}
impl From<StorageHomeActionKind> for MobileHomeActionKind {
fn from(kind: StorageHomeActionKind) -> Self {
match kind {
StorageHomeActionKind::Commit => Self::Commit,
StorageHomeActionKind::Fetch => Self::Fetch,
StorageHomeActionKind::Pull => Self::Pull,
StorageHomeActionKind::Push => Self::Push,
}
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobileHomeAction {
pub kind: MobileHomeActionKind,
pub title: String,
pub system_image: String,
}
impl From<StorageHomeChangeStatus> for MobileHomeChangeStatus { impl From<StorageHomeChangeStatus> for MobileHomeChangeStatus {
fn from(status: StorageHomeChangeStatus) -> Self { fn from(status: StorageHomeChangeStatus) -> Self {
match status { match status {
@@ -205,6 +237,7 @@ pub struct MobileHomeSummaryRow {
pub title: String, pub title: String,
pub detail: String, pub detail: String,
pub system_image: String, pub system_image: String,
pub actions: Vec<MobileHomeAction>,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -225,6 +258,7 @@ pub struct MobileHomeCommit {
pub system_image: String, pub system_image: String,
pub timestamp: i64, pub timestamp: i64,
pub changes: Vec<MobileHomeChange>, pub changes: Vec<MobileHomeChange>,
pub actions: Vec<MobileHomeAction>,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -236,6 +270,7 @@ pub struct MobileHomeNotice {
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileHomePage { pub struct MobileHomePage {
pub remote_configured: bool,
pub freshness: MobileHomeFreshness, pub freshness: MobileHomeFreshness,
pub refreshed_at: Option<i64>, pub refreshed_at: Option<i64>,
pub summaries: Vec<MobileHomeSummaryRow>, pub summaries: Vec<MobileHomeSummaryRow>,
@@ -249,6 +284,7 @@ pub struct MobileHomePage {
impl From<mobile_home::MobileHomePage> for MobileHomePage { impl From<mobile_home::MobileHomePage> for MobileHomePage {
fn from(page: mobile_home::MobileHomePage) -> Self { fn from(page: mobile_home::MobileHomePage) -> Self {
Self { Self {
remote_configured: page.remote_configured(),
freshness: page.freshness().into(), freshness: page.freshness().into(),
refreshed_at: page.refreshed_at(), refreshed_at: page.refreshed_at(),
summaries: page summaries: page
@@ -259,6 +295,7 @@ impl From<mobile_home::MobileHomePage> for MobileHomePage {
title: row.title().to_owned(), title: row.title().to_owned(),
detail: row.detail().to_owned(), detail: row.detail().to_owned(),
system_image: row.system_image().to_owned(), system_image: row.system_image().to_owned(),
actions: row.actions().iter().map(mobile_home_action).collect(),
}) })
.collect(), .collect(),
incoming: page.incoming().iter().map(mobile_home_commit).collect(), incoming: page.incoming().iter().map(mobile_home_commit).collect(),
@@ -293,6 +330,15 @@ fn mobile_home_commit(commit: &mobile_home::MobileHomeCommit) -> MobileHomeCommi
status: change.status().into(), status: change.status().into(),
}) })
.collect(), .collect(),
actions: commit.actions().iter().map(mobile_home_action).collect(),
}
}
fn mobile_home_action(action: &mobile_home::MobileHomeAction) -> MobileHomeAction {
MobileHomeAction {
kind: action.kind().into(),
title: action.title().to_owned(),
system_image: action.system_image().to_owned(),
} }
} }
@@ -302,6 +348,7 @@ pub enum MobileHomePhase {
Authenticating, Authenticating,
Receiving, Receiving,
Integrating, Integrating,
Sending,
Finishing, Finishing,
} }
@@ -312,6 +359,7 @@ impl From<StorageHomePhase> for MobileHomePhase {
StorageHomePhase::Authenticating => Self::Authenticating, StorageHomePhase::Authenticating => Self::Authenticating,
StorageHomePhase::Receiving => Self::Receiving, StorageHomePhase::Receiving => Self::Receiving,
StorageHomePhase::Integrating => Self::Integrating, StorageHomePhase::Integrating => Self::Integrating,
StorageHomePhase::Sending => Self::Sending,
StorageHomePhase::Finishing => Self::Finishing, StorageHomePhase::Finishing => Self::Finishing,
} }
} }
@@ -319,6 +367,7 @@ impl From<StorageHomePhase> for MobileHomePhase {
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileHomeProgress { pub struct MobileHomeProgress {
pub action: Option<MobileHomeActionKind>,
pub phase: MobileHomePhase, pub phase: MobileHomePhase,
pub title: String, pub title: String,
pub detail: String, pub detail: String,
@@ -331,6 +380,7 @@ pub enum MobileHomeErrorKind {
Authentication, Authentication,
Conflict, Conflict,
DirtyLocalChanges, DirtyLocalChanges,
NoChanges,
Offline, Offline,
Interrupted, Interrupted,
SecureStorage, SecureStorage,
@@ -346,6 +396,7 @@ impl From<StorageHomeErrorKind> for MobileHomeErrorKind {
StorageHomeErrorKind::Authentication => Self::Authentication, StorageHomeErrorKind::Authentication => Self::Authentication,
StorageHomeErrorKind::Conflict => Self::Conflict, StorageHomeErrorKind::Conflict => Self::Conflict,
StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges, StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges,
StorageHomeErrorKind::NoChanges => Self::NoChanges,
StorageHomeErrorKind::Offline => Self::Offline, StorageHomeErrorKind::Offline => Self::Offline,
StorageHomeErrorKind::Interrupted => Self::Interrupted, StorageHomeErrorKind::Interrupted => Self::Interrupted,
StorageHomeErrorKind::SecureStorage => Self::SecureStorage, StorageHomeErrorKind::SecureStorage => Self::SecureStorage,
@@ -394,6 +445,7 @@ impl MobileHomeOperation {
pub fn progress(&self) -> MobileHomeProgress { pub fn progress(&self) -> MobileHomeProgress {
let progress = self.operation.progress(); let progress = self.operation.progress();
MobileHomeProgress { MobileHomeProgress {
action: progress.action().map(Into::into),
phase: progress.phase().into(), phase: progress.phase().into(),
title: progress.title().to_owned(), title: progress.title().to_owned(),
detail: progress.detail().to_owned(), detail: progress.detail().to_owned(),
@@ -411,14 +463,29 @@ impl MobileHomeOperation {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeFfiError> { pub fn commit(&self, message: String) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.refresh().map(Into::into).map_err(Into::into) self.operation
.commit(&message)
.map(Into::into)
.map_err(Into::into)
}
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.fetch().map(Into::into).map_err(Into::into)
} }
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeFfiError> { pub fn pull(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.pull().map(Into::into).map_err(Into::into) self.operation.pull().map(Into::into).map_err(Into::into)
} }
pub fn push(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.push().map(Into::into).map_err(Into::into)
}
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.sync().map(Into::into).map_err(Into::into)
}
pub fn cancel(&self) { pub fn cancel(&self) {
self.operation.cancel(); self.operation.cancel();
} }
@@ -567,6 +634,99 @@ pub struct MobileAuthenticationState {
pub remaining_seconds: u64, pub remaining_seconds: u64,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileAppearance {
System,
Light,
Dark,
}
impl From<StorageMobileAppearance> for MobileAppearance {
fn from(appearance: StorageMobileAppearance) -> Self {
match appearance {
StorageMobileAppearance::System => Self::System,
StorageMobileAppearance::Light => Self::Light,
StorageMobileAppearance::Dark => Self::Dark,
}
}
}
impl From<MobileAppearance> for StorageMobileAppearance {
fn from(appearance: MobileAppearance) -> Self {
match appearance {
MobileAppearance::System => Self::System,
MobileAppearance::Light => Self::Light,
MobileAppearance::Dark => Self::Dark,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileWatchPreferenceState {
Unsupported,
NotPaired,
AppNotInstalled,
Ready,
Pending,
Delivered,
Current,
Failed,
}
impl From<StorageWatchPreferenceState> for MobileWatchPreferenceState {
fn from(state: StorageWatchPreferenceState) -> Self {
match state {
StorageWatchPreferenceState::Unsupported => Self::Unsupported,
StorageWatchPreferenceState::NotPaired => Self::NotPaired,
StorageWatchPreferenceState::AppNotInstalled => Self::AppNotInstalled,
StorageWatchPreferenceState::Ready => Self::Ready,
StorageWatchPreferenceState::Pending => Self::Pending,
StorageWatchPreferenceState::Delivered => Self::Delivered,
StorageWatchPreferenceState::Current => Self::Current,
StorageWatchPreferenceState::Failed => Self::Failed,
}
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobilePreferences {
pub sync_configured: bool,
pub repository_title: String,
pub repository_url: String,
pub server_title: String,
pub server_identity: String,
pub application_account: Option<String>,
pub default_key_title: String,
pub default_key_fingerprint: String,
pub authentication_timeout_seconds: u64,
pub biometric_unlock_enabled: bool,
pub appearance: MobileAppearance,
pub watch_state: MobileWatchPreferenceState,
pub watch_title: String,
pub watch_detail: String,
}
impl From<StorageMobilePreferences> for MobilePreferences {
fn from(preferences: StorageMobilePreferences) -> Self {
Self {
sync_configured: preferences.sync_configured(),
repository_title: preferences.repository_title().to_owned(),
repository_url: preferences.repository_url().to_owned(),
server_title: preferences.server_title().to_owned(),
server_identity: preferences.server_identity().to_owned(),
application_account: preferences.application_account().map(str::to_owned),
default_key_title: preferences.default_key_title().to_owned(),
default_key_fingerprint: preferences.default_key_fingerprint().to_owned(),
authentication_timeout_seconds: preferences.authentication_timeout_seconds(),
biometric_unlock_enabled: preferences.biometric_unlock_enabled(),
appearance: preferences.appearance().into(),
watch_state: preferences.watch_state().into(),
watch_title: preferences.watch_title().to_owned(),
watch_detail: preferences.watch_detail().to_owned(),
}
}
}
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileGitIdentity { pub struct MobileGitIdentity {
pub name: String, pub name: String,
@@ -779,14 +939,22 @@ pub struct MobileEntryCopy {
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileWatchSnapshotState { pub enum MobileWatchSnapshotState {
Unavailable, Unavailable,
Unpaired,
Pending, Pending,
Delivered,
Current,
Failed,
} }
impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState { impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState {
fn from(state: StorageWatchSnapshotState) -> Self { fn from(state: StorageWatchSnapshotState) -> Self {
match state { match state {
StorageWatchSnapshotState::Unavailable => Self::Unavailable, StorageWatchSnapshotState::Unavailable => Self::Unavailable,
StorageWatchSnapshotState::Unpaired => Self::Unpaired,
StorageWatchSnapshotState::Pending => Self::Pending, StorageWatchSnapshotState::Pending => Self::Pending,
StorageWatchSnapshotState::Delivered => Self::Delivered,
StorageWatchSnapshotState::Current => Self::Current,
StorageWatchSnapshotState::Failed => Self::Failed,
} }
} }
} }
@@ -794,6 +962,7 @@ impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState {
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileWatchSnapshotStatus { pub struct MobileWatchSnapshotStatus {
pub state: MobileWatchSnapshotState, pub state: MobileWatchSnapshotState,
pub revision: Option<u64>,
pub detail: String, pub detail: String,
} }
@@ -801,11 +970,31 @@ impl From<&StorageWatchSnapshotStatus> for MobileWatchSnapshotStatus {
fn from(status: &StorageWatchSnapshotStatus) -> Self { fn from(status: &StorageWatchSnapshotStatus) -> Self {
Self { Self {
state: status.state().into(), state: status.state().into(),
revision: status.revision(),
detail: status.detail().to_owned(), detail: status.detail().to_owned(),
} }
} }
} }
#[derive(Clone, uniffi::Record)]
pub struct MobileWatchSnapshotTransfer {
pub revision: u64,
pub selected_entries: u32,
pub snapshot: Vec<u8>,
pub delivered_receipt: Vec<u8>,
}
impl From<StorageWatchSnapshotTransfer> for MobileWatchSnapshotTransfer {
fn from(transfer: StorageWatchSnapshotTransfer) -> Self {
Self {
revision: transfer.revision(),
selected_entries: transfer.selected_entries(),
snapshot: transfer.snapshot().expose().to_vec(),
delivered_receipt: transfer.delivered_receipt().to_vec(),
}
}
}
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileTotpRow { pub struct MobileTotpRow {
pub path: String, pub path: String,
@@ -1170,13 +1359,22 @@ pub struct MobileKeyTransferOutcome {
#[derive(Debug, uniffi::Error)] #[derive(Debug, uniffi::Error)]
pub enum MobileKeyTransferFfiError { pub enum MobileKeyTransferFfiError {
Failed { message: String }, Failed {
kind: MobileKeyTransferErrorKind,
message: String,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileKeyTransferErrorKind {
ExistingKey,
Failed,
} }
impl fmt::Display for MobileKeyTransferFfiError { impl fmt::Display for MobileKeyTransferFfiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Failed { message } => formatter.write_str(message), Self::Failed { message, .. } => formatter.write_str(message),
} }
} }
} }
@@ -1185,7 +1383,17 @@ impl Error for MobileKeyTransferFfiError {}
impl From<StorageKeyTransferError> for MobileKeyTransferFfiError { impl From<StorageKeyTransferError> for MobileKeyTransferFfiError {
fn from(error: StorageKeyTransferError) -> Self { fn from(error: StorageKeyTransferError) -> Self {
let kind = match &error {
StorageKeyTransferError::ExistingKey => MobileKeyTransferErrorKind::ExistingKey,
StorageKeyTransferError::LocalSetup(error)
if error.kind() == StorageOnboardingErrorKind::ExistingKey =>
{
MobileKeyTransferErrorKind::ExistingKey
}
_ => MobileKeyTransferErrorKind::Failed,
};
Self::Failed { Self::Failed {
kind,
message: error.to_string(), message: error.to_string(),
} }
} }
@@ -1259,6 +1467,7 @@ impl MobileKeyTransferImport {
self.importer self.importer
.lock() .lock()
.map_err(|_| MobileKeyTransferFfiError::Failed { .map_err(|_| MobileKeyTransferFfiError::Failed {
kind: MobileKeyTransferErrorKind::Failed,
message: "the key-transfer session is unavailable".to_owned(), message: "the key-transfer session is unavailable".to_owned(),
})? })?
.add_frame(ironstorage::repository::SecretBytes::new( .add_frame(ironstorage::repository::SecretBytes::new(
@@ -1272,17 +1481,20 @@ impl MobileKeyTransferImport {
&self, &self,
passphrase: Option<String>, passphrase: Option<String>,
make_default: bool, make_default: bool,
overwrite: bool,
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferFfiError> { ) -> Result<MobileKeyTransferOutcome, MobileKeyTransferFfiError> {
let outcome = self let outcome = self
.importer .importer
.lock() .lock()
.map_err(|_| MobileKeyTransferFfiError::Failed { .map_err(|_| MobileKeyTransferFfiError::Failed {
kind: MobileKeyTransferErrorKind::Failed,
message: "the key-transfer session is unavailable".to_owned(), message: "the key-transfer session is unavailable".to_owned(),
})? })?
.import( .import(
passphrase passphrase
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
make_default, make_default,
overwrite,
)?; )?;
Ok(MobileKeyTransferOutcome { Ok(MobileKeyTransferOutcome {
title: outcome.title().to_owned(), title: outcome.title().to_owned(),
@@ -1322,7 +1534,7 @@ impl From<StorageAuthenticationError> for MobileAuthenticationFfiError {
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct MobileAuthentication { pub struct MobileAuthentication {
authentication: ironstorage::mobile_authentication::MobileAuthentication, authentication: Arc<ironstorage::mobile_authentication::MobileAuthentication>,
} }
#[uniffi::export] #[uniffi::export]
@@ -1344,6 +1556,49 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationFfiError> {
self.authentication
.mobile_appearance()
.map(Into::into)
.map_err(Into::into)
}
pub fn preferences(
&self,
watch_supported: bool,
watch_paired: bool,
watch_app_installed: bool,
) -> Result<MobilePreferences, MobileAuthenticationFfiError> {
self.authentication
.preferences(watch_supported, watch_paired, watch_app_installed)
.map(Into::into)
.map_err(Into::into)
}
pub fn set_authentication_timeout(
&self,
seconds: u64,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_authentication_timeout(seconds)
.map_err(Into::into)
}
pub fn set_mobile_appearance(
&self,
appearance: MobileAppearance,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_mobile_appearance(appearance.into())
.map_err(Into::into)
}
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.remove_application_token()
.map_err(Into::into)
}
pub fn set_git_identity( pub fn set_git_identity(
&self, &self,
name: String, name: String,
@@ -1494,6 +1749,16 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn search_cached_totp_page(
&self,
query: String,
) -> Result<Option<MobileTotpPage>, MobileAuthenticationFfiError> {
self.authentication
.search_cached_totp_page(&query)
.map(|page| page.map(Into::into))
.map_err(Into::into)
}
pub fn totp_detail( pub fn totp_detail(
&self, &self,
path: String, path: String,
@@ -1528,6 +1793,57 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn watch_snapshot_status(
&self,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
self.authentication
.watch_snapshot_status()
.map(|status| (&status).into())
.map_err(Into::into)
}
pub fn prepare_watch_snapshot(
&self,
platform_pairing_identity: String,
) -> Result<MobileWatchSnapshotTransfer, MobileAuthenticationFfiError> {
self.authentication
.prepare_watch_snapshot(&platform_pairing_identity)
.map(Into::into)
.map_err(Into::into)
}
pub fn set_watch_snapshot_unavailable(
&self,
unpaired: bool,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
self.authentication
.set_watch_snapshot_unavailable(unpaired, detail)
.map(|status| (&status).into())
.map_err(Into::into)
}
pub fn fail_watch_snapshot(
&self,
revision: u64,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
self.authentication
.fail_watch_snapshot(revision, detail)
.map(|status| (&status).into())
.map_err(Into::into)
}
pub fn acknowledge_watch_snapshot(
&self,
receipt: Vec<u8>,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
self.authentication
.acknowledge_watch_snapshot(&receipt)
.map(|status| (&status).into())
.map_err(Into::into)
}
pub fn begin_create_entry( pub fn begin_create_entry(
&self, &self,
directory: String, directory: String,
@@ -1545,6 +1861,16 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn create_directory(
&self,
parent: String,
name: String,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.create_directory(&parent, &name)
.map_err(Into::into)
}
pub fn entry_editor( pub fn entry_editor(
&self, &self,
editor: u64, editor: u64,
@@ -1579,6 +1905,17 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: Vec<u8>,
) -> Result<MobileEntryEditorPage, MobileAuthenticationFfiError> {
self.authentication
.add_entry_editor_otp_image(editor, ironstorage::repository::SecretBytes::new(image))
.map(|page| (&page).into())
.map_err(Into::into)
}
pub fn remove_entry_editor_field( pub fn remove_entry_editor_field(
&self, &self,
editor: u64, editor: u64,
@@ -1687,6 +2024,7 @@ pub enum MobileOnboardingErrorKind {
Authentication, Authentication,
Repository, Repository,
ExistingClone, ExistingClone,
ExistingKey,
Interrupted, Interrupted,
SecureStorage, SecureStorage,
Configuration, Configuration,
@@ -1701,6 +2039,7 @@ impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
StorageOnboardingErrorKind::Authentication => Self::Authentication, StorageOnboardingErrorKind::Authentication => Self::Authentication,
StorageOnboardingErrorKind::Repository => Self::Repository, StorageOnboardingErrorKind::Repository => Self::Repository,
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone, StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
StorageOnboardingErrorKind::ExistingKey => Self::ExistingKey,
StorageOnboardingErrorKind::Interrupted => Self::Interrupted, StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage, StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
StorageOnboardingErrorKind::Configuration => Self::Configuration, StorageOnboardingErrorKind::Configuration => Self::Configuration,
@@ -1775,6 +2114,43 @@ impl MobileOnboardingOperation {
}) })
} }
pub fn connect_local_store(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
let outcome = self.operation.connect_local_store(&self.request)?;
Ok(MobileOnboardingOutcome {
title: outcome.title().to_owned(),
detail: outcome.detail().to_owned(),
})
}
pub fn cancel(&self) {
self.operation.cancel();
}
}
#[derive(uniffi::Object)]
pub struct MobileLocalOnboardingOperation {
operation: mobile_onboarding::MobileOnboardingOperation,
}
#[uniffi::export]
impl MobileLocalOnboardingOperation {
pub fn generate(
&self,
user_id: String,
passphrase: String,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
let outcome = self.operation.setup_local_generated(
&user_id,
ironstorage::repository::SecretBytes::new(passphrase.into_bytes()),
replace_existing_key,
)?;
Ok(MobileOnboardingOutcome {
title: outcome.title().to_owned(),
detail: outcome.detail().to_owned(),
})
}
pub fn cancel(&self) { pub fn cancel(&self) {
self.operation.cancel(); self.operation.cancel();
} }
@@ -1845,9 +2221,13 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr
} }
#[uniffi::export] #[uniffi::export]
pub fn mobile_home_operation() -> Arc<MobileHomeOperation> { pub fn mobile_home_operation(
authentication: Option<Arc<MobileAuthentication>>,
) -> Arc<MobileHomeOperation> {
Arc::new(MobileHomeOperation { Arc::new(MobileHomeOperation {
operation: mobile_home::MobileHomeOperation::default(), operation: mobile_home::MobileHomeOperation::new(
authentication.map(|authentication| Arc::clone(&authentication.authentication)),
),
}) })
} }
@@ -1877,7 +2257,7 @@ pub fn mobile_password_search(query: String) -> Result<MobilePasswordPage, Mobil
#[uniffi::export] #[uniffi::export]
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> { pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
Ok(Arc::new(MobileAuthentication { Ok(Arc::new(MobileAuthentication {
authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?, authentication: Arc::new(ironstorage::mobile_authentication::MobileAuthentication::load()?),
})) }))
} }
@@ -1888,6 +2268,15 @@ pub fn mobile_key_transfer() -> Result<Arc<MobileKeyTransfer>, MobileKeyTransfer
})) }))
} }
#[uniffi::export]
pub fn mobile_local_key_transfer_importer() -> Arc<MobileKeyTransferImport> {
Arc::new(MobileKeyTransferImport {
importer: Mutex::new(
ironstorage::mobile_key_transfer::MobileKeyTransferImport::local_setup(),
),
})
}
#[uniffi::export] #[uniffi::export]
pub fn mobile_onboarding_operation( pub fn mobile_onboarding_operation(
server_url: String, server_url: String,
@@ -1906,6 +2295,13 @@ pub fn mobile_onboarding_operation(
})) }))
} }
#[uniffi::export]
pub fn mobile_local_onboarding_operation() -> Arc<MobileLocalOnboardingOperation> {
Arc::new(MobileLocalOnboardingOperation {
operation: mobile_onboarding::MobileOnboardingOperation::default(),
})
}
#[uniffi::export] #[uniffi::export]
pub fn replace_configured_mobile_application_token( pub fn replace_configured_mobile_application_token(
account: String, account: String,
@@ -1921,8 +2317,8 @@ pub fn replace_configured_mobile_application_token(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState, MobileHomeActionKind, MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError,
MobileTab, MobileShellState, MobileTab, StorageHomeActionKind,
}; };
#[test] #[test]
@@ -1966,10 +2362,17 @@ mod tests {
} }
#[test] #[test]
fn home_operation_exposes_typed_progress_and_cancellation() { fn home_bridge_preserves_storage_action_kinds() {
let operation = super::mobile_home_operation(); assert_eq!(
MobileHomeActionKind::from(StorageHomeActionKind::Push),
MobileHomeActionKind::Push
);
}
#[test]
fn home_bridge_does_not_require_entry_authentication() {
let operation = super::mobile_home_operation(None);
assert_eq!(operation.progress().phase, MobileHomePhase::Validating); assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
operation.cancel(); operation.cancel();
assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
} }
} }
+69 -27
View File
@@ -4,49 +4,90 @@ description = "Pure Rust password-store repository management"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false publish = false
[features]
default = ["full"]
full = [
"dep:apple-native-keyring-store",
"dep:arboard",
"dep:cap-std",
"dep:cap-tempfile",
"dep:clap",
"dep:clap_complete",
"dep:flate2",
"dep:gix",
"dep:gix-config",
"dep:image",
"dep:keepass",
"dep:keyring-core",
"dep:pgp",
"dep:percent-encoding",
"dep:qrcode",
"dep:rand",
"dep:regex",
"dep:reqwest",
"dep:rustls",
"dep:rqrr",
"dep:secret-service",
"dep:security-framework",
"dep:serde",
"dep:shlex",
"dep:toml",
"dep:url",
"dep:windows-native-keyring-store",
"dep:zbus-secret-service-keyring-store",
]
ssh = ["dep:russh", "dep:tokio"]
watch = []
[dependencies] [dependencies]
cap-std.workspace = true cap-std = { workspace = true, optional = true }
cap-tempfile.workspace = true cap-tempfile = { workspace = true, optional = true }
clap.workspace = true clap = { workspace = true, optional = true }
clap_complete.workspace = true clap_complete = { workspace = true, optional = true }
data-encoding.workspace = true data-encoding.workspace = true
flate2.workspace = true flate2 = { workspace = true, optional = true }
gix.workspace = true gix = { workspace = true, optional = true }
gix-config.workspace = true gix-config = { workspace = true, optional = true }
hmac.workspace = true hmac.workspace = true
image.workspace = true image = { workspace = true, optional = true }
keyring-core.workspace = true keyring-core = { workspace = true, optional = true }
keepass.workspace = true keepass = { workspace = true, optional = true }
pgp.workspace = true pgp = { workspace = true, optional = true }
qrcode.workspace = true percent-encoding = { workspace = true, optional = true }
rand.workspace = true qrcode = { workspace = true, optional = true }
regex.workspace = true rand = { workspace = true, optional = true }
reqwest.workspace = true regex = { workspace = true, optional = true }
rqrr.workspace = true reqwest = { workspace = true, optional = true }
serde.workspace = true rustls = { workspace = true, optional = true }
rqrr = { workspace = true, optional = true }
russh = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
sha1.workspace = true sha1.workspace = true
sha2.workspace = true sha2.workspace = true
shlex.workspace = true shlex = { workspace = true, optional = true }
toml.workspace = true toml = { workspace = true, optional = true }
url.workspace = true tokio = { workspace = true, optional = true }
url = { workspace = true, optional = true }
zeroize.workspace = true zeroize.workspace = true
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
apple-native-keyring-store.workspace = true apple-native-keyring-store = { workspace = true, optional = true }
security-framework.workspace = true security-framework = { workspace = true, optional = true }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store.workspace = true windows-native-keyring-store = { workspace = true, optional = true }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
arboard.workspace = true arboard = { workspace = true, optional = true }
secret-service.workspace = true secret-service = { workspace = true, optional = true }
zbus-secret-service-keyring-store.workspace = true zbus-secret-service-keyring-store = { workspace = true, optional = true }
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
arboard.workspace = true arboard = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
hex = "0.4" hex = "0.4"
@@ -55,3 +96,4 @@ nix = { version = "0.31", features = ["fs"] }
rand_chacha = "0.3" rand_chacha = "0.3"
smallvec = "1.15" smallvec = "1.15"
tempfile = "3" tempfile = "3"
tokio-stream.workspace = true
+93 -6
View File
@@ -15,7 +15,7 @@ use std::{
use crate::{ use crate::{
crypto::{KeyInfo, SecretProvider, SecretProviderError}, crypto::{KeyInfo, SecretProvider, SecretProviderError},
git::{GitCredential, GitCredentialProvider, GitError}, git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
repository::SecretBytes, repository::SecretBytes,
secret_store::{ secret_store::{
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference, NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference,
@@ -134,7 +134,7 @@ struct LeaseState {
struct Shared<B: SecretStoreBackend, C: AuthenticationClock> { struct Shared<B: SecretStoreBackend, C: AuthenticationClock> {
store: SecretStore<B>, store: SecretStore<B>,
timeout: AuthenticationTimeout, timeout: Mutex<AuthenticationTimeout>,
clock: C, clock: C,
/// Serializes access checks with relock, eliminating check-then-use races. /// Serializes access checks with relock, eliminating check-then-use races.
operation: Mutex<()>, operation: Mutex<()>,
@@ -168,9 +168,14 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationSess
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let timeout = self
.shared
.timeout
.lock()
.map_or_else(|poisoned| *poisoned.into_inner(), |timeout| *timeout);
formatter formatter
.debug_struct("AuthenticationSession") .debug_struct("AuthenticationSession")
.field("timeout", &self.shared.timeout) .field("timeout", &timeout)
.field("unlock_material", &"[REDACTED]") .field("unlock_material", &"[REDACTED]")
.finish() .finish()
} }
@@ -203,7 +208,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
// The lease is the only cache. This prevents a second lifetime // The lease is the only cache. This prevents a second lifetime
// policy in SecretStore from retaining duplicate records. // policy in SecretStore from retaining duplicate records.
store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections), store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections),
timeout, timeout: Mutex::new(timeout),
clock, clock,
operation: Mutex::new(()), operation: Mutex::new(()),
state: Mutex::new(LeaseState { state: Mutex::new(LeaseState {
@@ -308,6 +313,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
let _operation = self.shared.operation()?; let _operation = self.shared.operation()?;
self.shared.revoke(RevocationReason::Cancelled) self.shared.revoke(RevocationReason::Cancelled)
} }
/// Apply a new shared inactivity timeout to this process and any active lease.
pub fn set_timeout(&self, timeout: AuthenticationTimeout) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
let deadline = self
.shared
.clock
.now()
.checked_add(timeout.duration())
.ok_or(AuthenticationError::ClockOverflow)?;
*self.shared.timeout()? = timeout;
if let Some(active) = self.shared.state()?.active.as_mut() {
active.deadline = deadline;
}
Ok(())
}
} }
/// Generation-bound secret access. Clones are revoked together. /// Generation-bound secret access. Clones are revoked together.
@@ -351,7 +373,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
self.shared.expire_if_needed()?; self.shared.expire_if_needed()?;
let now = self.shared.clock.now(); let now = self.shared.clock.now();
let deadline = now let deadline = now
.checked_add(self.shared.timeout.duration()) .checked_add(self.shared.timeout()?.duration())
.ok_or(AuthenticationError::ClockOverflow)?; .ok_or(AuthenticationError::ClockOverflow)?;
self.shared self.shared
.with_active(self.generation, |active| active.deadline = deadline) .with_active(self.generation, |active| active.deadline = deadline)
@@ -384,6 +406,22 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
.store_openpgp_passphrase(&reference, value) .store_openpgp_passphrase(&reference, value)
.map_err(Into::into) .map_err(Into::into)
} }
/// Persist a passphrase that a completed SSH operation proved could
/// unlock and authenticate the configured private key.
pub fn persist_verified_ssh_passphrase(
&self,
fingerprint: &crate::config::SshFingerprint,
value: SecretBytes,
) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
self.shared.with_active(self.generation, |_| ())?;
self.shared
.store
.persist_verified_ssh_passphrase(fingerprint, value)
.map_err(Into::into)
}
} }
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
@@ -417,6 +455,47 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
.map_err(|_| GitError::CredentialsUnavailable)?; .map_err(|_| GitError::CredentialsUnavailable)?;
self.shared.store.credential(server, application) self.shared.store.credential(server, application)
} }
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
let _operation = self
.shared
.operation()
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared
.expire_if_needed()
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared
.with_active(self.generation, |_| ())
.map_err(|_| GitError::CredentialsUnavailable)?;
self.shared.store.credential_for(remote)
}
}
impl<B: SecretStoreBackend, C: AuthenticationClock> SshPassphraseProvider
for AuthenticationHandle<B, C>
{
fn ssh_key_passphrase(
&self,
fingerprint: &crate::config::SshFingerprint,
) -> Result<SecretBytes, GitError> {
let _operation =
self.shared
.operation()
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})?;
self.shared
.expire_if_needed()
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})?;
self.shared
.with_active(self.generation, |_| ())
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})?;
self.shared.store.ssh_key_passphrase(fingerprint)
}
} }
impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
@@ -432,10 +511,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
} }
fn timeout(
&self,
) -> Result<std::sync::MutexGuard<'_, AuthenticationTimeout>, AuthenticationError> {
self.timeout
.lock()
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
}
fn begin_lease(&self) -> Result<u64, AuthenticationError> { fn begin_lease(&self) -> Result<u64, AuthenticationError> {
let now = self.clock.now(); let now = self.clock.now();
let deadline = now let deadline = now
.checked_add(self.timeout.duration()) .checked_add(self.timeout()?.duration())
.ok_or(AuthenticationError::ClockOverflow)?; .ok_or(AuthenticationError::ClockOverflow)?;
let (generation, was_active) = { let (generation, was_active) = {
let mut state = self.state()?; let mut state = self.state()?;
File diff suppressed because it is too large. Load diff
+81 -4
View File
@@ -11,16 +11,17 @@ use std::{
use cap_std::{ambient_authority, fs::Dir}; use cap_std::{ambient_authority, fs::Dir};
use pgp::{ use pgp::{
composed::{ composed::{
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message, ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps, Esk,
MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, KeyType, Message, MessageBuilder, PublicOrSecret, SecretKeyParamsBuilder, SignedPublicKey,
SubpacketConfig, TheRing, SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
}, },
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}, crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
packet::{SignatureType, Subpacket, SubpacketData}, packet::{SignatureType, Subpacket, SubpacketData},
ser::Serialize as _, ser::Serialize as _,
types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey}, types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey},
}; };
use rand::rngs::OsRng; use rand::rngs::OsRng;
use zeroize::Zeroize as _;
use crate::repository::{EncryptedEntry, SecretBytes}; use crate::repository::{EncryptedEntry, SecretBytes};
@@ -62,6 +63,21 @@ pub struct KeyInfo {
can_sign: bool, can_sign: bool,
} }
pub struct GeneratedKey {
info: KeyInfo,
armor: SecretBytes,
}
impl GeneratedKey {
pub fn info(&self) -> &KeyInfo {
&self.info
}
pub fn into_armor(self) -> SecretBytes {
self.armor
}
}
impl KeyInfo { impl KeyInfo {
pub fn fingerprint(&self) -> &KeyFingerprint { pub fn fingerprint(&self) -> &KeyFingerprint {
&self.fingerprint &self.fingerprint
@@ -168,6 +184,61 @@ impl KeyStore {
Self::default() Self::default()
} }
/// Generate one pass-compatible protected signing certificate with a
/// Curve25519 encryption subkey and return transferable private armor.
pub fn generate(user_id: &str, passphrase: &SecretBytes) -> Result<GeneratedKey, CryptoError> {
let user_id = user_id.trim();
if user_id.is_empty()
|| user_id.len() > 240
|| user_id.chars().any(char::is_control)
|| passphrase.expose().is_empty()
{
return Err(CryptoError::InvalidGeneratedKeyInput);
}
let mut passphrase = String::from_utf8(passphrase.expose().to_vec())
.map_err(|_| CryptoError::InvalidGeneratedKeyInput)?;
let generated = (|| {
let subkey = SubkeyParamsBuilder::default()
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
.can_encrypt(EncryptionCaps::All)
.passphrase(Some(passphrase.clone()))
.build()
.map_err(|_| CryptoError::KeyGenerationFailed)?;
let secret = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519Legacy)
.can_certify(true)
.can_sign(true)
.primary_user_id(user_id.to_owned())
.preferred_symmetric_algorithms(
[SymmetricKeyAlgorithm::AES256].into_iter().collect(),
)
.preferred_hash_algorithms([HashAlgorithm::Sha256].into_iter().collect())
.preferred_compression_algorithms(Default::default())
.passphrase(Some(passphrase.clone()))
.subkey(subkey)
.build()
.map_err(|_| CryptoError::KeyGenerationFailed)?
.generate(OsRng)
.map_err(|_| CryptoError::KeyGenerationFailed)?;
secret
.verify_bindings()
.map_err(|_| CryptoError::KeyGenerationFailed)?;
let armor = secret
.to_armored_bytes(ArmorOptions::default())
.map_err(|_| CryptoError::KeyGenerationFailed)?;
let material = KeyMaterial {
public: secret.to_public_key(),
secret: Some(secret),
};
Ok(GeneratedKey {
info: key_info(&material),
armor: SecretBytes::new(armor),
})
})();
passphrase.zeroize();
generated
}
/// Load a regular exported-key file or a directory tree made only of exported-key files. /// Load a regular exported-key file or a directory tree made only of exported-key files.
pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> { pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> {
let path = path.as_ref(); let path = path.as_ref();
@@ -671,6 +742,8 @@ pub enum CryptoError {
NoKeyMaterial, NoKeyMaterial,
CorruptKeyMaterial, CorruptKeyMaterial,
InvalidKeyBindings, InvalidKeyBindings,
InvalidGeneratedKeyInput,
KeyGenerationFailed,
DuplicateFingerprint { DuplicateFingerprint {
fingerprint: KeyFingerprint, fingerprint: KeyFingerprint,
}, },
@@ -722,6 +795,10 @@ impl fmt::Display for CryptoError {
Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"), Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"),
Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"), Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"),
Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"), Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"),
Self::InvalidGeneratedKeyInput => {
formatter.write_str("the GPG key name and passphrase are required")
}
Self::KeyGenerationFailed => formatter.write_str("the GPG key could not be generated"),
Self::DuplicateFingerprint { fingerprint } => { Self::DuplicateFingerprint { fingerprint } => {
write!( write!(
formatter, formatter,
+62 -21
View File
@@ -22,10 +22,9 @@ use crate::{
EntryFieldKind, EntryFieldKind,
}, },
git::{ git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict,
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity, GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase,
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome, GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome,
PushOutcome, ReqwestGitTransport,
}, },
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter}, kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
mutation::{MutationOutcome, TreeMutator}, mutation::{MutationOutcome, TreeMutator},
@@ -434,6 +433,16 @@ impl DesktopStorage {
handle: Option<&NativeAuthenticationHandle>, handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest, request: &DesktopGitRequest,
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<DesktopGitResult, DesktopError> {
self.git_operation_with_ssh_passphrase(handle, request, control, None)
}
pub fn git_operation_with_ssh_passphrase(
&self,
handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest,
control: &GitOperationControl,
ssh_passphrase: Option<(&crate::config::SshFingerprint, &SecretBytes)>,
) -> Result<DesktopGitResult, DesktopError> { ) -> Result<DesktopGitResult, DesktopError> {
control control
.report(GitProgressPhase::Validating) .report(GitProgressPhase::Validating)
@@ -445,7 +454,7 @@ impl DesktopStorage {
self.config.git_remote(None).ok_or_else(|| { self.config.git_remote(None).ok_or_else(|| {
DesktopError::new( DesktopError::new(
DesktopErrorKind::Configuration, DesktopErrorKind::Configuration,
"no HTTPS Git remote is configured", "no Git remote is configured",
) )
}) })
}; };
@@ -453,7 +462,7 @@ impl DesktopStorage {
let handle = handle.ok_or_else(|| { let handle = handle.ok_or_else(|| {
DesktopError::new( DesktopError::new(
DesktopErrorKind::Authentication, DesktopErrorKind::Authentication,
"authentication is required for HTTPS Git credentials", "authentication is required for Git credentials",
) )
})?; })?;
handle handle
@@ -464,32 +473,53 @@ impl DesktopStorage {
let (outcome, changed_tree) = match request { let (outcome, changed_tree) = match request {
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false), DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
DesktopGitRequest::Pull => { DesktopGitRequest::Pull => {
let outcome = git let handle = authenticated()?;
.pull_with_transport_controlled( let credentials = ssh_passphrase.map_or_else(
configured()?, || GitRemoteCredentialOverride::new(handle),
None, |(fingerprint, passphrase)| {
authenticated()?, GitRemoteCredentialOverride::with_ssh_passphrase(
&EmbeddedFetchTransport, handle,
control, fingerprint,
passphrase,
) )
},
);
let outcome = git
.pull_controlled(configured()?, None, &credentials, control)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Pulled(outcome), true) (DesktopGitOutcome::Pulled(outcome), true)
} }
DesktopGitRequest::Push => { DesktopGitRequest::Push => {
let outcome = git let handle = authenticated()?;
.push_with_transport_controlled( let credentials = ssh_passphrase.map_or_else(
configured()?, || GitRemoteCredentialOverride::new(handle),
None, |(fingerprint, passphrase)| {
authenticated()?, GitRemoteCredentialOverride::with_ssh_passphrase(
&ReqwestGitTransport, handle,
control, fingerprint,
passphrase,
) )
},
);
let outcome = git
.push_controlled(configured()?, None, &credentials, control)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Pushed(outcome), false) (DesktopGitOutcome::Pushed(outcome), false)
} }
DesktopGitRequest::Sync => { DesktopGitRequest::Sync => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let (pull, push) = git let (pull, push) = git
.sync_controlled(configured()?, authenticated()?, control) .sync_controlled(configured()?, &credentials, control)
.map_err(DesktopError::git)?; .map_err(DesktopError::git)?;
(DesktopGitOutcome::Synchronized { pull, push }, true) (DesktopGitOutcome::Synchronized { pull, push }, true)
} }
@@ -515,6 +545,17 @@ impl DesktopStorage {
}) })
} }
#[cfg(feature = "ssh")]
pub fn confirm_ssh_host(&self, host_key: &crate::git::SshHostKey) -> Result<(), DesktopError> {
let remote = self.config.git_remote(None).ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Configuration,
"no Git remote is configured",
)
})?;
crate::git::confirm_ssh_host(remote, host_key).map_err(DesktopError::git)
}
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> { pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
let repository = self.repository()?; let repository = self.repository()?;
let keys = self.keys()?; let keys = self.keys()?;
+1104 -129
View File
File diff suppressed because it is too large. Load diff
+907
View File
@@ -0,0 +1,907 @@
use std::{
fs,
path::Path,
sync::{Arc, Mutex, mpsc},
thread,
time::Duration,
};
use russh::{
Channel, ChannelId, Sig,
keys::{PrivateKey, PublicKey, ssh_key::Algorithm},
server,
};
use sha1::{Digest as _, Sha1};
use super::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitRepository, PullOutcome, SshPassphraseProvider, build_pack,
};
use crate::{
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
repository::{Repository, SecretBytes},
};
#[derive(Clone, Copy)]
enum ReceiveBehavior {
Success,
NonFastForward,
Reject,
UnpackFailure,
MalformedStatus,
EarlyEof,
NonZero,
Signal,
Disconnect,
SlowSuccess,
}
#[derive(Clone)]
struct UploadFixture {
advertisement: Vec<u8>,
pack: Vec<u8>,
}
#[derive(Default)]
struct Observed {
commands: Vec<Vec<u8>>,
receive_requests: Vec<Vec<u8>>,
}
#[derive(Clone, Copy)]
enum Service {
Upload,
Receive,
}
struct GitServerHandler {
user_key: PublicKey,
upload: Option<UploadFixture>,
advertised_receive_tip: Option<String>,
receive_behavior: ReceiveBehavior,
malformed_upload: bool,
observed: Arc<Mutex<Observed>>,
service: Option<Service>,
input: Vec<u8>,
saw_want: bool,
saw_have: bool,
}
impl Clone for GitServerHandler {
fn clone(&self) -> Self {
Self {
user_key: self.user_key.clone(),
upload: self.upload.clone(),
advertised_receive_tip: self.advertised_receive_tip.clone(),
receive_behavior: self.receive_behavior,
malformed_upload: self.malformed_upload,
observed: Arc::clone(&self.observed),
service: None,
input: Vec::new(),
saw_want: false,
saw_have: false,
}
}
}
impl server::Handler for GitServerHandler {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &PublicKey,
) -> Result<server::Auth, Self::Error> {
Ok(if user == "git" && public_key == &self.user_key {
server::Auth::Accept
} else {
server::Auth::reject()
})
}
async fn channel_open_session(
&mut self,
_channel: Channel<server::Msg>,
reply: server::ChannelOpenHandle,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
command: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
self.observed
.lock()
.expect("observed")
.commands
.push(command.to_vec());
if command.starts_with(b"git-upload-pack '") && command.ends_with(b"'") {
self.service = Some(Service::Upload);
session.channel_success(channel)?;
if self.malformed_upload {
session.data(channel, b"zzzz".to_vec())?;
session.close(channel)?;
} else if let Some(upload) = &self.upload {
session.data(channel, upload.advertisement.clone())?;
} else {
session.channel_failure(channel)?;
}
} else if command.starts_with(b"git-receive-pack '") && command.ends_with(b"'") {
self.service = Some(Service::Receive);
session.channel_success(channel)?;
session.data(
channel,
receive_advertisement(self.advertised_receive_tip.as_deref()),
)?;
} else {
session.channel_failure(channel)?;
}
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
match self.service {
Some(Service::Receive) => self.input.extend_from_slice(data),
Some(Service::Upload) => self.upload_data(channel, data, session)?,
None => session.close(channel)?,
}
Ok(())
}
async fn channel_eof(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if matches!(self.service, Some(Service::Receive)) {
self.observed
.lock()
.expect("observed")
.receive_requests
.push(self.input.clone());
self.finish_receive(channel, session).await?;
}
Ok(())
}
}
impl GitServerHandler {
fn upload_data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut server::Session,
) -> Result<(), russh::Error> {
self.input.extend_from_slice(data);
while self.input.len() >= 4 {
let Some(length) = std::str::from_utf8(&self.input[..4])
.ok()
.and_then(|value| usize::from_str_radix(value, 16).ok())
else {
session.close(channel)?;
return Ok(());
};
if length == 0 {
self.input.drain(..4);
if !self.saw_want {
session.exit_status_request(channel, 0)?;
session.eof(channel)?;
session.close(channel)?;
return Ok(());
} else if self.saw_have {
session.data(channel, packet(b"NAK\n"))?;
self.saw_have = false;
}
continue;
}
if length < 4 || self.input.len() < length {
break;
}
let line = self.input[4..length].to_vec();
self.input.drain(..length);
if line.starts_with(b"want ") {
self.saw_want = true;
} else if line.starts_with(b"have ") {
self.saw_have = true;
} else if line == b"done\n" || line == b"done" {
session.data(channel, packet(b"NAK\n"))?;
if let Some(upload) = &self.upload {
for chunk in upload.pack.chunks(997) {
let mut sideband = Vec::with_capacity(chunk.len() + 1);
sideband.push(1);
sideband.extend_from_slice(chunk);
session.data(channel, packet(&sideband))?;
}
}
session.data(channel, b"0000".to_vec())?;
session.exit_status_request(channel, 0)?;
session.eof(channel)?;
session.close(channel)?;
return Ok(());
}
}
Ok(())
}
async fn finish_receive(
&self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), russh::Error> {
let reference = receive_reference(&self.input).unwrap_or("refs/heads/main");
match self.receive_behavior {
ReceiveBehavior::Success => {
if self.input == b"0000" {
session.exit_status_request(channel, 0)?;
} else {
send_status(
session,
channel,
b"unpack ok\n",
format!("ok {reference}\n"),
)?;
}
}
ReceiveBehavior::NonFastForward => send_status(
session,
channel,
b"unpack ok\n",
format!("ng {reference} non-fast-forward\n"),
)?,
ReceiveBehavior::Reject => send_status(
session,
channel,
b"unpack ok\n",
format!("ng {reference} protected branch\n"),
)?,
ReceiveBehavior::UnpackFailure => {
send_status(session, channel, b"unpack corrupt pack\n", String::new())?
}
ReceiveBehavior::MalformedStatus => {
session.data(channel, b"zzzz".to_vec())?;
session.exit_status_request(channel, 0)?;
}
ReceiveBehavior::EarlyEof => {
session.eof(channel)?;
session.close(channel)?;
return Ok(());
}
ReceiveBehavior::Disconnect => {
session.close(channel)?;
return Ok(());
}
ReceiveBehavior::NonZero => {
session.extended_data(channel, 1, b"receive-pack failed\n".to_vec())?;
session.exit_status_request(channel, 7)?;
}
ReceiveBehavior::Signal => {
session.exit_signal_request(channel, Sig::TERM, false, "killed", "")?;
}
ReceiveBehavior::SlowSuccess => {
tokio::time::sleep(Duration::from_millis(500)).await;
send_status(
session,
channel,
b"unpack ok\n",
format!("ok {reference}\n"),
)?;
}
}
session.eof(channel)?;
session.close(channel)
}
}
fn send_status(
session: &mut server::Session,
channel: ChannelId,
unpack: &[u8],
reference: String,
) -> Result<(), russh::Error> {
session.data(channel, packet(unpack))?;
if !reference.is_empty() {
session.data(channel, packet(reference.as_bytes()))?;
}
session.data(channel, b"0000".to_vec())?;
session.exit_status_request(channel, 0)
}
struct TestServer {
port: u16,
host_key: PrivateKey,
observed: Arc<Mutex<Observed>>,
join: thread::JoinHandle<()>,
}
fn start_server(
user_key: PublicKey,
upload: Option<UploadFixture>,
receive_tip: Option<String>,
receive_behavior: ReceiveBehavior,
malformed_upload: bool,
connections: usize,
) -> TestServer {
let host_key = key();
let observed = Arc::new(Mutex::new(Observed::default()));
let handler = GitServerHandler {
user_key,
upload,
advertised_receive_tip: receive_tip,
receive_behavior,
malformed_upload,
observed: Arc::clone(&observed),
service: None,
input: Vec::new(),
saw_want: false,
saw_have: false,
};
let server_key = host_key.clone();
let (port_tx, port_rx) = mpsc::channel();
let join = thread::spawn(move || {
tokio::runtime::Runtime::new()
.expect("server runtime")
.block_on(async move {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind server");
port_tx
.send(listener.local_addr().expect("address").port())
.expect("send port");
let config = Arc::new(server::Config {
keys: vec![server_key],
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
..server::Config::default()
});
for _ in 0..connections {
let (stream, _) = listener.accept().await.expect("accept client");
if let Ok(session) =
server::run_stream(Arc::clone(&config), stream, handler.clone()).await
{
let _ = session.await;
}
}
});
});
TestServer {
port: port_rx
.recv_timeout(Duration::from_secs(5))
.expect("server port"),
host_key,
observed,
join,
}
}
struct Credentials;
impl GitCredentialProvider for Credentials {
fn credential(
&self,
_server: &crate::config::ServerId,
_application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> {
Err(GitError::CredentialsUnavailable)
}
}
impl SshPassphraseProvider for Credentials {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
Err(GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})
}
}
fn key() -> PrivateKey {
PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519).expect("generate key")
}
fn packet(data: &[u8]) -> Vec<u8> {
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
output.extend_from_slice(data);
output
}
fn identity() -> GitIdentity {
GitIdentity::new("SSH Push Test", "ssh-push@ironstorage.invalid").expect("identity")
}
fn commit(git: &GitRepository, path: &str, contents: &[u8], message: &str) -> String {
fs::write(git.root().join(path), contents).expect("write worktree");
git.stage(&[path.into()]).expect("stage");
git.commit(message).expect("commit")
}
fn upload_fixture(repository: &GitRepository) -> UploadFixture {
let head = repository.repository.head_commit().expect("head").id;
let capabilities = "multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag symref=HEAD:refs/heads/main";
let mut advertisement = packet(format!("{head} HEAD\0{capabilities}\n").as_bytes());
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/main\n").as_bytes()));
advertisement.extend_from_slice(b"0000");
UploadFixture {
advertisement,
pack: build_pack(&repository.repository, head, None).expect("pack"),
}
}
fn receive_advertisement(tip: Option<&str>) -> Vec<u8> {
let mut output = match tip {
Some(tip) => packet(
format!("{tip} refs/heads/main\0report-status delete-refs ofs-delta\n").as_bytes(),
),
None => packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs ofs-delta\n"),
};
output.extend_from_slice(b"0000");
output
}
fn remote(root: &Path, server: &TestServer, identity: &PrivateKey, scp_like: bool) -> GitRemote {
let identity_file = root.join("identity");
fs::write(
&identity_file,
identity
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("identity"),
)
.expect("write identity");
let known_hosts = root.join("known_hosts");
fs::write(
&known_hosts,
format!(
"[127.0.0.1]:{} {}\n",
server.port,
server.host_key.public_key().to_openssh().expect("host key")
),
)
.expect("known hosts");
let url = if scp_like {
"git@127.0.0.1:team/store.git".to_owned()
} else {
format!("ssh://git@127.0.0.1:{}/team/store.git", server.port)
};
let mut remote = GitRemote::ssh_with_authentication(
"origin",
url,
SshRemoteAuthentication::key_file(identity_file, known_hosts).expect("authentication"),
)
.expect("remote");
remote.set_ssh_test_port(server.port);
remote
}
fn local_repository(root: &Path) -> GitRepository {
fs::create_dir(root).expect("local root");
let store = Repository::open(root).expect("store");
GitRepository::init(&store, identity()).expect("Git")
}
fn receive_reference(input: &[u8]) -> Option<&str> {
let length = std::str::from_utf8(input.get(..4)?)
.ok()
.and_then(|value| usize::from_str_radix(value, 16).ok())?;
let command = std::str::from_utf8(input.get(4..length)?).ok()?;
command.split_once('\0')?.0.split_whitespace().nth(2)
}
fn tracking_id(repository: &GitRepository) -> Option<String> {
repository
.repository
.try_find_reference("refs/remotes/origin/main")
.expect("tracking lookup")
.map(|reference| reference.id().to_string())
}
fn assert_pack_request(request: &[u8], old: Option<&str>, new: &str) {
let pack_offset = request
.windows(4)
.position(|window| window == b"PACK")
.expect("pack payload");
let command = &request[..pack_offset];
assert!(
command
.windows(new.len())
.any(|window| window == new.as_bytes())
);
assert!(
command
.windows(b"refs/heads/main".len())
.any(|window| window == b"refs/heads/main")
);
if let Some(old) = old {
assert!(
command
.windows(old.len())
.any(|window| window == old.as_bytes())
);
}
let pack = &request[pack_offset..];
assert_eq!(&pack[..4], b"PACK");
assert_eq!(
u32::from_be_bytes(pack[4..8].try_into().expect("version")),
2
);
assert!(u32::from_be_bytes(pack[8..12].try_into().expect("count")) >= 3);
assert_eq!(
Sha1::digest(&pack[..pack.len() - 20]).as_slice(),
&pack[pack.len() - 20..]
);
assert!(
!request
.windows(b"PRIVATE KEY".len())
.any(|window| window == b"PRIVATE KEY")
);
}
#[test]
fn new_branch_fast_forward_and_already_current_push_are_confirmed() {
for scp_like in [false, true] {
for old_kind in [None, Some("base")] {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
let base = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let new = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
let old = old_kind.map(|_| base.clone());
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
old.clone(),
ReceiveBehavior::Success,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, scp_like);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
let outcome = local
.push(&remote, Some("main"), &Credentials)
.expect("push");
assert_eq!(outcome.new_id(), new);
assert_eq!(tracking_id(&local), Some(new.clone()));
server.join.join().expect("server");
let observed = server.observed.lock().expect("observed");
let expected = if scp_like {
b"git-receive-pack 'team/store.git'".to_vec()
} else {
b"git-receive-pack '/team/store.git'".to_vec()
};
assert_eq!(observed.commands, [expected]);
assert_eq!(observed.receive_requests.len(), 1);
assert_pack_request(&observed.receive_requests[0], old.as_deref(), &new);
}
}
for scp_like in [false, true] {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
let head = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
Some(head.clone()),
ReceiveBehavior::Success,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, scp_like);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
local
.push(&remote, Some("main"), &Credentials)
.expect("current push");
assert_eq!(tracking_id(&local), Some(head));
server.join.join().expect("server");
assert_eq!(
server.observed.lock().expect("observed").receive_requests,
[b"0000".to_vec()]
);
}
}
#[test]
fn receive_pack_failures_never_advance_tracking_or_change_local_state() {
for (behavior, expected) in [
(ReceiveBehavior::NonFastForward, GitError::NonFastForward),
(
ReceiveBehavior::Reject,
GitError::RemoteRefRejected {
diagnostic: "protected branch".to_owned(),
},
),
(
ReceiveBehavior::UnpackFailure,
GitError::RemoteUnpackFailed {
diagnostic: "corrupt pack".to_owned(),
},
),
(
ReceiveBehavior::MalformedStatus,
GitError::PushOutcomeUnknown {
diagnostic: "malformed receive-pack status".to_owned(),
},
),
(
ReceiveBehavior::EarlyEof,
GitError::PushOutcomeUnknown {
diagnostic: "SSH service ended without confirmed status".to_owned(),
},
),
(
ReceiveBehavior::NonZero,
GitError::PushOutcomeUnknown {
diagnostic: "receive-pack failed".to_owned(),
},
),
(
ReceiveBehavior::Signal,
GitError::PushOutcomeUnknown {
diagnostic: "killed".to_owned(),
},
),
(
ReceiveBehavior::Disconnect,
GitError::PushOutcomeUnknown {
diagnostic: "SSH service ended without confirmed status".to_owned(),
},
),
] {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let head = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
let before_status = local.status().expect("status");
let before_entry = fs::read(local.root().join("entry.gpg")).expect("entry");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
None,
behavior,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, false);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
assert_eq!(
local
.push(&remote, Some("main"), &Credentials)
.expect_err("push failure"),
expected
);
assert_eq!(tracking_id(&local), None);
assert_eq!(local.status().expect("status"), before_status);
assert_eq!(
fs::read(local.root().join("entry.gpg")).expect("entry"),
before_entry
);
assert_eq!(local.log(Some(1)).expect("log")[0].id(), head);
server.join.join().expect("server");
}
}
#[test]
fn non_fast_forward_and_cancellation_fail_without_blind_replay() {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let unrelated = "1111111111111111111111111111111111111111".to_owned();
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
Some(unrelated),
ReceiveBehavior::Success,
false,
1,
);
let non_ff_remote = remote(temporary.path(), &server, &user_key, false);
let mut local = local;
local
.add_remote("origin", non_ff_remote.url())
.expect("remote");
assert_eq!(
local.push(&non_ff_remote, Some("main"), &Credentials),
Err(GitError::NonFastForward)
);
server.join.join().expect("server");
assert_eq!(
server.observed.lock().expect("observed").receive_requests,
[b"0000".to_vec()]
);
fs::write(local.root().join("staged.gpg"), b"staged").expect("staged entry");
local.stage(&["staged.gpg".into()]).expect("stage entry");
let staged = local.status().expect("staged status");
assert_eq!(
local.push(&non_ff_remote, Some("main"), &Credentials),
Err(GitError::DirtyWorktree)
);
assert_eq!(local.status().expect("status after refusal"), staged);
let control = GitOperationControl::default();
control.cancel();
assert_eq!(
local.push_controlled(&non_ff_remote, Some("main"), &Credentials, &control),
Err(GitError::Cancelled)
);
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
None,
ReceiveBehavior::SlowSuccess,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, false);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
let control = GitOperationControl::default();
let cancel = control.clone();
let cancellation = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
cancel.cancel();
});
assert!(matches!(
local.push_controlled(&remote, Some("main"), &Credentials, &control),
Err(GitError::PushOutcomeUnknown { .. })
));
cancellation.join().expect("cancellation");
assert_eq!(tracking_id(&local), None);
server.join.join().expect("server");
assert_eq!(
server
.observed
.lock()
.expect("observed")
.receive_requests
.len(),
1
);
}
#[test]
fn untrusted_and_unauthenticated_clients_never_request_receive_pack() {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let accepted_identity = key();
let server = start_server(
accepted_identity.public_key().clone(),
None,
None,
ReceiveBehavior::Success,
false,
2,
);
let changed = remote(temporary.path(), &server, &accepted_identity, false);
fs::write(
temporary.path().join("known_hosts"),
format!(
"[127.0.0.1]:{} {}\n",
server.port,
key().public_key().to_openssh().expect("wrong host key")
),
)
.expect("changed known hosts");
let mut local = local;
local.add_remote("origin", changed.url()).expect("remote");
assert!(matches!(
local.push(&changed, Some("main"), &Credentials),
Err(GitError::ChangedSshHostKey { .. })
));
let rejected = remote(temporary.path(), &server, &key(), false);
assert_eq!(
local.push(&rejected, Some("main"), &Credentials),
Err(GitError::SshAuthenticationRejected)
);
server.join.join().expect("server");
assert!(
server
.observed
.lock()
.expect("observed")
.commands
.is_empty()
);
}
#[test]
fn synchronization_pulls_then_pushes_over_both_ssh_url_forms() {
for scp_like in [false, true] {
let temporary = tempfile::tempdir().expect("temporary directory");
let source = local_repository(&temporary.path().join("source"));
let base = commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
Some(upload_fixture(&source)),
Some(base.clone()),
ReceiveBehavior::Success,
false,
3,
);
let remote = remote(temporary.path(), &server, &user_key, scp_like);
let destination = temporary.path().join("clone");
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
.expect("clone");
let new = commit(&clone, "local.gpg", b"local", "Local change");
let (pull, push) = clone.sync(&remote, &Credentials).expect("synchronize");
assert_eq!(pull, PullOutcome::UpToDate);
assert_eq!(push.new_id(), new);
assert_eq!(tracking_id(&clone), Some(new));
server.join.join().expect("server");
let observed = server.observed.lock().expect("observed");
assert_eq!(
observed
.commands
.iter()
.filter(|command| command.starts_with(b"git-upload-pack"))
.count(),
2
);
assert_eq!(
observed
.commands
.iter()
.filter(|command| command.starts_with(b"git-receive-pack"))
.count(),
1
);
let path = if scp_like {
"'team/store.git'"
} else {
"'/team/store.git'"
};
let upload = format!("git-upload-pack {path}").into_bytes();
let receive = format!("git-receive-pack {path}").into_bytes();
assert!(
observed
.commands
.iter()
.all(|command| command == &upload || command == &receive)
);
}
}
#[test]
fn synchronization_never_pushes_after_pull_failure() {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
None,
ReceiveBehavior::Success,
true,
1,
);
let remote = remote(temporary.path(), &server, &user_key, false);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
assert!(local.sync(&remote, &Credentials).is_err());
server.join.join().expect("server");
let commands = &server.observed.lock().expect("observed").commands;
assert_eq!(commands.len(), 1);
assert!(commands[0].starts_with(b"git-upload-pack"));
}
+740
View File
@@ -0,0 +1,740 @@
use std::{
collections::BTreeSet,
fs,
path::Path,
sync::{Arc, Mutex, mpsc},
thread,
time::Duration,
};
use russh::{
Channel, ChannelId,
keys::{PrivateKey, PublicKey, ssh_key::Algorithm},
server,
};
use super::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitRepository, PullOutcome, SshPassphraseProvider, build_pack,
};
use crate::{
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
repository::{Repository, SecretBytes},
};
#[derive(Clone, Copy)]
enum Behavior {
Normal,
MalformedAdvertisement,
MalformedPack,
NonZero,
Disconnect,
Slow,
}
#[derive(Clone)]
struct Fixture {
advertisement: Vec<u8>,
pack: Vec<u8>,
}
struct UploadServer {
user_key: PublicKey,
fixture: Arc<Mutex<Fixture>>,
commands: Arc<Mutex<Vec<Vec<u8>>>>,
behavior: Behavior,
input: Vec<u8>,
saw_want: bool,
saw_have: bool,
}
impl Clone for UploadServer {
fn clone(&self) -> Self {
Self {
user_key: self.user_key.clone(),
fixture: Arc::clone(&self.fixture),
commands: Arc::clone(&self.commands),
behavior: self.behavior,
input: Vec::new(),
saw_want: false,
saw_have: false,
}
}
}
impl server::Handler for UploadServer {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &PublicKey,
) -> Result<server::Auth, Self::Error> {
Ok(if user == "git" && public_key == &self.user_key {
server::Auth::Accept
} else {
server::Auth::reject()
})
}
async fn channel_open_session(
&mut self,
_channel: Channel<server::Msg>,
reply: server::ChannelOpenHandle,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
command: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
self.commands
.lock()
.expect("commands")
.push(command.to_vec());
if !command.starts_with(b"git-upload-pack '") || !command.ends_with(b"'") {
session.channel_failure(channel)?;
return Ok(());
}
session.channel_success(channel)?;
match self.behavior {
Behavior::MalformedAdvertisement => session.data(channel, b"zzzz".to_vec())?,
Behavior::Disconnect => {
session.close(channel)?;
return Ok(());
}
_ => session.data(
channel,
self.fixture.lock().expect("fixture").advertisement.clone(),
)?,
}
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
if matches!(self.behavior, Behavior::Slow | Behavior::Disconnect) {
return Ok(());
}
self.input.extend_from_slice(data);
while self.input.len() >= 4 {
let Some(length) = std::str::from_utf8(&self.input[..4])
.ok()
.and_then(|value| usize::from_str_radix(value, 16).ok())
else {
session.close(channel)?;
return Ok(());
};
if length == 0 {
self.input.drain(..4);
if !self.saw_want {
self.finish_without_pack(channel, session)?;
} else if self.saw_have {
session.data(channel, packet(b"NAK\n"))?;
self.saw_have = false;
}
continue;
}
if length < 4 || self.input.len() < length {
break;
}
let line = self.input[4..length].to_vec();
self.input.drain(..length);
if line.starts_with(b"want ") {
self.saw_want = true;
} else if line.starts_with(b"have ") {
self.saw_have = true;
} else if line == b"done\n" || line == b"done" {
self.send_pack(channel, session)?;
return Ok(());
}
}
Ok(())
}
}
impl UploadServer {
fn finish_without_pack(
&self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), russh::Error> {
if matches!(self.behavior, Behavior::NonZero) {
session.extended_data(channel, 1, b"repository unavailable\n".to_vec())?;
session.exit_status_request(channel, 9)?;
} else {
session.exit_status_request(channel, 0)?;
}
session.eof(channel)?;
session.close(channel)
}
fn send_pack(
&self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), russh::Error> {
session.data(channel, packet(b"NAK\n"))?;
let fixture = self.fixture.lock().expect("fixture").clone();
let pack = if matches!(self.behavior, Behavior::MalformedPack) {
b"PACK\0\0\0\x02\0\0\0\x01broken".as_slice()
} else {
fixture.pack.as_slice()
};
for chunk in pack.chunks(997) {
let mut sideband = Vec::with_capacity(chunk.len() + 1);
sideband.push(1);
sideband.extend_from_slice(chunk);
session.data(channel, packet(&sideband))?;
}
session.data(channel, b"0000".to_vec())?;
if matches!(self.behavior, Behavior::NonZero) {
session.extended_data(channel, 1, b"upload-pack failed\n".to_vec())?;
session.exit_status_request(channel, 7)?;
} else {
session.exit_status_request(channel, 0)?;
}
session.eof(channel)?;
session.close(channel)
}
}
struct Server {
port: u16,
host_key: PrivateKey,
fixture: Arc<Mutex<Fixture>>,
commands: Arc<Mutex<Vec<Vec<u8>>>>,
join: thread::JoinHandle<()>,
}
fn start_server(
user_key: PublicKey,
fixture: Fixture,
behavior: Behavior,
connections: usize,
) -> Server {
let host_key = key();
let shared_fixture = Arc::new(Mutex::new(fixture));
let commands = Arc::new(Mutex::new(Vec::new()));
let handler = UploadServer {
user_key,
fixture: Arc::clone(&shared_fixture),
commands: Arc::clone(&commands),
behavior,
input: Vec::new(),
saw_want: false,
saw_have: false,
};
let server_key = host_key.clone();
let (port_tx, port_rx) = mpsc::channel();
let join = thread::spawn(move || {
tokio::runtime::Runtime::new()
.expect("server runtime")
.block_on(async move {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind server");
port_tx
.send(listener.local_addr().expect("address").port())
.expect("send port");
let config = Arc::new(server::Config {
keys: vec![server_key],
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
..server::Config::default()
});
for _ in 0..connections {
let (stream, _) = listener.accept().await.expect("accept client");
if let Ok(session) =
server::run_stream(Arc::clone(&config), stream, handler.clone()).await
{
let _ = session.await;
}
}
});
});
Server {
port: port_rx
.recv_timeout(Duration::from_secs(5))
.expect("server port"),
host_key,
fixture: shared_fixture,
commands,
join,
}
}
struct Credentials;
impl GitCredentialProvider for Credentials {
fn credential(
&self,
_server: &crate::config::ServerId,
_application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> {
Err(GitError::CredentialsUnavailable)
}
}
impl SshPassphraseProvider for Credentials {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
Err(GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})
}
}
fn key() -> PrivateKey {
PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519).expect("generate key")
}
fn packet(data: &[u8]) -> Vec<u8> {
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
output.extend_from_slice(data);
output
}
fn identity() -> GitIdentity {
GitIdentity::new("SSH Test", "ssh@ironstorage.invalid").expect("identity")
}
fn commit(git: &GitRepository, path: &str, contents: &[u8], message: &str) -> String {
fs::write(git.root().join(path), contents).expect("write worktree");
git.stage(&[path.into()]).expect("stage");
git.commit(message).expect("commit")
}
fn populated_fixture(repository: &GitRepository) -> Fixture {
let head = repository.repository.head_commit().expect("head").id;
let capabilities = "multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag symref=HEAD:refs/heads/main";
let mut advertisement = packet(format!("{head} HEAD\0{capabilities}\n").as_bytes());
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/feature\n").as_bytes()));
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/main\n").as_bytes()));
advertisement.extend_from_slice(&packet(format!("{head} refs/tags/v1\n").as_bytes()));
advertisement.extend_from_slice(b"0000");
Fixture {
advertisement,
pack: build_pack(&repository.repository, head, None).expect("pack"),
}
}
fn empty_fixture() -> Fixture {
let mut advertisement = packet(
b"0000000000000000000000000000000000000000 capabilities^{}\0multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag\n",
);
advertisement.extend_from_slice(b"0000");
Fixture {
advertisement,
pack: Vec::new(),
}
}
fn remote(root: &Path, server: &Server, identity: &PrivateKey, path: &str) -> GitRemote {
remote_with_url(
root,
server,
identity,
format!("ssh://git@127.0.0.1:{}{path}", server.port),
)
}
fn remote_with_url(root: &Path, server: &Server, identity: &PrivateKey, url: String) -> GitRemote {
let identity_file = root.join("identity");
fs::write(
&identity_file,
identity
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
.expect("identity"),
)
.expect("write identity");
let known_hosts = root.join("known_hosts");
fs::write(
&known_hosts,
format!(
"[127.0.0.1]:{} {}\n",
server.port,
server.host_key.public_key().to_openssh().expect("host key")
),
)
.expect("known hosts");
let mut remote = GitRemote::ssh_with_authentication(
"origin",
url,
SshRemoteAuthentication::key_file(identity_file, known_hosts).expect("authentication"),
)
.expect("remote");
remote.set_ssh_test_port(server.port);
remote
}
#[test]
fn both_ssh_url_forms_drive_discovery_clone_fetch_fast_forward_merge_and_conflict() {
for scp_like in [false, true] {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
commit(&source, "shared.gpg", b"base", "Add shared entry");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
populated_fixture(&source),
Behavior::Normal,
6,
);
let remote = if scp_like {
remote_with_url(
temporary.path(),
&server,
&user_key,
"git@127.0.0.1:team/store.git".to_owned(),
)
} else {
remote(temporary.path(), &server, &user_key, "/team/store.git")
};
let branches = GitRepository::discover_remote_branches(
temporary.path(),
identity(),
&remote,
&Credentials,
&GitOperationControl::default(),
)
.expect("discover branches");
assert_eq!(branches, ["feature", "main"]);
let destination = temporary.path().join("clone");
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
.expect("clone over SSH");
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
b"base"
);
assert!(
clone
.repository
.try_find_reference("refs/tags/v1")
.expect("tag lookup")
.is_some()
);
commit(&source, "remote-one.gpg", b"remote", "Remote fast-forward");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert!(
clone
.fetch(&remote, &Credentials)
.expect("fetch over SSH")
.received_pack()
);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("fast-forward pull"),
PullOutcome::FastForward
);
commit(&clone, "local.gpg", b"local", "Local change");
commit(
&source,
"remote-two.gpg",
b"remote",
"Remote parallel change",
);
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("merge pull"),
PullOutcome::Merged
);
commit(&clone, "shared.gpg", b"local conflict", "Local conflict");
commit(&source, "shared.gpg", b"remote conflict", "Remote conflict");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
let before = fs::read(destination.join("shared.gpg")).expect("local entry");
assert!(matches!(
clone.pull(&remote, Some("main"), &Credentials),
Err(GitError::MergeConflicts { .. })
));
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
before
);
server.join.join().expect("server");
let commands = server.commands.lock().expect("commands");
assert_eq!(commands.len(), 6);
let expected = if scp_like {
b"git-upload-pack 'team/store.git'".as_slice()
} else {
b"git-upload-pack '/team/store.git'".as_slice()
};
assert!(commands.iter().all(|command| command == expected));
}
}
#[test]
fn empty_upload_pack_discovers_no_branches_and_leaves_no_partial_clone() {
let temporary = tempfile::tempdir().expect("temporary directory");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
empty_fixture(),
Behavior::Normal,
2,
);
let remote = remote(temporary.path(), &server, &user_key, "/empty.git");
assert!(matches!(
GitRepository::discover_remote_branches(
temporary.path(),
identity(),
&remote,
&Credentials,
&GitOperationControl::default(),
),
Err(GitError::InvalidRepository(message)) if message == "the remote has no branches"
));
let destination = temporary.path().join("empty-clone");
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
assert!(matches!(
result,
Err(GitError::RemoteNotFound { name }) if name == "refs/remotes/origin/main"
));
assert!(!destination.exists());
server.join.join().expect("server");
}
#[test]
fn malformed_pack_does_not_change_existing_repository_state() {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"REMOTE\n", "Remote initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
populated_fixture(&source),
Behavior::MalformedPack,
1,
);
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
let local_root = temporary.path().join("local");
fs::create_dir(&local_root).expect("local directory");
let local_store = Repository::open(&local_root).expect("local store");
let mut local = GitRepository::init(&local_store, identity()).expect("local Git");
commit(&local, ".gpg-id", b"LOCAL\n", "Local initialize");
local.add_remote("origin", remote.url()).expect("remote");
let head = local.log(Some(1)).expect("log")[0].id().to_owned();
let status = local.status().expect("status");
let contents = fs::read(local_root.join(".gpg-id")).expect("contents");
assert_eq!(
local
.fetch(&remote, &Credentials)
.expect_err("malformed pack"),
GitError::MalformedGitPack
);
assert_eq!(local.log(Some(1)).expect("log")[0].id(), head);
assert_eq!(local.status().expect("status"), status);
assert_eq!(
fs::read(local_root.join(".gpg-id")).expect("contents"),
contents
);
assert!(
local
.repository
.try_find_reference("refs/remotes/origin/main")
.expect("tracking lookup")
.is_none()
);
server.join.join().expect("server");
}
#[test]
fn untrusted_and_unauthenticated_clients_never_request_upload_pack() {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
let accepted_identity = key();
let server = start_server(
accepted_identity.public_key().clone(),
populated_fixture(&source),
Behavior::Normal,
2,
);
let changed = remote(
temporary.path(),
&server,
&accepted_identity,
"/team/store.git",
);
fs::write(
temporary.path().join("known_hosts"),
format!(
"[127.0.0.1]:{} {}\n",
server.port,
key().public_key().to_openssh().expect("wrong host key")
),
)
.expect("changed known hosts");
assert!(matches!(
GitRepository::clone_into(
&temporary.path().join("untrusted"),
identity(),
&changed,
&Credentials,
),
Err(GitError::ChangedSshHostKey { .. })
));
let rejected = remote(temporary.path(), &server, &key(), "/team/store.git");
assert_eq!(
match GitRepository::clone_into(
&temporary.path().join("unauthenticated"),
identity(),
&rejected,
&Credentials,
) {
Ok(_) => panic!("authentication unexpectedly succeeded"),
Err(error) => error,
},
GitError::SshAuthenticationRejected
);
server.join.join().expect("server");
assert!(server.commands.lock().expect("commands").is_empty());
}
#[test]
fn empty_malformed_failed_and_cancelled_services_preserve_destinations() {
for (behavior, expected) in [
(
Behavior::MalformedAdvertisement,
GitError::GitProtocolFailed,
),
(Behavior::MalformedPack, GitError::MalformedGitPack),
(
Behavior::NonZero,
GitError::SshRemoteServiceFailed {
diagnostic: "repository unavailable".to_owned(),
},
),
(
Behavior::Disconnect,
GitError::SshRemoteServiceFailed {
diagnostic: String::new(),
},
),
] {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("store");
let source = GitRepository::init(&source_store, identity()).expect("Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
let fixture = if matches!(behavior, Behavior::NonZero | Behavior::Disconnect) {
empty_fixture()
} else {
populated_fixture(&source)
};
let user_key = key();
let server = start_server(user_key.public_key().clone(), fixture, behavior, 1);
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
let destination = temporary.path().join("destination");
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
let error = match result {
Ok(_) => panic!("clone unexpectedly succeeded"),
Err(error) => error,
};
assert_eq!(error, expected);
assert!(!destination.exists());
assert!(
fs::read_dir(temporary.path())
.expect("temporary root")
.filter_map(Result::ok)
.all(|entry| !entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-clone-"))
);
server.join.join().expect("server");
}
let temporary = tempfile::tempdir().expect("temporary directory");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
empty_fixture(),
Behavior::Slow,
1,
);
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
let destination = temporary.path().join("cancelled");
let control = GitOperationControl::default();
let cancel = control.clone();
let cancellation = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
cancel.cancel();
});
let result = GitRepository::clone_into_with_transport_controlled(
&destination,
identity(),
&remote,
None,
&Credentials,
&super::EmbeddedFetchTransport,
&control,
);
let error = match result {
Ok(_) => panic!("clone unexpectedly succeeded"),
Err(error) => error,
};
assert_eq!(error, GitError::Cancelled);
cancellation.join().expect("cancellation");
assert!(!destination.exists());
server.join.join().expect("server");
}
#[test]
fn advertised_refs_are_sorted_and_unique() {
let temporary = tempfile::tempdir().expect("temporary directory");
let store = Repository::open(temporary.path()).expect("store");
let repository = GitRepository::init(&store, identity()).expect("Git");
commit(&repository, ".gpg-id", b"ALICE\n", "Initialize");
let fixture = populated_fixture(&repository);
let refs = super::decode_pkt_lines(&fixture.advertisement).expect("advertisement");
let names = refs
.into_iter()
.flatten()
.filter_map(|line| {
line.split(|byte| *byte == b' ').nth(1).map(|name| {
name.split(|byte| *byte == 0)
.next()
.unwrap_or_default()
.to_vec()
})
})
.collect::<Vec<_>>();
assert_eq!(names.first().map(Vec::as_slice), Some(b"HEAD".as_slice()));
assert_eq!(
names[1..].iter().cloned().collect::<BTreeSet<_>>().len(),
names.len() - 1
);
}
+31
View File
@@ -5,31 +5,62 @@
//! //!
//! This crate is the sole owner of stored and derived password-store objects. //! This crate is the sole owner of stored and derived password-store objects.
#[cfg(feature = "full")]
pub mod authentication; pub mod authentication;
#[cfg(feature = "full")]
pub mod command; pub mod command;
#[cfg(feature = "full")]
pub mod config; pub mod config;
#[cfg(feature = "full")]
pub mod crypto; pub mod crypto;
#[cfg(feature = "full")]
pub mod desktop; pub mod desktop;
#[cfg(feature = "full")]
pub mod document; pub mod document;
#[cfg(feature = "full")]
pub mod generate; pub mod generate;
#[cfg(feature = "full")]
pub mod git; pub mod git;
#[cfg(feature = "full")]
pub mod kdbx; pub mod kdbx;
#[cfg(feature = "full")]
pub mod mobile; pub mod mobile;
#[cfg(feature = "full")]
pub mod mobile_authentication; pub mod mobile_authentication;
#[cfg(feature = "full")]
pub mod mobile_entry; pub mod mobile_entry;
#[cfg(feature = "full")]
pub mod mobile_home; pub mod mobile_home;
#[cfg(feature = "full")]
pub mod mobile_key_transfer; pub mod mobile_key_transfer;
#[cfg(feature = "full")]
pub mod mobile_mutation; pub mod mobile_mutation;
#[cfg(feature = "full")]
pub mod mobile_onboarding; pub mod mobile_onboarding;
#[cfg(feature = "full")]
pub mod mobile_passwords; pub mod mobile_passwords;
#[cfg(feature = "full")]
pub mod mobile_totp; pub mod mobile_totp;
pub mod mobile_watch;
#[cfg(feature = "full")]
pub mod mutation; pub mod mutation;
#[cfg(feature = "full")]
pub mod otp; pub mod otp;
mod otp_core;
#[cfg(feature = "full")]
pub mod presentation; pub mod presentation;
#[cfg(feature = "full")]
pub mod read; pub mod read;
#[cfg(feature = "full")]
pub mod recipient; pub mod recipient;
#[cfg(feature = "full")]
pub mod repository; pub mod repository;
mod secret;
#[cfg(feature = "full")]
pub mod secret_store; pub mod secret_store;
#[cfg(all(feature = "full", feature = "ssh"))]
pub mod ssh;
#[cfg(feature = "full")]
pub mod write; pub mod write;
/// Product name shared by the presentation adapters. /// Product name shared by the presentation adapters.
+696 -13
View File
@@ -1,12 +1,13 @@
//! Shared mobile authentication state; Swift only supplies input and presents results. //! Shared mobile authentication state; Swift only supplies input and presents results.
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex}; use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
use crate::{ use crate::{
authentication::{ authentication::{
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession, AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
NativeAuthenticationSession,
}, },
config::{Config, ConfigError}, config::{Config, ConfigError, GitRemote, MobileAppearance},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
@@ -22,12 +23,18 @@ use crate::{
mobile_totp::{ mobile_totp::{
MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService,
}, },
mobile_watch::{
MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError,
WatchSnapshotSender, WatchSnapshotTransfer,
},
otp::OtpError, otp::OtpError,
recipient::RecipientPolicyManager, recipient::RecipientPolicyManager,
repository::{ repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
}, },
secret_store::{SecretProtectionPolicy, SecretStoreError}, secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
},
write::{VaultWriter, WriteError}, write::{VaultWriter, WriteError},
}; };
@@ -129,6 +136,94 @@ pub struct MobileAuthenticationState {
remaining_seconds: u64, remaining_seconds: u64,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileWatchPreferenceState {
Unsupported,
NotPaired,
AppNotInstalled,
Ready,
Pending,
Delivered,
Current,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobilePreferences {
sync_configured: bool,
repository_title: String,
repository_url: String,
server_title: String,
server_identity: String,
application_account: Option<String>,
default_key_title: String,
default_key_fingerprint: String,
authentication_timeout_seconds: u64,
biometric_unlock_enabled: bool,
appearance: MobileAppearance,
watch_state: MobileWatchPreferenceState,
watch_title: String,
watch_detail: String,
}
impl MobilePreferences {
pub fn sync_configured(&self) -> bool {
self.sync_configured
}
pub fn repository_title(&self) -> &str {
&self.repository_title
}
pub fn repository_url(&self) -> &str {
&self.repository_url
}
pub fn server_title(&self) -> &str {
&self.server_title
}
pub fn server_identity(&self) -> &str {
&self.server_identity
}
pub fn application_account(&self) -> Option<&str> {
self.application_account.as_deref()
}
pub fn default_key_title(&self) -> &str {
&self.default_key_title
}
pub fn default_key_fingerprint(&self) -> &str {
&self.default_key_fingerprint
}
pub fn authentication_timeout_seconds(&self) -> u64 {
self.authentication_timeout_seconds
}
pub fn biometric_unlock_enabled(&self) -> bool {
self.biometric_unlock_enabled
}
pub fn appearance(&self) -> MobileAppearance {
self.appearance
}
pub fn watch_state(&self) -> MobileWatchPreferenceState {
self.watch_state
}
pub fn watch_title(&self) -> &str {
&self.watch_title
}
pub fn watch_detail(&self) -> &str {
&self.watch_detail
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy { pub struct MobileEntryCopy {
value: String, value: String,
@@ -190,12 +285,44 @@ struct ActiveMobileLease {
struct MobileAuthenticationStatus { struct MobileAuthenticationStatus {
biometric_unlock_enabled: bool, biometric_unlock_enabled: bool,
authentication_timeout: AuthenticationTimeout,
appearance: MobileAppearance,
git_identity: GitIdentity, git_identity: GitIdentity,
active: Option<ActiveMobileLease>, active: Option<ActiveMobileLease>,
next_editor_id: u64, next_editor_id: u64,
editors: BTreeMap<u64, MobileEntryDraft>, editors: BTreeMap<u64, MobileEntryDraft>,
mutation_active: bool, mutation_active: bool,
repository_operation_active: bool,
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>, watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
watch_snapshot: WatchSnapshotSender,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum MobileRepositoryOperation {
Commit,
Fetch,
Pull,
Push,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum MobileRepositoryOperationError {
Busy,
DirtyEditor,
OpenEditor,
Unavailable,
}
pub(crate) struct MobileRepositoryOperationGuard<'a> {
authentication: &'a MobileAuthentication,
}
impl Drop for MobileRepositoryOperationGuard<'_> {
fn drop(&mut self) {
if let Ok(mut status) = self.authentication.status.lock() {
status.repository_operation_active = false;
}
}
} }
/// One process-wide mobile authentication lease shared by every tab and viewer. /// One process-wide mobile authentication lease shared by every tab and viewer.
@@ -224,15 +351,21 @@ impl MobileAuthentication {
config.authentication_timeout(), config.authentication_timeout(),
) )
.map_err(MobileAuthenticationError::authentication)?; .map_err(MobileAuthenticationError::authentication)?;
let watch_snapshot =
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
Ok(Self { Ok(Self {
status: Mutex::new(MobileAuthenticationStatus { status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(), biometric_unlock_enabled: config.biometric_unlock_enabled(),
authentication_timeout: config.authentication_timeout(),
appearance: config.mobile_appearance(),
git_identity: config.git_identity().clone(), git_identity: config.git_identity().clone(),
active: None, active: None,
next_editor_id: 0, next_editor_id: 0,
editors: BTreeMap::new(), editors: BTreeMap::new(),
mutation_active: false, mutation_active: false,
repository_operation_active: false,
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
watch_snapshot,
}), }),
config, config,
repository, repository,
@@ -400,6 +533,10 @@ impl MobileAuthentication {
Ok(self.status()?.git_identity.clone()) Ok(self.status()?.git_identity.clone())
} }
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationError> {
Ok(self.status()?.appearance)
}
pub fn set_git_identity( pub fn set_git_identity(
&self, &self,
name: String, name: String,
@@ -419,6 +556,137 @@ impl MobileAuthentication {
Ok(identity) Ok(identity)
} }
pub fn preferences(
&self,
watch_supported: bool,
watch_paired: bool,
watch_app_installed: bool,
) -> Result<MobilePreferences, MobileAuthenticationError> {
let remote = self
.config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.or_else(|| self.config.git_remotes().first());
let handle = self
.keys
.resolve(self.config.default_key().as_str())
.map_err(key_error)?;
let key = self
.keys
.infos()
.find(|key| key.fingerprint() == handle.fingerprint())
.ok_or_else(|| config_detail("The default GPG key is unavailable."))?;
let status = self.status()?;
let selected = status.watch_shared_totp_entries.len();
let snapshot = status.watch_snapshot.status();
let (watch_state, watch_title, watch_detail) = watch_preference(
watch_supported,
watch_paired,
watch_app_installed,
selected,
&snapshot,
);
let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs();
let biometric_unlock_enabled = status.biometric_unlock_enabled;
let appearance = status.appearance;
drop(status);
let repository_title = remote.map_or_else(
|| "Local Password Store".to_owned(),
remote_repository_title,
);
Ok(MobilePreferences {
sync_configured: remote.is_some(),
repository_title,
repository_url: remote.map_or_else(
|| "Stored and versioned on this iPhone".to_owned(),
|remote| remote.url().to_string(),
),
server_title: remote
.and_then(|remote| {
remote
.endpoint()
.as_https()
.and_then(url::Url::host_str)
.or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host()))
})
.unwrap_or("Git Sync Not Configured")
.to_owned(),
server_identity: remote.map_or_else(
|| "Add an HTTPS remote when you want to sync".to_owned(),
|remote| {
remote.https_credentials().map_or_else(
|| "SSH identity and host trust".to_owned(),
|(server_id, _)| server_id.as_str().to_owned(),
)
},
),
application_account: remote.map(application_account).transpose()?.flatten(),
default_key_title: key
.user_ids()
.first()
.cloned()
.unwrap_or_else(|| self.config.default_key().to_string()),
default_key_fingerprint: key.fingerprint().to_string(),
authentication_timeout_seconds,
biometric_unlock_enabled,
appearance,
watch_state,
watch_title,
watch_detail,
})
}
pub fn set_authentication_timeout(
&self,
seconds: u64,
) -> Result<(), MobileAuthenticationError> {
let timeout = AuthenticationTimeout::new(Duration::from_secs(seconds))
.map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?;
let previous = self.status()?.authentication_timeout;
self.session
.set_timeout(timeout)
.map_err(MobileAuthenticationError::authentication)?;
if let Err(error) = self.config.update_authentication_timeout(timeout) {
let _ = self.session.set_timeout(previous);
return Err(config_error(error));
}
self.status()?.authentication_timeout = timeout;
Ok(())
}
pub fn set_mobile_appearance(
&self,
appearance: MobileAppearance,
) -> Result<(), MobileAuthenticationError> {
self.config
.update_mobile_appearance(appearance)
.map_err(config_error)?;
self.status()?.appearance = appearance;
Ok(())
}
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationError> {
let remote = self
.config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.or_else(|| self.config.git_remotes().first())
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
config_detail("The configured Git remote does not use HTTPS credentials.")
})?;
let result = store.delete_https_git_credential(server_id, application_id);
let lock_result = store.lock();
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
return Err(preference_secret_error(result.expect_err("checked error")));
}
lock_result.map_err(preference_secret_error)
}
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> { pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
let status = self.status()?; let status = self.status()?;
let active = status.active.as_ref().ok_or_else(|| { let active = status.active.as_ref().ok_or_else(|| {
@@ -454,7 +722,10 @@ impl MobileAuthentication {
&cache_path, &cache_path,
) )
.map_err(totp_error)?; .map_err(totp_error)?;
let (totp, cache_notice) = reconciliation.into_parts(); let (mut totp, cache_notice) = reconciliation.into_parts();
if let Some(detail) = &mut totp {
detail.set_watch(self.watch_snapshot_status()?);
}
Ok(MobileEntryPresentation { Ok(MobileEntryPresentation {
page: MobileEntryPage::from_document(&document), page: MobileEntryPage::from_document(&document),
totp, totp,
@@ -581,15 +852,32 @@ impl MobileAuthentication {
}; };
let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let cache_path = self.config.source().with_file_name("totp-catalog.toml");
let mut provider = KeyOnlyProvider::new(handle, &key); let mut provider = KeyOnlyProvider::new(handle, &key);
MobileTotpService::new(&self.repository, &self.keys) let mut page = MobileTotpService::new(&self.repository, &self.keys)
.discover(&shared, &mut provider, &cache_path, operation) .discover(&shared, &mut provider, &cache_path, operation)
.map_err(totp_error) .map_err(totp_error)?;
page.set_watch(self.watch_snapshot_status()?);
Ok(page)
} }
pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> { pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
self.search_cached_totp_page("")
}
pub fn search_cached_totp_page(
&self,
query: &str,
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
let shared = self.status()?.watch_shared_totp_entries.clone(); let shared = self.status()?.watch_shared_totp_entries.clone();
let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let cache_path = self.config.source().with_file_name("totp-catalog.toml");
Ok(MobileTotpService::new(&self.repository, &self.keys).cached_page(&shared, &cache_path)) let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
&shared,
&cache_path,
query,
);
if let Some(page) = &mut page {
page.set_watch(self.watch_snapshot_status()?);
}
Ok(page)
} }
pub fn totp_detail( pub fn totp_detail(
@@ -608,9 +896,11 @@ impl MobileAuthentication {
) )
}; };
let mut provider = KeyOnlyProvider::new(handle, &key); let mut provider = KeyOnlyProvider::new(handle, &key);
MobileTotpService::new(&self.repository, &self.keys) let mut detail = MobileTotpService::new(&self.repository, &self.keys)
.detail(path, unix_seconds, &shared, &mut provider) .detail(path, unix_seconds, &shared, &mut provider)
.map_err(totp_error) .map_err(totp_error)?;
detail.set_watch(self.watch_snapshot_status()?);
Ok(detail)
} }
pub fn copy_totp_code( pub fn copy_totp_code(
@@ -648,12 +938,92 @@ impl MobileAuthentication {
self.totp_detail(path, unix_seconds) self.totp_detail(path, unix_seconds)
} }
pub fn watch_snapshot_status(
&self,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
Ok(self.status()?.watch_snapshot.status())
}
pub fn prepare_watch_snapshot(
&self,
platform_pairing_identity: &str,
) -> Result<WatchSnapshotTransfer, MobileAuthenticationError> {
self.ensure_active()?;
let (handle, key, selected) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(
active.handle.clone(),
active.key.clone(),
status.watch_shared_totp_entries.clone(),
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
let selection = MobileTotpService::new(&self.repository, &self.keys)
.watch_snapshot_selection(&selected, &mut provider)
.map_err(totp_error)?;
let (entries, retained) = selection.into_parts();
let mut status = self.status()?;
if status.watch_shared_totp_entries != selected {
return Err(entry_detail(
"Apple Watch Snapshot Changed",
"the selected TOTP entries changed while the snapshot was being prepared",
));
}
if retained != selected {
drop(status);
self.config
.update_watch_shared_totp_entries(&retained)
.map_err(config_error)?;
status = self.status()?;
status.watch_shared_totp_entries = retained;
}
status
.watch_snapshot
.prepare(platform_pairing_identity, entries)
.map_err(watch_error)
}
pub fn set_watch_snapshot_unavailable(
&self,
unpaired: bool,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
let mut status = self.status()?;
status
.watch_snapshot
.unavailable(unpaired, detail)
.map_err(watch_error)?;
Ok(status.watch_snapshot.status())
}
pub fn fail_watch_snapshot(
&self,
revision: u64,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
let mut status = self.status()?;
status.watch_snapshot.failed(revision, detail);
Ok(status.watch_snapshot.status())
}
pub fn acknowledge_watch_snapshot(
&self,
receipt: &[u8],
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
self.status()?
.watch_snapshot
.acknowledge(receipt)
.map_err(watch_error)
}
pub fn replace_entry_field( pub fn replace_entry_field(
&self, &self,
path: &str, path: &str,
field: u64, field: u64,
value: String, value: String,
) -> Result<MobileEntryPage, MobileAuthenticationError> { ) -> Result<MobileEntryPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let mut document = self.open_active_document(path)?; let mut document = self.open_active_document(path)?;
document document
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) .replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
@@ -727,6 +1097,44 @@ impl MobileAuthentication {
self.store_editor(MobileEntryDraft::new(document, true)) self.store_editor(MobileEntryDraft::new(document, true))
} }
pub fn create_directory(
&self,
parent: &str,
name: &str,
) -> Result<(), MobileAuthenticationError> {
self.ensure_repository_idle()?;
let parent = DirectoryPath::parse(parent).map_err(entry_error)?;
let leaf = DirectoryPath::parse(name).map_err(entry_error)?;
if leaf
.as_path()
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty())
{
return Err(entry_detail(
"Folder Name Is Invalid",
"enter a name without a folder separator",
));
}
let directory =
DirectoryPath::parse(parent.as_path().join(leaf.as_path())).map_err(entry_error)?;
if self
.repository
.snapshot()
.map_err(entry_error)?
.directories()
.any(|record| record.path() == &directory)
{
return Err(entry_detail(
"Folder Already Exists",
"Choose a different folder name.",
));
}
self.repository
.ensure_directory(&directory)
.map_err(|error| entry_detail("Folder Could Not Be Created", error))?;
Ok(())
}
pub fn entry_editor( pub fn entry_editor(
&self, &self,
editor: u64, editor: u64,
@@ -765,6 +1173,18 @@ impl MobileAuthentication {
Ok(draft.page()) Ok(draft.page())
} }
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: SecretBytes,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_active()?;
let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
draft.add_otp_image(image).map_err(editor_error)?;
Ok(draft.page())
}
pub fn remove_entry_editor_field( pub fn remove_entry_editor_field(
&self, &self,
editor: u64, editor: u64,
@@ -811,6 +1231,7 @@ impl MobileAuthentication {
fields: Vec<MobileEntryEditorInput>, fields: Vec<MobileEntryEditorInput>,
) -> Result<MobileEntryPage, MobileAuthenticationError> { ) -> Result<MobileEntryPage, MobileAuthenticationError> {
self.ensure_active()?; self.ensure_active()?;
self.ensure_repository_idle()?;
let mut draft = self let mut draft = self
.status()? .status()?
.editors .editors
@@ -926,10 +1347,10 @@ impl MobileAuthentication {
draft: MobileEntryDraft, draft: MobileEntryDraft,
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> { ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
let mut status = self.status()?; let mut status = self.status()?;
if status.mutation_active { if status.mutation_active || status.repository_operation_active {
return Err(entry_detail( return Err(entry_detail(
"Password Action In Progress", "Password Action In Progress",
"wait for the current move, copy, or delete action to finish", "wait for the current password-store action to finish",
)); ));
} }
let id = status.next_editor_id; let id = status.next_editor_id;
@@ -947,7 +1368,7 @@ impl MobileAuthentication {
fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> { fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
let mut status = self.status()?; let mut status = self.status()?;
// ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it. // ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
if status.mutation_active { if status.mutation_active || status.repository_operation_active {
return Err(MobileAuthenticationError::new( return Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Conflict, MobileAuthenticationErrorKind::Conflict,
"Password Action In Progress", "Password Action In Progress",
@@ -958,6 +1379,44 @@ impl MobileAuthentication {
Ok(()) Ok(())
} }
pub(crate) fn reserve_repository_operation(
&self,
operation: MobileRepositoryOperation,
) -> Result<MobileRepositoryOperationGuard<'_>, MobileRepositoryOperationError> {
let mut status = self
.status
.lock()
.map_err(|_| MobileRepositoryOperationError::Unavailable)?;
let dirty_editor = status
.editors
.values()
.any(|draft| draft.document().is_modified());
if let Some(error) = repository_operation_conflict(
operation,
status.mutation_active || status.repository_operation_active,
!status.editors.is_empty(),
dirty_editor,
) {
return Err(error);
}
status.repository_operation_active = true;
drop(status);
Ok(MobileRepositoryOperationGuard {
authentication: self,
})
}
fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> {
if self.status()?.repository_operation_active {
Err(entry_detail(
"Repository Action In Progress",
"wait for the current commit, fetch, pull, or push action to finish",
))
} else {
Ok(())
}
}
fn release_entry_mutation(&self) { fn release_entry_mutation(&self) {
if let Ok(mut status) = self.status.lock() { if let Ok(mut status) = self.status.lock() {
status.mutation_active = false; status.mutation_active = false;
@@ -1048,6 +1507,27 @@ impl MobileAuthentication {
} }
} }
fn repository_operation_conflict(
operation: MobileRepositoryOperation,
busy: bool,
open_editor: bool,
dirty_editor: bool,
) -> Option<MobileRepositoryOperationError> {
if busy {
Some(MobileRepositoryOperationError::Busy)
} else if operation == MobileRepositoryOperation::Pull && open_editor {
Some(if dirty_editor {
MobileRepositoryOperationError::DirtyEditor
} else {
MobileRepositoryOperationError::OpenEditor
})
} else if operation == MobileRepositoryOperation::Push && dirty_editor {
Some(MobileRepositoryOperationError::DirtyEditor)
} else {
None
}
}
struct KeyOnlyProvider<'a> { struct KeyOnlyProvider<'a> {
handle: NativeAuthenticationHandle, handle: NativeAuthenticationHandle,
fingerprint: &'a str, fingerprint: &'a str,
@@ -1079,6 +1559,148 @@ fn config_error(error: ConfigError) -> MobileAuthenticationError {
) )
} }
fn config_detail(detail: &str) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Configuration,
"Preference Is Unavailable",
detail,
)
}
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(preference_secret_error)
}
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
let Some((server_id, application_id)) = remote.https_credentials() else {
return Ok(None);
};
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let result = store.https_git_credential_account(server_id, application_id);
let lock_result = store.lock();
let account = match result {
Ok(account) => Some(account),
Err(SecretStoreError::Missing) => None,
Err(error) => return Err(preference_secret_error(error)),
};
lock_result
.map_err(preference_secret_error)
.map(|()| account)
}
fn remote_repository_title(remote: &GitRemote) -> String {
let path = remote.endpoint().as_https().map_or_else(
|| {
remote
.endpoint()
.as_ssh()
.map(|ssh| ssh.path().as_str())
.unwrap_or_default()
},
|url| url.path(),
);
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or("Password Store")
.trim_end_matches(".git")
.to_owned()
}
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,
"Application Token Is Unavailable",
match error {
SecretStoreError::Denied => "Access to protected token storage was denied.".to_owned(),
SecretStoreError::Cancelled => {
"Access to protected token storage was cancelled.".to_owned()
}
_ => "Protected application-token storage is unavailable.".to_owned(),
},
)
}
fn watch_preference(
supported: bool,
paired: bool,
app_installed: bool,
selected: usize,
snapshot: &MobileWatchSnapshotStatus,
) -> (MobileWatchPreferenceState, String, String) {
if !supported {
return (
MobileWatchPreferenceState::Unsupported,
"Unavailable".to_owned(),
"Apple Watch connectivity is unavailable on this device.".to_owned(),
);
}
if !paired {
return (
MobileWatchPreferenceState::NotPaired,
"Not Paired".to_owned(),
"Pair an Apple Watch in the Watch app to enable synchronization.".to_owned(),
);
}
if !app_installed {
return (
MobileWatchPreferenceState::AppNotInstalled,
"Watch App Not Installed".to_owned(),
"Install the IronStorage companion on the paired Apple Watch.".to_owned(),
);
}
match snapshot.state() {
MobileWatchSnapshotState::Pending => (
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Delivered => (
MobileWatchPreferenceState::Delivered,
"Delivered".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Current => (
MobileWatchPreferenceState::Current,
"Current".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Failed => (
MobileWatchPreferenceState::Failed,
"Synchronization Failed".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Unavailable | MobileWatchSnapshotState::Unpaired => {
if selected == 0 {
(
MobileWatchPreferenceState::Ready,
"Ready".to_owned(),
"No TOTP codes are selected for Apple Watch.".to_owned(),
)
} else {
(
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
format!(
"{selected} selected TOTP {} pending Apple Watch synchronization.",
if selected == 1 {
"code is"
} else {
"codes are"
}
),
)
}
}
}
}
fn entry_error(error: RepositoryError) -> MobileAuthenticationError { fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
MobileAuthenticationError::new( MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry, MobileAuthenticationErrorKind::Entry,
@@ -1171,6 +1793,14 @@ fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
} }
} }
fn watch_error(error: WatchSnapshotError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
"Apple Watch Synchronization Failed",
error.to_string(),
)
}
fn editor_missing() -> MobileAuthenticationError { fn editor_missing() -> MobileAuthenticationError {
entry_detail( entry_detail(
"Entry Draft Is Unavailable", "Entry Draft Is Unavailable",
@@ -1185,3 +1815,56 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
error.to_string(), error.to_string(),
) )
} }
#[cfg(test)]
mod tests {
use super::{
MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
MobileWatchSnapshotState, MobileWatchSnapshotStatus, repository_operation_conflict,
watch_preference,
};
#[test]
fn repository_actions_protect_editors_and_serialize_mutations() {
assert_eq!(
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, false),
Some(MobileRepositoryOperationError::OpenEditor)
);
assert_eq!(
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, true),
Some(MobileRepositoryOperationError::DirtyEditor)
);
assert_eq!(
repository_operation_conflict(MobileRepositoryOperation::Push, false, true, true),
Some(MobileRepositoryOperationError::DirtyEditor)
);
assert_eq!(
repository_operation_conflict(MobileRepositoryOperation::Fetch, false, true, true),
None
);
assert_eq!(
repository_operation_conflict(MobileRepositoryOperation::Commit, true, false, false),
Some(MobileRepositoryOperationError::Busy)
);
}
#[test]
fn watch_preferences_render_platform_and_selection_state() {
let unavailable = MobileWatchSnapshotStatus {
state: MobileWatchSnapshotState::Unavailable,
revision: None,
detail: "not started".to_owned(),
};
assert_eq!(
watch_preference(true, false, false, 2, &unavailable).0,
MobileWatchPreferenceState::NotPaired
);
assert_eq!(
watch_preference(true, true, true, 0, &unavailable).0,
MobileWatchPreferenceState::Ready
);
let pending = watch_preference(true, true, true, 2, &unavailable);
assert_eq!(pending.0, MobileWatchPreferenceState::Pending);
assert!(pending.2.contains("2 selected TOTP codes"));
}
}
+33
View File
@@ -7,6 +7,8 @@ use crate::document::{
EntryFieldKind, EntrySensitivity, EntryFieldKind, EntrySensitivity,
}; };
use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH}; use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH};
use crate::presentation::{QrError, QrMatrix};
use crate::repository::SecretBytes;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileEntrySectionKind { pub enum MobileEntrySectionKind {
@@ -330,6 +332,15 @@ impl MobileEntryDraft {
name: Option<String>, name: Option<String>,
value: String, value: String,
) -> Result<(), MobileEntryEditorError> { ) -> Result<(), MobileEntryEditorError> {
if kind == MobileEntryEditorFieldKind::OtpUri
&& self
.document
.fields()
.iter()
.any(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
{
return Err(MobileEntryEditorError::ExistingOtp);
}
let draft = match kind { let draft = match kind {
MobileEntryEditorFieldKind::Password => { MobileEntryEditorFieldKind::Password => {
return Err(MobileEntryEditorError::ProtectedPasswordField); return Err(MobileEntryEditorError::ProtectedPasswordField);
@@ -343,10 +354,26 @@ impl MobileEntryDraft {
} }
MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?, MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?,
}; };
if kind == MobileEntryEditorFieldKind::OtpUri
&& self.creating
&& let [field] = self.document.fields()
&& field.metadata().kind() == EntryFieldKind::Password
&& field.value().is_empty()
{
self.document.update(field.id(), draft)?;
return Ok(());
}
self.document.add(self.document.fields().len(), draft)?; self.document.add(self.document.fields().len(), draft)?;
Ok(()) Ok(())
} }
pub fn add_otp_image(&mut self, image: SecretBytes) -> Result<(), MobileEntryEditorError> {
let payload = QrMatrix::decode_image(&image).map_err(MobileEntryEditorError::Qr)?;
let uri = String::from_utf8(payload.expose().to_vec())
.map_err(|_| MobileEntryEditorError::NonUtf8)?;
self.add(MobileEntryEditorFieldKind::OtpUri, None, uri)
}
pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> { pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> {
let id = EntryFieldId::from_value(id); let id = EntryFieldId::from_value(id);
if self if self
@@ -420,6 +447,8 @@ pub enum MobileEntryEditorError {
MissingFieldName, MissingFieldName,
ProtectedPasswordField, ProtectedPasswordField,
NonUtf8, NonUtf8,
ExistingOtp,
Qr(QrError),
} }
impl fmt::Display for MobileEntryEditorError { impl fmt::Display for MobileEntryEditorError {
@@ -433,6 +462,10 @@ impl fmt::Display for MobileEntryEditorError {
formatter.write_str("the first password field cannot be removed or moved") formatter.write_str("the first password field cannot be removed or moved")
} }
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"), Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
Self::ExistingOtp => formatter.write_str(
"this entry already has an OTP URI; edit its existing field to replace it",
),
Self::Qr(error) => error.fmt(formatter),
} }
} }
} }
+508 -33
View File
@@ -1,4 +1,4 @@
//! Storage-owned remote activity and pull-to-refresh models for iPhone Home. //! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
use std::{ use std::{
collections::BTreeSet, collections::BTreeSet,
@@ -12,8 +12,12 @@ use std::{
use crate::{ use crate::{
config::{Config, ConfigError, GitRemote}, config::{Config, ConfigError, GitRemote},
git::{ git::{
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl, FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
GitProgressPhase, GitRepository, PullOutcome, GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
PushOutcome,
},
mobile_authentication::{
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
}, },
repository::{Repository, RepositoryError}, repository::{Repository, RepositoryError},
secret_store::{ secret_store::{
@@ -46,6 +50,35 @@ pub enum MobileHomeChangeStatus {
Deleted, Deleted,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeActionKind {
Commit,
Fetch,
Pull,
Push,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeAction {
kind: MobileHomeActionKind,
title: &'static str,
system_image: &'static str,
}
impl MobileHomeAction {
pub fn kind(&self) -> MobileHomeActionKind {
self.kind
}
pub fn title(&self) -> &str {
self.title
}
pub fn system_image(&self) -> &str {
self.system_image
}
}
impl From<GitChangeKind> for MobileHomeChangeStatus { impl From<GitChangeKind> for MobileHomeChangeStatus {
fn from(kind: GitChangeKind) -> Self { fn from(kind: GitChangeKind) -> Self {
match kind { match kind {
@@ -62,6 +95,7 @@ pub struct MobileHomeSummaryRow {
title: String, title: String,
detail: String, detail: String,
system_image: String, system_image: String,
actions: Vec<MobileHomeAction>,
} }
impl MobileHomeSummaryRow { impl MobileHomeSummaryRow {
@@ -80,6 +114,10 @@ impl MobileHomeSummaryRow {
pub fn system_image(&self) -> &str { pub fn system_image(&self) -> &str {
&self.system_image &self.system_image
} }
pub fn actions(&self) -> &[MobileHomeAction] {
&self.actions
}
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -126,6 +164,7 @@ pub struct MobileHomeCommit {
system_image: String, system_image: String,
timestamp: i64, timestamp: i64,
changes: Vec<MobileHomeChange>, changes: Vec<MobileHomeChange>,
actions: Vec<MobileHomeAction>,
} }
impl MobileHomeCommit { impl MobileHomeCommit {
@@ -152,6 +191,10 @@ impl MobileHomeCommit {
pub fn changes(&self) -> &[MobileHomeChange] { pub fn changes(&self) -> &[MobileHomeChange] {
&self.changes &self.changes
} }
pub fn actions(&self) -> &[MobileHomeAction] {
&self.actions
}
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -177,6 +220,7 @@ impl MobileHomeNotice {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomePage { pub struct MobileHomePage {
remote_configured: bool,
freshness: MobileHomeFreshness, freshness: MobileHomeFreshness,
refreshed_at: Option<i64>, refreshed_at: Option<i64>,
summaries: Vec<MobileHomeSummaryRow>, summaries: Vec<MobileHomeSummaryRow>,
@@ -188,6 +232,10 @@ pub struct MobileHomePage {
} }
impl MobileHomePage { impl MobileHomePage {
pub fn remote_configured(&self) -> bool {
self.remote_configured
}
pub fn freshness(&self) -> MobileHomeFreshness { pub fn freshness(&self) -> MobileHomeFreshness {
self.freshness self.freshness
} }
@@ -227,6 +275,7 @@ pub enum MobileHomePhase {
Authenticating, Authenticating,
Receiving, Receiving,
Integrating, Integrating,
Sending,
Finishing, Finishing,
} }
@@ -236,7 +285,8 @@ impl From<GitProgressPhase> for MobileHomePhase {
GitProgressPhase::Validating => Self::Validating, GitProgressPhase::Validating => Self::Validating,
GitProgressPhase::Authenticating => Self::Authenticating, GitProgressPhase::Authenticating => Self::Authenticating,
GitProgressPhase::Receiving => Self::Receiving, GitProgressPhase::Receiving => Self::Receiving,
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating, GitProgressPhase::Integrating => Self::Integrating,
GitProgressPhase::Sending => Self::Sending,
GitProgressPhase::Refreshing => Self::Finishing, GitProgressPhase::Refreshing => Self::Finishing,
} }
} }
@@ -244,12 +294,17 @@ impl From<GitProgressPhase> for MobileHomePhase {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeProgress { pub struct MobileHomeProgress {
action: Option<MobileHomeActionKind>,
phase: MobileHomePhase, phase: MobileHomePhase,
title: String, title: String,
detail: String, detail: String,
} }
impl MobileHomeProgress { impl MobileHomeProgress {
pub fn action(&self) -> Option<MobileHomeActionKind> {
self.action
}
pub fn phase(&self) -> MobileHomePhase { pub fn phase(&self) -> MobileHomePhase {
self.phase self.phase
} }
@@ -264,32 +319,34 @@ impl MobileHomeProgress {
} }
pub struct MobileHomeOperation { pub struct MobileHomeOperation {
authentication: Option<Arc<MobileAuthentication>>,
control: GitOperationControl, control: GitOperationControl,
phase: Arc<Mutex<MobileHomePhase>>, phase: Arc<Mutex<MobileHomePhase>>,
action: Mutex<Option<MobileHomeActionKind>>,
} }
impl Default for MobileHomeOperation { impl MobileHomeOperation {
fn default() -> Self { pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> Self {
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating)); let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
let observed = Arc::clone(&phase); let observed = Arc::clone(&phase);
Self { Self {
authentication,
control: GitOperationControl::new(move |phase| { control: GitOperationControl::new(move |phase| {
if let Ok(mut current) = observed.lock() { if let Ok(mut current) = observed.lock() {
*current = phase.into(); *current = phase.into();
} }
}), }),
phase, phase,
action: Mutex::new(None),
} }
} }
}
impl MobileHomeOperation {
pub fn cancel(&self) { pub fn cancel(&self) {
self.control.cancel(); self.control.cancel();
} }
pub fn progress(&self) -> MobileHomeProgress { pub fn progress(&self) -> MobileHomeProgress {
progress_copy( progress_copy(
self.action.lock().ok().and_then(|action| *action),
self.phase self.phase
.lock() .lock()
.map_or(MobileHomePhase::Validating, |phase| *phase), .map_or(MobileHomePhase::Validating, |phase| *phase),
@@ -313,6 +370,9 @@ impl MobileHomeOperation {
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> { pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
let storage = MobileHomeStorage::load()?; let storage = MobileHomeStorage::load()?;
let now = unix_seconds()?; let now = unix_seconds()?;
if storage.remote.is_none() {
return storage.page(MobileHomeFreshness::Current, Some(now), None);
}
if !is_stale(storage.config.mobile_home_refreshed_at(), now) { if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
return storage.page( return storage.page(
MobileHomeFreshness::Cached, MobileHomeFreshness::Cached,
@@ -320,22 +380,72 @@ impl MobileHomeOperation {
None, None,
); );
} }
storage.refresh(now, &self.control) self.run(MobileHomeActionKind::Fetch, || {
storage.fetch(now, &self.control)
})
} }
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> { pub fn commit(&self, message: &str) -> Result<MobileHomePage, MobileHomeError> {
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control) if message.trim().is_empty() || message.contains('\0') {
return Err(MobileHomeError::invalid_commit_message());
}
self.run(MobileHomeActionKind::Commit, || {
MobileHomeStorage::load()?.commit(message, &self.control)
})
}
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Fetch, || {
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
})
} }
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> { pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Pull, || {
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
})
}
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Push, || {
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
})
}
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
// Hold the pull reservation across both steps: sync can replace local entries.
self.run(MobileHomeActionKind::Pull, || {
MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control)
})
}
fn run(
&self,
action: MobileHomeActionKind,
operation: impl FnOnce() -> Result<MobileHomePage, MobileHomeError>,
) -> Result<MobileHomePage, MobileHomeError> {
if let Ok(mut current) = self.action.lock() {
*current = Some(action);
}
let reservation = self
.authentication
.as_ref()
.map(|authentication| {
authentication
.reserve_repository_operation(repository_operation(action))
.map_err(MobileHomeError::from_reservation)
})
.transpose()?;
let result = operation();
drop(reservation);
result
} }
} }
struct MobileHomeStorage { struct MobileHomeStorage {
config: Config, config: Config,
git: GitRepository, git: GitRepository,
remote: GitRemote, remote: Option<GitRemote>,
} }
impl MobileHomeStorage { impl MobileHomeStorage {
@@ -345,10 +455,7 @@ impl MobileHomeStorage {
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?; Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
let git = GitRepository::open(&repository, config.git_identity().clone()) let git = GitRepository::open(&repository, config.git_identity().clone())
.map_err(MobileHomeError::from_git)?; .map_err(MobileHomeError::from_git)?;
let remote = config let remote = config.git_remote(None).cloned();
.git_remote(None)
.cloned()
.ok_or_else(MobileHomeError::missing_remote)?;
Ok(Self { Ok(Self {
config, config,
git, git,
@@ -356,27 +463,51 @@ impl MobileHomeStorage {
}) })
} }
fn refresh( fn commit(
&self,
message: &str,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
control
.report(GitProgressPhase::Validating)
.map_err(MobileHomeError::from_git)?;
let commit = self
.git
.commit_all(message)
.map_err(MobileHomeError::from_git)?;
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
self.page(
MobileHomeFreshness::Cached,
self.config.mobile_home_refreshed_at(),
Some(commit_notice(&commit)),
)
.map_err(MobileHomeError::after_commit)
}
fn fetch(
&self, &self,
now: i64, now: i64,
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> { ) -> Result<MobileHomePage, MobileHomeError> {
let remote = self.remote()?;
let store = self.credentials()?; let store = self.credentials()?;
let refreshed = self.git.fetch_with_transport_controlled( let fetched = self.git.fetch_with_transport_controlled(
&self.remote, remote,
&store, &store,
&crate::git::EmbeddedFetchTransport, &crate::git::EmbeddedFetchTransport,
control, control,
); );
let locked = store.lock(); let locked = store.lock();
refreshed.map_err(MobileHomeError::from_git)?; let outcome = fetched.map_err(MobileHomeError::from_git)?;
if let Err(error) = locked { if let Err(error) = locked {
return Err(MobileHomeError::partial_secret(error)); return Err(MobileHomeError::partial_secret(error));
} }
control control
.report(GitProgressPhase::Refreshing) .report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?; .map_err(MobileHomeError::from_git)?;
self.current_page(now, None) self.current_page(now, Some(fetch_notice(&outcome)))
} }
fn pull( fn pull(
@@ -384,9 +515,10 @@ impl MobileHomeStorage {
now: i64, now: i64,
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> { ) -> Result<MobileHomePage, MobileHomeError> {
let remote = self.remote()?;
let store = self.credentials()?; let store = self.credentials()?;
let pulled = self.git.pull_with_transport_controlled( let pulled = self.git.pull_with_transport_controlled(
&self.remote, remote,
None, None,
&store, &store,
&crate::git::EmbeddedFetchTransport, &crate::git::EmbeddedFetchTransport,
@@ -404,6 +536,53 @@ impl MobileHomeStorage {
.map_err(MobileHomeError::after_pull) .map_err(MobileHomeError::after_pull)
} }
fn push(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let remote = self.remote()?;
let store = self.credentials()?;
let pushed = self.git.push_with_transport_controlled(
remote,
None,
&store,
&crate::git::ReqwestGitTransport,
control,
);
let locked = store.lock();
let outcome = pushed.map_err(MobileHomeError::from_git)?;
if let Err(error) = locked {
return Err(MobileHomeError::partial_secret(error));
}
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
self.current_page(now, Some(push_notice(&outcome)))
.map_err(MobileHomeError::after_push)
}
fn sync(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let remote = self.remote()?;
let store = self.credentials()?;
let synced = self.git.sync_controlled(remote, &store, control);
let locked = store.lock();
let (_, pushed) = synced.map_err(MobileHomeError::from_git)?;
locked.map_err(MobileHomeError::partial_secret)?;
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
let mut notice = push_notice(&pushed);
notice.title = "Sync Completed".to_owned();
notice.detail = format!("Pull completed successfully. {}", notice.detail);
self.current_page(now, Some(notice))
.map_err(MobileHomeError::after_push)
}
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> { fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
let store = NativeSecretStore::system( let store = NativeSecretStore::system(
SecretCachePolicy::Disabled, SecretCachePolicy::Disabled,
@@ -414,6 +593,12 @@ impl MobileHomeStorage {
Ok(store) Ok(store)
} }
fn remote(&self) -> Result<&GitRemote, MobileHomeError> {
self.remote
.as_ref()
.ok_or_else(MobileHomeError::missing_remote)
}
fn current_page( fn current_page(
&self, &self,
now: i64, now: i64,
@@ -443,9 +628,10 @@ impl MobileHomeStorage {
refreshed_at: Option<i64>, refreshed_at: Option<i64>,
notice: Option<MobileHomeNotice>, notice: Option<MobileHomeNotice>,
) -> Result<MobileHomePage, MobileHomeError> { ) -> Result<MobileHomePage, MobileHomeError> {
if let Some(remote) = self.remote.as_ref() {
let divergence = self let divergence = self
.git .git
.divergence(&self.remote, ACTIVITY_LIMIT) .divergence(remote, ACTIVITY_LIMIT)
.map_err(MobileHomeError::from_git)?; .map_err(MobileHomeError::from_git)?;
Ok(page_from_divergence( Ok(page_from_divergence(
&divergence, &divergence,
@@ -453,6 +639,18 @@ impl MobileHomeStorage {
refreshed_at, refreshed_at,
notice, notice,
)) ))
} else {
let snapshot = self
.git
.snapshot(None, ACTIVITY_LIMIT)
.map_err(MobileHomeError::from_git)?;
Ok(page_from_local_snapshot(
&snapshot,
freshness,
refreshed_at,
notice,
))
}
} }
} }
@@ -463,6 +661,7 @@ pub enum MobileHomeErrorKind {
Authentication, Authentication,
Conflict, Conflict,
DirtyLocalChanges, DirtyLocalChanges,
NoChanges,
Offline, Offline,
Interrupted, Interrupted,
SecureStorage, SecureStorage,
@@ -557,6 +756,17 @@ impl MobileHomeError {
title: "Local Changes Need Attention", title: "Local Changes Need Attention",
detail: "Commit or discard local changes before pulling remote activity.".to_owned(), detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
}, },
GitError::NoChanges => Self {
kind: MobileHomeErrorKind::NoChanges,
title: "Nothing to Commit",
detail: "The password-store working tree has no changes to commit.".to_owned(),
},
GitError::NonFastForward => Self {
kind: MobileHomeErrorKind::Conflict,
title: "Pull Before Pushing",
detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push."
.to_owned(),
},
GitError::NetworkUnavailable => Self { GitError::NetworkUnavailable => Self {
kind: MobileHomeErrorKind::Offline, kind: MobileHomeErrorKind::Offline,
title: "Server Is Offline", title: "Server Is Offline",
@@ -589,6 +799,43 @@ impl MobileHomeError {
} }
} }
fn invalid_commit_message() -> Self {
Self {
kind: MobileHomeErrorKind::NoChanges,
title: "Commit Message Required",
detail: "Enter a non-empty commit message and try again.".to_owned(),
}
}
fn from_reservation(error: MobileRepositoryOperationError) -> Self {
match error {
MobileRepositoryOperationError::DirtyEditor => Self {
kind: MobileHomeErrorKind::DirtyLocalChanges,
title: "Unsaved Entry Edits",
detail: "Save or discard the open entry editor before pulling or pushing."
.to_owned(),
},
MobileRepositoryOperationError::OpenEditor => Self {
kind: MobileHomeErrorKind::DirtyLocalChanges,
title: "Close the Entry Editor",
detail: "Close the open entry editor before pulling so its source cannot change underneath it."
.to_owned(),
},
MobileRepositoryOperationError::Busy => Self {
kind: MobileHomeErrorKind::Interrupted,
title: "Password-Store Action in Progress",
detail: "Wait for the current password-store action to finish, then retry."
.to_owned(),
},
MobileRepositoryOperationError::Unavailable => Self {
kind: MobileHomeErrorKind::Repository,
title: "Password-Store State Is Unavailable",
detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry."
.to_owned(),
},
}
}
fn from_secret(error: SecretStoreError) -> Self { fn from_secret(error: SecretStoreError) -> Self {
let detail = match error { let detail = match error {
SecretStoreError::Denied => "Access to the application token was denied.", SecretStoreError::Denied => "Access to the application token was denied.",
@@ -631,6 +878,24 @@ impl MobileHomeError {
.to_owned(), .to_owned(),
} }
} }
fn after_commit(_error: Self) -> Self {
Self {
kind: MobileHomeErrorKind::PartialProgress,
title: "Commit Completed, Status Unavailable",
detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry."
.to_owned(),
}
}
fn after_push(_error: Self) -> Self {
Self {
kind: MobileHomeErrorKind::PartialProgress,
title: "Push Completed, Status Unavailable",
detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry."
.to_owned(),
}
}
} }
impl fmt::Display for MobileHomeError { impl fmt::Display for MobileHomeError {
@@ -674,6 +939,11 @@ fn page_from_divergence(
title: format!("{}/{}", divergence.remote().name(), divergence.branch()), title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
detail: "Tracked HTTPS branch".to_owned(), detail: "Tracked HTTPS branch".to_owned(),
system_image: "point.3.connected.trianglepath.dotted".to_owned(), system_image: "point.3.connected.trianglepath.dotted".to_owned(),
actions: vec![
mobile_action(MobileHomeActionKind::Fetch),
mobile_action(MobileHomeActionKind::Pull),
mobile_action(MobileHomeActionKind::Push),
],
}, },
MobileHomeSummaryRow { MobileHomeSummaryRow {
id: "divergence".to_owned(), id: "divergence".to_owned(),
@@ -684,6 +954,13 @@ fn page_from_divergence(
} else { } else {
"arrow.triangle.2.circlepath".to_owned() "arrow.triangle.2.circlepath".to_owned()
}, },
actions: [
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
(ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)),
]
.into_iter()
.flatten()
.collect(),
}, },
MobileHomeSummaryRow { MobileHomeSummaryRow {
id: "working-tree".to_owned(), id: "working-tree".to_owned(),
@@ -702,9 +979,15 @@ fn page_from_divergence(
} else { } else {
"exclamationmark.triangle".to_owned() "exclamationmark.triangle".to_owned()
}, },
actions: if local_count > 0 {
vec![mobile_action(MobileHomeActionKind::Commit)]
} else {
Vec::new()
},
}, },
]; ];
MobileHomePage { MobileHomePage {
remote_configured: true,
freshness, freshness,
refreshed_at, refreshed_at,
summaries, summaries,
@@ -724,6 +1007,89 @@ fn page_from_divergence(
} }
} }
fn page_from_local_snapshot(
snapshot: &GitSnapshot,
freshness: MobileHomeFreshness,
refreshed_at: Option<i64>,
notice: Option<MobileHomeNotice>,
) -> MobileHomePage {
let local_paths = snapshot
.status()
.staged()
.iter()
.chain(snapshot.status().unstaged())
.map(|change| change.path().to_owned())
.collect::<BTreeSet<_>>();
let commit_count = snapshot.recent().len();
let latest = snapshot
.recent()
.first()
.and_then(|commit| {
commit
.message()
.lines()
.find(|line| !line.trim().is_empty())
})
.map(|line| display_text(line.trim(), 160));
MobileHomePage {
remote_configured: false,
freshness,
refreshed_at,
summaries: vec![
MobileHomeSummaryRow {
id: "local-branch".to_owned(),
title: snapshot.branch().to_owned(),
detail: "Local Git branch · no sync remote configured".to_owned(),
system_image: "externaldrive.fill".to_owned(),
actions: Vec::new(),
},
MobileHomeSummaryRow {
id: "local-history".to_owned(),
title: format!(
"{} Local Commit{}",
commit_count,
if commit_count == 1 { "" } else { "s" }
),
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
system_image: "clock.arrow.circlepath".to_owned(),
actions: Vec::new(),
},
working_tree_summary(local_paths.len()),
],
incoming: Vec::new(),
outgoing: Vec::new(),
incoming_total: 0,
outgoing_total: 0,
notice,
}
}
fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
MobileHomeSummaryRow {
id: "working-tree".to_owned(),
title: if local_count == 0 {
"Working Tree Clean".to_owned()
} else {
format!("{} Local", change_count(local_count))
},
detail: if local_count == 0 {
"No uncommitted password-store changes".to_owned()
} else {
"Commit these local password-store changes".to_owned()
},
system_image: if local_count == 0 {
"checkmark.shield".to_owned()
} else {
"exclamationmark.triangle".to_owned()
},
actions: if local_count > 0 {
vec![mobile_action(MobileHomeActionKind::Commit)]
} else {
Vec::new()
},
}
}
fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit { fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
let commit = activity.commit(); let commit = activity.commit();
let title = commit let title = commit
@@ -765,6 +1131,11 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
}, },
timestamp: commit.timestamp(), timestamp: commit.timestamp(),
changes, changes,
actions: vec![mobile_action(if incoming {
MobileHomeActionKind::Pull
} else {
MobileHomeActionKind::Push
})],
} }
} }
@@ -881,23 +1252,113 @@ fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
} }
} }
fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress { fn commit_notice(commit: &str) -> MobileHomeNotice {
let (title, detail) = match phase { MobileHomeNotice {
MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."), title: "Changes Committed".to_owned(),
MobileHomePhase::Authenticating => ( detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
system_image: "checkmark.circle".to_owned(),
}
}
fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
MobileHomeNotice {
title: if outcome.received_pack() {
"Remote Status Updated"
} else {
"Remote Already Current"
}
.to_owned(),
detail: if outcome.received_pack() {
format!(
"Received new objects from {} without changing the local password store.",
outcome.remote()
)
} else {
format!("{} has no new objects for this clone.", outcome.remote())
},
system_image: "arrow.clockwise.circle".to_owned(),
}
}
fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
let unchanged = outcome.old() == Some(outcome.new_id());
MobileHomeNotice {
title: if unchanged {
"Remote Already Current"
} else {
"Changes Pushed"
}
.to_owned(),
detail: if unchanged {
format!(
"{}/{} already points to the local commit.",
outcome.remote(),
outcome.branch()
)
} else {
format!(
"Updated {}/{} with local commits.",
outcome.remote(),
outcome.branch()
)
},
system_image: "arrow.up.circle".to_owned(),
}
}
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
let (title, system_image) = match kind {
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
};
MobileHomeAction {
kind,
title,
system_image,
}
}
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
match action {
MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit,
MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch,
MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull,
MobileHomeActionKind::Push => MobileRepositoryOperation::Push,
}
}
fn progress_copy(
action: Option<MobileHomeActionKind>,
phase: MobileHomePhase,
) -> MobileHomeProgress {
let (title, detail) = match (action, phase) {
(Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => (
"Committing Changes",
"Staging current password-store changes and creating the commit in Rust.",
),
(_, MobileHomePhase::Validating) => {
("Checking Home", "Validating local and remote Git state.")
}
(_, MobileHomePhase::Authenticating) => (
"Authenticating", "Authenticating",
"Reading the application token from protected storage.", "Reading the application token from protected storage.",
), ),
MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."), (_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."),
MobileHomePhase::Integrating => ( (_, MobileHomePhase::Integrating) => (
"Updating Password Store", "Updating Password Store",
"Integrating fetched commits into the local clone.", "Integrating fetched commits into the local clone.",
), ),
MobileHomePhase::Finishing => { (_, MobileHomePhase::Sending) => {
("Pushing Changes", "Sending local Git objects over HTTPS.")
}
(_, MobileHomePhase::Finishing) => {
("Updating Home", "Building current activity and divergence.") ("Updating Home", "Building current activity and divergence.")
} }
}; };
MobileHomeProgress { MobileHomeProgress {
action,
phase, phase,
title: title.to_owned(), title: title.to_owned(),
detail: detail.to_owned(), detail: detail.to_owned(),
@@ -935,8 +1396,8 @@ mod tests {
use crate::git::{GitChangeKind, GitError}; use crate::git::{GitChangeKind, GitError};
use super::{ use super::{
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale, MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
mobile_change, MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy,
}; };
#[test] #[test]
@@ -974,6 +1435,20 @@ mod tests {
assert_eq!(offline.kind(), MobileHomeErrorKind::Offline); assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree); let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges); assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward);
assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict);
let no_changes = super::MobileHomeError::from_git(GitError::NoChanges);
assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges);
assert!(!offline.to_string().contains("token-value")); assert!(!offline.to_string().contains("token-value"));
} }
#[test]
fn actions_and_progress_are_typed_before_swift_presentation() {
let push = mobile_action(MobileHomeActionKind::Push);
assert_eq!(push.title(), "Push");
assert_eq!(push.system_image(), "arrow.up.circle");
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
assert_eq!(progress.title(), "Pushing Changes");
}
} }
+147 -24
View File
@@ -145,7 +145,7 @@ pub struct MobileKeyTransferService {
impl MobileKeyTransferService { impl MobileKeyTransferService {
pub fn load() -> Result<Self, MobileKeyTransferError> { pub fn load() -> Result<Self, MobileKeyTransferError> {
let config = Config::load(None)?; let config = Config::load(None)?;
let keys = KeyStore::load(config.key_material())?; let keys = load_keys_or_empty(config.key_material())?;
Ok(Self { config, keys }) Ok(Self { config, keys })
} }
@@ -181,7 +181,7 @@ impl MobileKeyTransferService {
} }
pub struct MobileKeyTransferImport { pub struct MobileKeyTransferImport {
config: Config, destination: MobileKeyTransferDestination,
digest: Option<String>, digest: Option<String>,
total: Option<usize>, total: Option<usize>,
chunks: BTreeMap<usize, Vec<u8>>, chunks: BTreeMap<usize, Vec<u8>>,
@@ -189,10 +189,26 @@ pub struct MobileKeyTransferImport {
key: Option<MobileKeyTransferKey>, key: Option<MobileKeyTransferKey>,
} }
enum MobileKeyTransferDestination {
Configured(Box<Config>),
LocalSetup,
}
impl MobileKeyTransferImport { impl MobileKeyTransferImport {
fn new(config: Config) -> Self { fn new(config: Config) -> Self {
Self { Self {
config, destination: MobileKeyTransferDestination::Configured(Box::new(config)),
digest: None,
total: None,
chunks: BTreeMap::new(),
complete: None,
key: None,
}
}
pub fn local_setup() -> Self {
Self {
destination: MobileKeyTransferDestination::LocalSetup,
digest: None, digest: None,
total: None, total: None,
chunks: BTreeMap::new(), chunks: BTreeMap::new(),
@@ -280,6 +296,7 @@ impl MobileKeyTransferImport {
&mut self, &mut self,
passphrase: Option<SecretBytes>, passphrase: Option<SecretBytes>,
make_default: bool, make_default: bool,
overwrite: bool,
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> { ) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> {
let armor = self let armor = self
.complete .complete
@@ -298,20 +315,54 @@ impl MobileKeyTransferImport {
return Err(MobileKeyTransferError::PublicDefault); return Err(MobileKeyTransferError::PublicDefault);
} }
let existing = KeyStore::load(self.config.key_material())?; if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) {
if key.kind != MobileKeyTransferKind::Private {
return Err(MobileKeyTransferError::PublicDefault);
}
let operation = crate::mobile_onboarding::MobileOnboardingOperation::default();
let outcome = if make_default {
operation.setup_local_imported(
SecretBytes::new(armor.expose().to_vec()),
passphrase,
overwrite,
)
} else {
operation.stage_local_imported(
SecretBytes::new(armor.expose().to_vec()),
passphrase,
overwrite,
)
}
.map_err(MobileKeyTransferError::LocalSetup)?;
return Ok(MobileKeyTransferOutcome {
title: outcome.title().to_owned(),
detail: outcome.detail().to_owned(),
});
}
let MobileKeyTransferDestination::Configured(config) = &self.destination else {
unreachable!("local setup returned above")
};
let existing = load_keys_or_empty(config.key_material())?;
if let Some(found) = existing if let Some(found) = existing
.infos() .infos()
.find(|info| info.fingerprint().as_str() == key.fingerprint) .find(|info| info.fingerprint().as_str() == key.fingerprint)
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public) && (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
&& !overwrite
{ {
return Err(MobileKeyTransferError::DuplicateKey); return Err(MobileKeyTransferError::ExistingKey);
} }
persist_key_material(self.config.key_material(), key, armor.expose())?; persist_armored_key(
config.key_material(),
&key.fingerprint,
key.kind == MobileKeyTransferKind::Private,
armor.expose(),
overwrite,
)?;
if make_default { if make_default {
let mut settings = self.config.settings(); let mut settings = config.settings();
settings.set_default_key(key.fingerprint.clone()); settings.set_default_key(key.fingerprint.clone());
self.config.with_settings(settings)?.persist()?; config.with_settings(settings)?.persist()?;
} }
let kind = if key.kind == MobileKeyTransferKind::Private { let kind = if key.kind == MobileKeyTransferKind::Private {
"private" "private"
@@ -346,6 +397,13 @@ impl MobileKeyTransferImport {
} }
} }
fn load_keys_or_empty(path: &Path) -> Result<KeyStore, CryptoError> {
match KeyStore::load(path) {
Err(CryptoError::NoKeyMaterial) => Ok(KeyStore::new()),
result => result,
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum MobileKeyTransferError { pub enum MobileKeyTransferError {
Configuration, Configuration,
@@ -357,12 +415,13 @@ pub enum MobileKeyTransferError {
AlreadyComplete, AlreadyComplete,
ChecksumMismatch, ChecksumMismatch,
MismatchedKeys, MismatchedKeys,
DuplicateKey, ExistingKey,
PublicDefault, PublicDefault,
InvalidKeyMaterial, InvalidKeyMaterial,
IncorrectPassphrase, IncorrectPassphrase,
QrPayload, QrPayload,
Write, Write,
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
} }
impl fmt::Display for MobileKeyTransferError { impl fmt::Display for MobileKeyTransferError {
@@ -381,12 +440,15 @@ impl fmt::Display for MobileKeyTransferError {
"the reconstructed key transfer did not pass its integrity check" "the reconstructed key transfer did not pass its integrity check"
} }
Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys", Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
Self::DuplicateKey => "this GPG key is already installed", Self::ExistingKey => {
"this GPG key is already installed; confirm replacement to continue"
}
Self::PublicDefault => "a public-only key cannot become the default GPG key", Self::PublicDefault => "a public-only key cannot become the default GPG key",
Self::InvalidKeyMaterial => "the GPG key material is invalid", Self::InvalidKeyMaterial => "the GPG key material is invalid",
Self::IncorrectPassphrase => "the GPG key passphrase is incorrect", Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
Self::QrPayload => "the GPG key could not be represented as QR codes", Self::QrPayload => "the GPG key could not be represented as QR codes",
Self::Write => "the GPG key could not be stored safely", Self::Write => "the GPG key could not be stored safely",
Self::LocalSetup(error) => return error.fmt(formatter),
}) })
} }
} }
@@ -499,7 +561,7 @@ fn parse_positive(value: Option<&str>) -> Result<usize, MobileKeyTransferError>
.ok_or(MobileKeyTransferError::InvalidFrame) .ok_or(MobileKeyTransferError::InvalidFrame)
} }
fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> { pub(crate) fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
let text = let text =
std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?; std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") { let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
@@ -522,18 +584,19 @@ fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
Ok(()) Ok(())
} }
fn persist_key_material( pub(crate) fn persist_armored_key(
configured: &Path, configured: &Path,
key: &MobileKeyTransferKey, fingerprint: &str,
secret: bool,
armor: &[u8], armor: &[u8],
overwrite: bool,
) -> Result<(), MobileKeyTransferError> { ) -> Result<(), MobileKeyTransferError> {
if configured.is_dir() { if configured.is_dir() {
let suffix = if key.kind == MobileKeyTransferKind::Private { let suffix = if secret { "secret" } else { "public" };
"secret" let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase());
} else { if configured.join(&name).exists() && !overwrite {
"public" return Err(MobileKeyTransferError::ExistingKey);
}; }
let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
atomic_replace(configured, Path::new(&name), armor) atomic_replace(configured, Path::new(&name), armor)
} else { } else {
let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?; let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
@@ -592,7 +655,7 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileKeyTra
mod tests { mod tests {
use super::{ use super::{
MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind, MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind,
MobileKeyTransferService, encode_multipart, MobileKeyTransferService, encode_multipart, load_keys_or_empty,
}; };
use crate::{config::Config, crypto::KeyStore, repository::SecretBytes}; use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
use tempfile::tempdir; use tempfile::tempdir;
@@ -689,7 +752,7 @@ mod tests {
); );
assert!(progress.received() < progress.total()); assert!(progress.received() < progress.total());
assert_eq!( assert_eq!(
importer.import(None, false), importer.import(None, false, false),
Err(MobileKeyTransferError::IncompleteTransfer) Err(MobileKeyTransferError::IncompleteTransfer)
); );
assert_eq!( assert_eq!(
@@ -706,13 +769,14 @@ mod tests {
let mut importer = MobileKeyTransferImport::new(fixture.config.clone()); let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
assert_eq!( assert_eq!(
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true), importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
Err(MobileKeyTransferError::IncorrectPassphrase) Err(MobileKeyTransferError::IncorrectPassphrase)
); );
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1); assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
importer.import( importer.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true, true,
false,
)?; )?;
let keys = KeyStore::load(&fixture.key_path)?; let keys = KeyStore::load(&fixture.key_path)?;
assert!(keys.infos().any(|key| key.has_secret())); assert!(keys.infos().any(|key| key.has_secret()));
@@ -722,6 +786,65 @@ mod tests {
.as_str(), .as_str(),
ALICE_FINGERPRINT ALICE_FINGERPRINT
); );
let before = std::fs::read(fixture.key_path.join(format!(
"{}-secret.asc",
ALICE_FINGERPRINT.to_ascii_lowercase()
)))?;
let mut replacement = MobileKeyTransferImport::new(fixture.config);
replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
assert_eq!(
replacement.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true,
false,
),
Err(MobileKeyTransferError::ExistingKey)
);
assert_eq!(
std::fs::read(fixture.key_path.join(format!(
"{}-secret.asc",
ALICE_FINGERPRINT.to_ascii_lowercase()
)))?,
before
);
replacement.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true,
true,
)?;
Ok(())
}
#[test]
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_key("placeholder", b"")?;
std::fs::remove_file(fixture.key_path.join("placeholder"))?;
let service = MobileKeyTransferService {
keys: load_keys_or_empty(&fixture.key_path)?,
config: fixture.config.clone(),
};
assert!(service.keys().is_empty());
let mut importer = service.importer();
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
importer.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true,
false,
)?;
let keys = KeyStore::load(&fixture.key_path)?;
assert!(keys.resolve(ALICE_FINGERPRINT).is_ok());
assert_eq!(
Config::load(Some(fixture.config.source()))?
.default_key()
.as_str(),
ALICE_FINGERPRINT
);
let config = std::fs::read_to_string(fixture.config.source())?;
assert!(config.contains("vault = \"vault\""));
assert!(config.contains("key_material = \"keys\""));
Ok(()) Ok(())
} }
@@ -750,8 +873,8 @@ mod tests {
std::fs::write( std::fs::write(
&config_path, &config_path,
format!( format!(
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", "vault = \"vault\"\ndefault_key = {:?}\nkey_material = \"keys\"\n",
vault, ALICE_FINGERPRINT, keys ALICE_FINGERPRINT
), ),
)?; )?;
let config = Config::load(Some(&config_path))?; let config = Config::load(Some(&config_path))?;
+656 -15
View File
@@ -7,15 +7,20 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
use rand::{RngCore as _, rngs::OsRng};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
use url::Url; use url::Url;
use crate::{ use crate::{
command::InitRequest,
config::{Config, ConfigError, ConfigLoader, GitRemote}, config::{Config, ConfigError, ConfigLoader, GitRemote},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{ git::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl, GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository, GitProgressPhase, GitRepository, SshPassphraseProvider,
}, },
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
recipient::RecipientPolicyManager,
repository::{DirectoryPath, Repository, SecretBytes}, repository::{DirectoryPath, Repository, SecretBytes},
secret_store::{ secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError, NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
@@ -142,7 +147,7 @@ impl MobileOnboardingOperation {
let actual = git let actual = git
.remote_url(request.remote.name().as_str()) .remote_url(request.remote.name().as_str())
.map_err(MobileOnboardingError::from_git)?; .map_err(MobileOnboardingError::from_git)?;
if actual != request.remote.url().as_str() if actual != request.remote.url()
|| git.current_branch().ok().as_deref() != Some(branch) || git.current_branch().ok().as_deref() != Some(branch)
{ {
return Err(MobileOnboardingError::different_existing_clone()); return Err(MobileOnboardingError::different_existing_clone());
@@ -174,7 +179,7 @@ impl MobileOnboardingOperation {
.map_err(MobileOnboardingError::from_git)?; .map_err(MobileOnboardingError::from_git)?;
fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?; fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?; set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
let default_key = default_key(&repository)?; let default_key = default_key(&repository, &paths.keys)?;
store_application_token(request)?; store_application_token(request)?;
Config::create_mobile_clone( Config::create_mobile_clone(
paths.config, paths.config,
@@ -189,6 +194,223 @@ impl MobileOnboardingOperation {
detail: format!("The {} branch is available locally.", branch), detail: format!("The {} branch is available locally.", branch),
}) })
} }
pub fn connect_local_store(
&self,
request: &MobileOnboardingRequest,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
let config = Config::load(None).map_err(MobileOnboardingError::from_config)?;
self.connect_local_store_at(&config, request, || store_application_token(request))
}
fn connect_local_store_at<F>(
&self,
config: &Config,
request: &MobileOnboardingRequest,
store_token: F,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError>
where
F: FnOnce() -> Result<(), MobileOnboardingError>,
{
if !config.git_remotes().is_empty() {
return Err(MobileOnboardingError::already_configured());
}
let repository = Repository::open(config.vault())
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let mut git = GitRepository::open(&repository, config.git_identity().clone())
.map_err(MobileOnboardingError::from_git)?;
git.add_remote(request.remote.name().as_str(), request.remote.url())
.map_err(MobileOnboardingError::from_git)?;
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
let _ = git.remove_remote(request.remote.name().as_str());
return Err(MobileOnboardingError::from_config(error));
}
if let Err(error) = store_token() {
let _ = config.update_mobile_remote(None);
let _ = git.remove_remote(request.remote.name().as_str());
return Err(error);
}
Ok(MobileOnboardingOutcome {
title: "Git Sync Configured".to_owned(),
detail: "The local Git repository is connected to the HTTPS remote.".to_owned(),
})
}
pub fn setup_local_generated(
&self,
user_id: &str,
passphrase: SecretBytes,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
let generated =
KeyStore::generate(user_id, &passphrase).map_err(MobileOnboardingError::from_crypto)?;
let fingerprint = generated.info().fingerprint().as_str().to_owned();
self.setup_local_key(
generated.into_armor(),
Some(passphrase),
&fingerprint,
replace_existing_key,
)
}
pub fn setup_local_imported(
&self,
armor: SecretBytes,
passphrase: Option<SecretBytes>,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
let fingerprint = validate_imported_local_key(&armor, passphrase.as_ref())?;
self.setup_local_key(armor, passphrase, &fingerprint, replace_existing_key)
}
pub fn stage_local_imported(
&self,
armor: SecretBytes,
passphrase: Option<SecretBytes>,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
let fingerprint = validate_imported_local_key(&armor, passphrase.as_ref())?;
let paths = MobileOnboardingPaths::system()?;
self.stage_local_key_at(
paths,
armor,
passphrase.as_ref(),
&fingerprint,
replace_existing_key,
)
}
fn setup_local_key(
&self,
armor: SecretBytes,
passphrase: Option<SecretBytes>,
fingerprint: &str,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
let paths = MobileOnboardingPaths::system()?;
self.setup_local_key_at(paths, armor, passphrase, fingerprint, replace_existing_key)
}
fn setup_local_key_at(
&self,
paths: MobileOnboardingPaths,
armor: SecretBytes,
passphrase: Option<SecretBytes>,
fingerprint: &str,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
if Config::load(Some(&paths.config)).is_ok() {
return Err(MobileOnboardingError::already_configured());
}
paths.prepare_root()?;
if paths.config.exists() {
return Err(MobileOnboardingError::already_configured());
}
require_empty_directory(&paths.vault, false)?;
require_empty_directory(&paths.keys, replace_existing_key)?;
let staging = paths.staging_path();
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
let result = (|| {
let staged_vault = staging.join("vault");
let staged_keys = staging.join("keys");
fs::create_dir(&staged_vault)
.and_then(|()| fs::create_dir(&staged_keys))
.map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&staged_vault)
.and_then(|()| set_private_directory(&staged_keys))
.map_err(|_| MobileOnboardingError::configuration())?;
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
.map_err(|_| MobileOnboardingError::configuration())?;
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
let key = keys
.infos()
.find(|key| key.fingerprint().as_str() == fingerprint)
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
validate_local_key(&keys, &key, passphrase.as_ref())?;
let repository = Repository::open(&staged_vault)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
.map_err(MobileOnboardingError::from_git)?;
RecipientPolicyManager::new(&repository, &keys)
.apply_init(
&InitRequest {
path: None,
key_identities: vec![fingerprint.to_owned()],
},
None,
&mut NoSetupSecrets,
&mut git,
)
.map_err(|_| MobileOnboardingError::configuration())?;
paths.install_staged(&staging)?;
if let Err(error) = Config::create_mobile_local(
paths.config.clone(),
&paths.vault,
&paths.keys,
fingerprint,
) {
let _ = paths.restore_staged(&staging);
return Err(MobileOnboardingError::from_config(error));
}
Ok(())
})();
if staging.exists() {
let _ = fs::remove_dir_all(&staging);
}
result?;
Ok(MobileOnboardingOutcome {
title: "Local Password Store Ready".to_owned(),
detail: "A local Git repository and protected GPG key are ready on this iPhone."
.to_owned(),
})
}
fn stage_local_key_at(
&self,
paths: MobileOnboardingPaths,
armor: SecretBytes,
passphrase: Option<&SecretBytes>,
fingerprint: &str,
replace_existing_key: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
if Config::load(Some(&paths.config)).is_ok() || paths.config.exists() {
return Err(MobileOnboardingError::already_configured());
}
paths.prepare_root()?;
require_empty_directory(&paths.keys, replace_existing_key)?;
let staging = paths.staging_path();
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
let result = (|| {
let staged_keys = staging.join("keys");
fs::create_dir(&staged_keys).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&staged_keys)
.map_err(|_| MobileOnboardingError::configuration())?;
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
.map_err(|_| MobileOnboardingError::configuration())?;
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
let key = keys
.infos()
.find(|key| key.fingerprint().as_str() == fingerprint)
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
validate_local_key(&keys, &key, passphrase)?;
paths.install_staged_keys(&staging)
})();
if staging.exists() {
let _ = fs::remove_dir_all(&staging);
}
result?;
Ok(MobileOnboardingOutcome {
title: "GPG Key Ready".to_owned(),
detail:
"The private key is ready. Connect Existing Store to clone its password repository."
.to_owned(),
})
}
} }
pub struct MobileOnboardingRequest { pub struct MobileOnboardingRequest {
@@ -256,13 +478,27 @@ impl GitCredentialProvider for MobileOnboardingRequest {
server: &crate::config::ServerId, server: &crate::config::ServerId,
application: &crate::config::ApplicationId, application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> { ) -> Result<GitCredential, GitError> {
if server != self.remote.server_id() || application != self.remote.application_id() { let Some((expected_server, expected_application)) = self.remote.https_credentials() else {
return Err(GitError::CredentialsUnavailable);
};
if server != expected_server || application != expected_application {
return Err(GitError::CredentialsUnavailable); return Err(GitError::CredentialsUnavailable);
} }
GitCredential::new(self.account.clone(), self.token.expose().to_vec()) GitCredential::new(self.account.clone(), self.token.expose().to_vec())
} }
} }
impl SshPassphraseProvider for MobileOnboardingRequest {
fn ssh_key_passphrase(
&self,
fingerprint: &crate::config::SshFingerprint,
) -> Result<crate::repository::SecretBytes, GitError> {
Err(GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingDiscovery { pub struct MobileOnboardingDiscovery {
branches: Vec<String>, branches: Vec<String>,
@@ -302,6 +538,7 @@ pub enum MobileOnboardingErrorKind {
Authentication, Authentication,
Repository, Repository,
ExistingClone, ExistingClone,
ExistingKey,
Interrupted, Interrupted,
SecureStorage, SecureStorage,
Configuration, Configuration,
@@ -344,6 +581,25 @@ impl MobileOnboardingError {
} }
} }
fn existing_key() -> Self {
Self {
kind: MobileOnboardingErrorKind::ExistingKey,
title: "GPG Key Already Exists",
detail: "This operation would replace existing local key material. Confirm replacement to continue."
.to_owned(),
}
}
fn invalid_gpg_key() -> Self {
Self {
kind: MobileOnboardingErrorKind::InvalidInput,
title: "GPG Key Could Not Be Used",
detail:
"Import one private GPG key with an encryption subkey and the correct passphrase."
.to_owned(),
}
}
fn different_existing_clone() -> Self { fn different_existing_clone() -> Self {
Self { Self {
kind: MobileOnboardingErrorKind::ExistingClone, kind: MobileOnboardingErrorKind::ExistingClone,
@@ -432,6 +688,13 @@ impl MobileOnboardingError {
} }
} }
fn from_crypto(error: CryptoError) -> Self {
match error {
CryptoError::InvalidGeneratedKeyInput => Self::invalid("GPG key name or passphrase"),
_ => Self::invalid_gpg_key(),
}
}
fn from_secret(error: SecretStoreError) -> Self { fn from_secret(error: SecretStoreError) -> Self {
let detail = match error { let detail = match error {
SecretStoreError::Denied => "Access to secure token storage was denied.", SecretStoreError::Denied => "Access to secure token storage was denied.",
@@ -482,6 +745,158 @@ impl MobileOnboardingPaths {
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?; fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration()) set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
} }
fn staging_path(&self) -> PathBuf {
let mut nonce = [0_u8; 8];
OsRng.fill_bytes(&mut nonce);
self.root.join(format!(
".local-setup-{}",
nonce
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
))
}
fn install_staged(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
let previous_vault = staging.join("previous-vault");
let previous_keys = staging.join("previous-keys");
let had_vault = self.vault.exists();
let had_keys = self.keys.exists();
if had_vault {
fs::rename(&self.vault, &previous_vault)
.map_err(|_| MobileOnboardingError::configuration())?;
}
if had_keys && fs::rename(&self.keys, &previous_keys).is_err() {
if had_vault {
let _ = fs::rename(&previous_vault, &self.vault);
}
return Err(MobileOnboardingError::configuration());
}
if fs::rename(staging.join("vault"), &self.vault).is_err() {
if had_vault {
let _ = fs::rename(&previous_vault, &self.vault);
}
if had_keys {
let _ = fs::rename(&previous_keys, &self.keys);
}
return Err(MobileOnboardingError::configuration());
}
if fs::rename(staging.join("keys"), &self.keys).is_err() {
let _ = fs::rename(&self.vault, staging.join("vault"));
if had_vault {
let _ = fs::rename(&previous_vault, &self.vault);
}
if had_keys {
let _ = fs::rename(&previous_keys, &self.keys);
}
return Err(MobileOnboardingError::configuration());
}
Ok(())
}
fn install_staged_keys(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
let previous = staging.join("previous-keys");
let had_keys = self.keys.exists();
if had_keys {
fs::rename(&self.keys, &previous)
.map_err(|_| MobileOnboardingError::configuration())?;
}
if fs::rename(staging.join("keys"), &self.keys).is_err() {
if had_keys {
let _ = fs::rename(previous, &self.keys);
}
return Err(MobileOnboardingError::configuration());
}
Ok(())
}
fn restore_staged(&self, staging: &Path) -> std::io::Result<()> {
let failed_vault = staging.join("failed-vault");
let failed_keys = staging.join("failed-keys");
if self.vault.exists() {
fs::rename(&self.vault, &failed_vault)?;
}
if self.keys.exists() {
fs::rename(&self.keys, &failed_keys)?;
}
let previous_vault = staging.join("previous-vault");
let previous_keys = staging.join("previous-keys");
if previous_vault.exists() {
fs::rename(previous_vault, &self.vault)?;
}
if previous_keys.exists() {
fs::rename(previous_keys, &self.keys)?;
}
Ok(())
}
}
fn require_empty_directory(
path: &Path,
replace_existing_key: bool,
) -> Result<(), MobileOnboardingError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err(MobileOnboardingError::configuration()),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(MobileOnboardingError::configuration());
}
let nonempty = path
.read_dir()
.map_err(|_| MobileOnboardingError::configuration())?
.next()
.is_some();
if !nonempty {
return Ok(());
}
if path.file_name().is_some_and(|name| name == "vault") {
return Err(MobileOnboardingError::existing_clone());
}
if !replace_existing_key {
return Err(MobileOnboardingError::existing_key());
}
Ok(())
}
fn validate_local_key(
keys: &KeyStore,
key: &KeyInfo,
passphrase: Option<&SecretBytes>,
) -> Result<(), MobileOnboardingError> {
if !key.has_secret() || !key.can_encrypt() {
return Err(MobileOnboardingError::invalid_gpg_key());
}
keys.validate_secret_passphrase(key.fingerprint().as_str(), passphrase)
.map_err(|_| MobileOnboardingError::invalid_gpg_key())
}
fn validate_imported_local_key(
armor: &SecretBytes,
passphrase: Option<&SecretBytes>,
) -> Result<String, MobileOnboardingError> {
validate_transfer_armor(armor.expose())
.map_err(|_| MobileOnboardingError::invalid_gpg_key())?;
let mut keys = KeyStore::new();
let infos = keys
.import(armor.expose())
.map_err(MobileOnboardingError::from_crypto)?;
let key = infos
.first()
.filter(|_| infos.len() == 1)
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
validate_local_key(&keys, key, passphrase)?;
Ok(key.fingerprint().as_str().to_owned())
}
struct NoSetupSecrets;
impl SecretProvider for NoSetupSecrets {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Err(SecretProviderError::Unavailable)
}
} }
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> { fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
@@ -544,7 +959,10 @@ fn hex_prefix(bytes: &[u8], length: usize) -> String {
.collect() .collect()
} }
fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> { fn default_key(
repository: &Repository,
key_material: &Path,
) -> Result<String, MobileOnboardingError> {
let root = let root =
DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?; DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let contents = repository let contents = repository
@@ -553,17 +971,37 @@ fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError>
.ok_or_else(MobileOnboardingError::inaccessible_repository)?; .ok_or_else(MobileOnboardingError::inaccessible_repository)?;
let text = std::str::from_utf8(&contents) let text = std::str::from_utf8(&contents)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?; .map_err(|_| MobileOnboardingError::inaccessible_repository())?;
text.lines() let identities = text
.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim()) .map(|line| line.split('#').next().unwrap_or_default().trim())
.find(|identity| !identity.is_empty()) .filter(|identity| {
.filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control)) !identity.is_empty() && identity.len() <= 512 && !identity.chars().any(char::is_control)
.map(str::to_owned) })
.collect::<Vec<_>>();
if let Ok(keys) = KeyStore::load(key_material) {
for identity in &identities {
if let Ok(handle) = keys.resolve(identity)
&& keys
.infos()
.any(|key| key.fingerprint() == handle.fingerprint() && key.has_secret())
{
return Ok(handle.fingerprint().to_string());
}
}
}
identities
.first()
.map(|identity| (*identity).to_owned())
.ok_or_else(MobileOnboardingError::inaccessible_repository) .ok_or_else(MobileOnboardingError::inaccessible_repository)
} }
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> { fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
let (server_id, application_id) = request
.remote
.https_credentials()
.ok_or_else(MobileOnboardingError::token_configuration)?;
let credential = request let credential = request
.credential(request.remote.server_id(), request.remote.application_id()) .credential(server_id, application_id)
.map_err(MobileOnboardingError::from_git)?; .map_err(MobileOnboardingError::from_git)?;
store_git_credential(&request.remote, &credential) store_git_credential(&request.remote, &credential)
} }
@@ -572,6 +1010,9 @@ fn store_git_credential(
remote: &GitRemote, remote: &GitRemote,
credential: &GitCredential, credential: &GitCredential,
) -> Result<(), MobileOnboardingError> { ) -> Result<(), MobileOnboardingError> {
let (server_id, application_id) = remote
.https_credentials()
.ok_or_else(MobileOnboardingError::token_configuration)?;
let store = NativeSecretStore::system( let store = NativeSecretStore::system(
SecretCachePolicy::Disabled, SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(), SecretProtectionPolicy::device_unlocked(),
@@ -580,8 +1021,8 @@ fn store_git_credential(
store.unlock().map_err(MobileOnboardingError::from_secret)?; store.unlock().map_err(MobileOnboardingError::from_secret)?;
store store
.store_https_git_credential( .store_https_git_credential(
remote.server_id(), server_id,
remote.application_id(), application_id,
credential.username(), credential.username(),
SecretBytes::new(credential.password().to_vec()), SecretBytes::new(credential.password().to_vec()),
) )
@@ -622,9 +1063,18 @@ fn set_private_directory(path: &Path) -> std::io::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs;
use crate::{
config::Config,
crypto::KeyStore,
git::{GitIdentity, GitRepository},
repository::SecretBytes,
};
use super::{ use super::{
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase, MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPaths,
MobileOnboardingRequest, MobileOnboardingPhase, MobileOnboardingRequest, default_key,
}; };
#[test] #[test]
@@ -637,7 +1087,7 @@ mod tests {
) )
.expect("valid request"); .expect("valid request");
assert_eq!( assert_eq!(
request.remote().url().as_str(), request.remote().url(),
"https://example.test/gitea/team/passwords.git" "https://example.test/gitea/team/passwords.git"
); );
assert!(!format!("{request:?}").contains("DO-NOT-RENDER")); assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
@@ -662,4 +1112,195 @@ mod tests {
operation.cancel(); operation.cancel();
assert!(operation.control.is_cancelled()); assert!(operation.control.is_cancelled());
} }
#[test]
fn local_setup_generates_a_versioned_pass_store_without_a_remote() {
let temporary = tempfile::tempdir().expect("temporary directory");
let root = temporary.path().join("ironstorage");
let paths = MobileOnboardingPaths {
config: root.join("config.toml"),
vault: root.join("vault"),
keys: root.join("keys"),
root,
};
let passphrase = b"correct horse battery staple";
let generated = KeyStore::generate(
"Local Reviewer <reviewer@demo.example.com>",
&SecretBytes::new(passphrase.to_vec()),
)
.expect("generate key");
let fingerprint = generated.info().fingerprint().as_str().to_owned();
MobileOnboardingOperation::default()
.setup_local_key_at(
paths,
generated.into_armor(),
Some(SecretBytes::new(passphrase.to_vec())),
&fingerprint,
false,
)
.expect("local setup");
let config = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
.expect("local config");
assert!(config.git_remotes().is_empty());
assert_eq!(config.default_key().as_str(), fingerprint);
let keys = KeyStore::load(config.key_material()).expect("stored key");
keys.validate_secret_passphrase(&fingerprint, Some(&SecretBytes::new(passphrase.to_vec())))
.expect("protected key");
assert_eq!(
fs::read_to_string(config.vault().join(".gpg-id")).expect("recipient policy"),
format!("{fingerprint}\n")
);
let repository = crate::repository::Repository::open(config.vault()).expect("vault");
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
assert!(git.log(None).expect("history").len() >= 2);
let request = MobileOnboardingRequest::new(
"https://git.demo.example.com".to_owned(),
"reviewer".to_owned(),
"reviewer/passwords".to_owned(),
b"test-token".to_vec(),
)
.expect("remote request");
MobileOnboardingOperation::default()
.connect_local_store_at(&config, &request, || Ok(()))
.expect("connect remote");
let configured = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
.expect("updated config");
assert_eq!(configured.git_remotes(), &[request.remote().clone()]);
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
assert_eq!(
git.remote_url("origin").expect("remote URL"),
"https://git.demo.example.com/reviewer/passwords.git"
);
}
#[test]
fn imported_key_can_be_staged_before_cloning_without_creating_a_repository() {
let temporary = tempfile::tempdir().expect("temporary directory");
let root = temporary.path().join("ironstorage");
let paths = MobileOnboardingPaths {
config: root.join("config.toml"),
vault: root.join("vault"),
keys: root.join("keys"),
root,
};
let passphrase = SecretBytes::new(b"existing key passphrase".to_vec());
let generated =
KeyStore::generate("Existing Store Key <owner@vault.example.com>", &passphrase)
.expect("generate key");
let fingerprint = generated.info().fingerprint().as_str().to_owned();
MobileOnboardingOperation::default()
.stage_local_key_at(
paths,
generated.into_armor(),
Some(&passphrase),
&fingerprint,
false,
)
.expect("stage imported key");
assert!(!temporary.path().join("ironstorage/config.toml").exists());
assert!(!temporary.path().join("ironstorage/vault").exists());
let keys =
KeyStore::load(temporary.path().join("ironstorage/keys")).expect("staged key material");
assert!(keys.resolve(&fingerprint).is_ok());
}
#[test]
fn existing_store_selects_the_staged_private_recipient_as_default() {
let temporary = tempfile::tempdir().expect("temporary directory");
let vault = temporary.path().join("vault");
let keys_path = temporary.path().join("keys");
fs::create_dir(&vault).expect("vault");
fs::create_dir(&keys_path).expect("keys");
let passphrase = SecretBytes::new(b"existing key passphrase".to_vec());
let generated =
KeyStore::generate("Existing Store Key <owner@vault.example.com>", &passphrase)
.expect("generate key");
let fingerprint = generated.info().fingerprint().as_str().to_owned();
fs::write(
vault.join(".gpg-id"),
format!("UNAVAILABLE-RECIPIENT\n{fingerprint}\n"),
)
.expect("recipient policy");
fs::write(
keys_path.join("imported-secret.asc"),
generated.into_armor().expose(),
)
.expect("key material");
let repository = crate::repository::Repository::open(&vault).expect("repository");
assert_eq!(
default_key(&repository, &keys_path).expect("default key"),
fingerprint
);
}
#[test]
fn local_setup_requires_confirmation_before_replacing_key_material() {
let temporary = tempfile::tempdir().expect("temporary directory");
let root = temporary.path().join("ironstorage");
let paths = MobileOnboardingPaths {
config: root.join("config.toml"),
vault: root.join("vault"),
keys: root.join("keys"),
root,
};
paths.prepare_root().expect("root");
fs::create_dir(&paths.vault).expect("vault");
fs::create_dir(&paths.keys).expect("keys");
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
let generated = KeyStore::generate(
"Replacement Key <replacement@demo.example.com>",
&SecretBytes::new(b"replacement passphrase".to_vec()),
)
.expect("generate key");
let fingerprint = generated.info().fingerprint().as_str().to_owned();
let error = MobileOnboardingOperation::default()
.setup_local_key_at(
paths,
generated.into_armor(),
Some(SecretBytes::new(b"replacement passphrase".to_vec())),
&fingerprint,
false,
)
.expect_err("replacement must require confirmation");
assert_eq!(error.kind(), MobileOnboardingErrorKind::ExistingKey);
assert_eq!(
fs::read(temporary.path().join("ironstorage/keys/existing.asc"))
.expect("existing key preserved"),
b"existing key material"
);
}
#[test]
fn failed_staged_install_restores_existing_key_material() {
let temporary = tempfile::tempdir().expect("temporary directory");
let root = temporary.path().join("ironstorage");
let paths = MobileOnboardingPaths {
config: root.join("config.toml"),
vault: root.join("vault"),
keys: root.join("keys"),
root: root.clone(),
};
paths.prepare_root().expect("root");
fs::create_dir(&paths.vault).expect("vault");
fs::create_dir(&paths.keys).expect("keys");
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
let staging = root.join("staging");
fs::create_dir(&staging).expect("staging");
fs::create_dir(staging.join("vault")).expect("staged vault");
paths
.install_staged(&staging)
.expect_err("missing staged keys must fail");
assert!(paths.vault.is_dir());
assert_eq!(
fs::read(paths.keys.join("existing.asc")).expect("existing key preserved"),
b"existing key material"
);
}
} }
+20 -3
View File
@@ -179,13 +179,13 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
TreeNodeKind::Directory => ( TreeNodeKind::Directory => (
MobilePasswordRowKind::Directory, MobilePasswordRowKind::Directory,
"directory", "directory",
"Folder", entry_count_detail(node),
"folder", "folder",
), ),
TreeNodeKind::Entry => ( TreeNodeKind::Entry => (
MobilePasswordRowKind::Entry, MobilePasswordRowKind::Entry,
"entry", "entry",
"Locked password", "Locked password".to_owned(),
"key.fill", "key.fill",
), ),
}; };
@@ -193,12 +193,24 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
id: format!("{prefix}:{path}"), id: format!("{prefix}:{path}"),
path, path,
title: node.name().to_owned(), title: node.name().to_owned(),
detail: detail.to_owned(), detail,
system_image: system_image.to_owned(), system_image: system_image.to_owned(),
kind, kind,
} }
} }
fn entry_count_detail(node: &TreeNode) -> String {
let count = entry_count(node);
format!("{count} {}", if count == 1 { "entry" } else { "entries" })
}
fn entry_count(node: &TreeNode) -> usize {
match node.kind() {
TreeNodeKind::Directory => node.children().iter().map(entry_count).sum(),
TreeNodeKind::Entry => 1,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobilePasswordErrorKind { pub enum MobilePasswordErrorKind {
MissingConfiguration, MissingConfiguration,
@@ -311,12 +323,16 @@ mod tests {
.join("Personal/a very long password entry name.gpg"), .join("Personal/a very long password entry name.gpg"),
b"ciphertext", b"ciphertext",
)?; )?;
fs::create_dir_all(temporary.path().join("Solo"))?;
fs::write(temporary.path().join("Solo/only.gpg"), b"ciphertext")?;
let repository = Repository::open(temporary.path())?; let repository = Repository::open(temporary.path())?;
let root = MobilePasswordPage::from_repository(&repository, None)?; let root = MobilePasswordPage::from_repository(&repository, None)?;
assert_eq!(root.title(), "Passwords"); assert_eq!(root.title(), "Passwords");
assert_eq!(root.rows()[0].id(), "directory:Personal"); assert_eq!(root.rows()[0].id(), "directory:Personal");
assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory); assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory);
assert_eq!(root.rows()[0].detail(), "2 entries");
assert_eq!(root.rows()[1].detail(), "1 entry");
let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?; let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?;
assert_eq!(finance.id(), "directory:Personal/Finance"); assert_eq!(finance.id(), "directory:Personal/Finance");
@@ -325,6 +341,7 @@ mod tests {
finance.rows()[0].id(), finance.rows()[0].id(),
"directory:Personal/Finance/Very Deep" "directory:Personal/Finance/Very Deep"
); );
assert_eq!(finance.rows()[0].detail(), "0 entries");
assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank"); assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank");
assert_eq!(finance.rows()[1].detail(), "Locked password"); assert_eq!(finance.rows()[1].detail(), "Locked password");
+67 -22
View File
@@ -21,6 +21,7 @@ use sha2::{Digest as _, Sha256};
use crate::{ use crate::{
crypto::{KeyStore, SecretProvider}, crypto::{KeyStore, SecretProvider},
document::EntryDocument, document::EntryDocument,
mobile_watch::{MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotEntry},
otp::{OtpError, OtpKind, OtpService}, otp::{OtpError, OtpKind, OtpService},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
}; };
@@ -124,28 +125,6 @@ impl MobileTotpOperation {
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileWatchSnapshotState {
Unavailable,
Pending,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileWatchSnapshotStatus {
state: MobileWatchSnapshotState,
detail: String,
}
impl MobileWatchSnapshotStatus {
pub fn state(&self) -> MobileWatchSnapshotState {
self.state
}
pub fn detail(&self) -> &str {
&self.detail
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileTotpRow { pub struct MobileTotpRow {
path: String, path: String,
@@ -206,6 +185,10 @@ impl MobileTotpPage {
pub fn watch(&self) -> &MobileWatchSnapshotStatus { pub fn watch(&self) -> &MobileWatchSnapshotStatus {
&self.watch &self.watch
} }
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
self.watch = watch;
}
} }
pub struct MobileTotpDetail { pub struct MobileTotpDetail {
@@ -251,6 +234,21 @@ impl MobileTotpDetail {
pub fn watch(&self) -> &MobileWatchSnapshotStatus { pub fn watch(&self) -> &MobileWatchSnapshotStatus {
&self.watch &self.watch
} }
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
self.watch = watch;
}
}
pub struct WatchSnapshotSelection {
entries: Vec<WatchSnapshotEntry>,
retained: BTreeSet<EntryPath>,
}
impl WatchSnapshotSelection {
pub fn into_parts(self) -> (Vec<WatchSnapshotEntry>, BTreeSet<EntryPath>) {
(self.entries, self.retained)
}
} }
impl fmt::Debug for MobileTotpDetail { impl fmt::Debug for MobileTotpDetail {
@@ -423,14 +421,25 @@ impl<'a> MobileTotpService<'a> {
&self, &self,
shared: &BTreeSet<EntryPath>, shared: &BTreeSet<EntryPath>,
cache_path: &Path, cache_path: &Path,
) -> Option<MobileTotpPage> {
self.search_cached_page(shared, cache_path, "")
}
pub fn search_cached_page(
&self,
shared: &BTreeSet<EntryPath>,
cache_path: &Path,
query: &str,
) -> Option<MobileTotpPage> { ) -> Option<MobileTotpPage> {
let store = store_identity(self.repository.root_path(), cache_path); let store = store_identity(self.repository.root_path(), cache_path);
let (catalog, _) = load_cache(cache_path, &store); let (catalog, _) = load_cache(cache_path, &store);
let query = query.trim().to_lowercase();
let mut rows: Vec<_> = catalog? let mut rows: Vec<_> = catalog?
.entries .entries
.into_iter() .into_iter()
.filter(|(_, record)| record.is_totp) .filter(|(_, record)| record.is_totp)
.filter_map(|(path, _)| EntryPath::parse(&path).ok()) .filter_map(|(path, _)| EntryPath::parse(&path).ok())
.filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query))
.map(|path| cached_row(&path, shared.contains(&path))) .map(|path| cached_row(&path, shared.contains(&path)))
.collect(); .collect();
sort_rows(&mut rows); sort_rows(&mut rows);
@@ -489,6 +498,40 @@ impl<'a> MobileTotpService<'a> {
} }
detail_for_uri(&path, uri, unix_seconds, shared) detail_for_uri(&path, uri, unix_seconds, shared)
} }
pub fn watch_snapshot_selection(
&self,
selected: &BTreeSet<EntryPath>,
provider: &mut impl SecretProvider,
) -> Result<WatchSnapshotSelection, MobileTotpError> {
let mut entries = Vec::with_capacity(selected.len());
let mut retained = BTreeSet::new();
for path in selected {
let uri = match OtpService::new(self.repository, self.keys)
.uri(&path.to_string(), provider)
{
Ok(uri) if uri.kind() == OtpKind::Totp => uri,
Ok(_) => continue,
Err(OtpError::Repository(RepositoryError::NotFound { .. }))
| Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => continue,
Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error).into()),
Err(OtpError::Repository(error)) => return Err(error.into()),
Err(_) => continue,
};
let period = uri.period().ok_or(OtpError::NotTotp)?;
retained.insert(path.clone());
entries.push(WatchSnapshotEntry::new(
path.to_string(),
uri.issuer().map(str::to_owned),
uri.account().to_owned(),
uri.algorithm(),
uri.digits(),
period,
uri.watch_secret(),
));
}
Ok(WatchSnapshotSelection { entries, retained })
}
} }
fn detail_for_uri( fn detail_for_uri(
@@ -744,11 +787,13 @@ fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus {
if selected == 0 { if selected == 0 {
MobileWatchSnapshotStatus { MobileWatchSnapshotStatus {
state: MobileWatchSnapshotState::Unavailable, state: MobileWatchSnapshotState::Unavailable,
revision: None,
detail: "No TOTP codes are selected for Apple Watch.".to_owned(), detail: "No TOTP codes are selected for Apple Watch.".to_owned(),
} }
} else { } else {
MobileWatchSnapshotStatus { MobileWatchSnapshotStatus {
state: MobileWatchSnapshotState::Pending, state: MobileWatchSnapshotState::Pending,
revision: None,
detail: format!( detail: format!(
"{selected} selected TOTP {} pending Apple Watch synchronization.", "{selected} selected TOTP {} pending Apple Watch synchronization.",
if selected == 1 { if selected == 1 {
File diff suppressed because it is too large. Load diff
+254 -69
View File
@@ -7,7 +7,8 @@ use crate::{
crypto::{KeyStore, SecretProvider}, crypto::{KeyStore, SecretProvider},
recipient::{RecipientPolicyManager, SigningPolicy}, recipient::{RecipientPolicyManager, SigningPolicy},
repository::{ repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject, DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, RepositorySnapshot,
ResolvedObject,
}, },
write::OverwriteDecision, write::OverwriteDecision,
}; };
@@ -164,19 +165,21 @@ impl<'a> TreeMutator<'a> {
if !request.recursive { if !request.recursive {
return Err(MutationError::RecursiveRequired); return Err(MutationError::RecursiveRequired);
} }
self.reject_unmanaged(record.path())?; self.reject_unmanaged(&snapshot, record.path())?;
( (
self.source_entries(record.path())?, self.source_entries(&snapshot, record.path())?,
self.source_policies(record.path())?, self.source_policies(&snapshot, record.path())?,
self.source_directories(record.path())?, self.source_directories(&snapshot, record.path()),
record.path().clone(), record.path().clone(),
format!("{}/", record.path()), format!("{}/", record.path()),
) )
} }
}; };
if let Err(operation) = self.remove_sources(&entries, &policies, &directories) { if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
self.restore_sources(&entries, &policies, &directories)?; return Err(with_rollback(
return Err(operation); operation,
self.restore_sources(&entries, &policies, &directories),
));
} }
let change = TreeCommit { let change = TreeCommit {
action: MutationAction::Remove, action: MutationAction::Remove,
@@ -195,8 +198,10 @@ impl<'a> TreeMutator<'a> {
message: format!("Remove {display} from store."), message: format!("Remove {display} from store."),
}; };
if let Err(error) = committer.commit(&change) { if let Err(error) = committer.commit(&change) {
self.restore_sources(&entries, &policies, &directories)?; return Err(with_rollback(
return Err(MutationError::Commit(error)); MutationError::Commit(error),
self.restore_sources(&entries, &policies, &directories),
));
} }
self.repository.cleanup_empty_directories(&cleanup)?; self.repository.cleanup_empty_directories(&cleanup)?;
Ok(MutationOutcome { Ok(MutationOutcome {
@@ -331,11 +336,11 @@ impl<'a> TreeMutator<'a> {
path: destination.as_path().to_owned(), path: destination.as_path().to_owned(),
}); });
} }
self.reject_unmanaged(record.path())?; self.reject_unmanaged(&snapshot, record.path())?;
( (
self.source_entries(record.path())?, self.source_entries(&snapshot, record.path())?,
self.source_policies(record.path())?, self.source_policies(&snapshot, record.path())?,
self.source_directories(record.path())?, self.source_directories(&snapshot, record.path()),
record.path().clone(), record.path().clone(),
destination, destination,
None, None,
@@ -386,10 +391,10 @@ impl<'a> TreeMutator<'a> {
.starts_with(source_root.as_path()) .starts_with(source_root.as_path())
}) { }) {
RecipientPolicyManager::new(self.repository, self.keys) RecipientPolicyManager::new(self.repository, self.keys)
.resolve_for_entry(&source_entry.source, signing)? .resolve_for_entry_in_snapshot(&snapshot, &source_entry.source, signing)?
} else { } else {
RecipientPolicyManager::new(self.repository, self.keys) RecipientPolicyManager::new(self.repository, self.keys)
.resolve_for_entry(&destination_path, signing)? .resolve_for_entry_in_snapshot(&snapshot, &destination_path, signing)?
}; };
let replacement = if self let replacement = if self
.keys .keys
@@ -484,12 +489,18 @@ impl<'a> TreeMutator<'a> {
Ok(()) Ok(())
})(); })();
if let Err(operation) = applied { if let Err(operation) = applied {
if moving { return Err(with_rollback(
self.restore_sources(&source_entries, &source_policies, &source_directories)?; operation,
} self.rollback_transfer(
self.restore_destinations(&entries, &policies)?; moving,
self.remove_created_directories(&created_directories)?; &source_entries,
return Err(operation); &source_policies,
&source_directories,
&entries,
&policies,
&created_directories,
),
));
} }
let action = if moving { let action = if moving {
MutationAction::Move MutationAction::Move
@@ -528,12 +539,18 @@ impl<'a> TreeMutator<'a> {
message: format!("{verb} {source} to {destination}."), message: format!("{verb} {source} to {destination}."),
}; };
if let Err(error) = committer.commit(&change) { if let Err(error) = committer.commit(&change) {
if moving { return Err(with_rollback(
self.restore_sources(&source_entries, &source_policies, &source_directories)?; MutationError::Commit(error),
} self.rollback_transfer(
self.restore_destinations(&entries, &policies)?; moving,
self.remove_created_directories(&created_directories)?; &source_entries,
return Err(MutationError::Commit(error)); &source_policies,
&source_directories,
&entries,
&policies,
&created_directories,
),
));
} }
if moving { if moving {
self.repository.cleanup_empty_directories(&source_root)?; self.repository.cleanup_empty_directories(&source_root)?;
@@ -549,9 +566,12 @@ impl<'a> TreeMutator<'a> {
}) })
} }
fn source_entries(&self, root: &DirectoryPath) -> Result<Vec<EntryState>, MutationError> { fn source_entries(
self.repository &self,
.snapshot()? snapshot: &RepositorySnapshot,
root: &DirectoryPath,
) -> Result<Vec<EntryState>, MutationError> {
snapshot
.entries() .entries()
.filter(|entry| entry.path().as_path().starts_with(root.as_path())) .filter(|entry| entry.path().as_path().starts_with(root.as_path()))
.map(|entry| { .map(|entry| {
@@ -563,9 +583,12 @@ impl<'a> TreeMutator<'a> {
.collect() .collect()
} }
fn source_policies(&self, root: &DirectoryPath) -> Result<Vec<PolicyState>, MutationError> { fn source_policies(
self.repository &self,
.snapshot()? snapshot: &RepositorySnapshot,
root: &DirectoryPath,
) -> Result<Vec<PolicyState>, MutationError> {
snapshot
.recipient_policies() .recipient_policies()
.filter(|policy| policy.directory().as_path().starts_with(root.as_path())) .filter(|policy| policy.directory().as_path().starts_with(root.as_path()))
.map(|policy| { .map(|policy| {
@@ -583,19 +606,21 @@ impl<'a> TreeMutator<'a> {
fn source_directories( fn source_directories(
&self, &self,
snapshot: &RepositorySnapshot,
root: &DirectoryPath, root: &DirectoryPath,
) -> Result<Vec<DirectoryPath>, MutationError> { ) -> Vec<DirectoryPath> {
Ok(self snapshot
.repository
.snapshot()?
.directories() .directories()
.filter(|directory| directory.path().as_path().starts_with(root.as_path())) .filter(|directory| directory.path().as_path().starts_with(root.as_path()))
.map(|directory| directory.path().clone()) .map(|directory| directory.path().clone())
.collect()) .collect()
} }
fn reject_unmanaged(&self, root: &DirectoryPath) -> Result<(), MutationError> { fn reject_unmanaged(
let snapshot = self.repository.snapshot()?; &self,
snapshot: &RepositorySnapshot,
root: &DirectoryPath,
) -> Result<(), MutationError> {
if let Some(file) = snapshot if let Some(file) = snapshot
.auxiliary_files() .auxiliary_files()
.find(|file| file.path().starts_with(root.as_path())) .find(|file| file.path().starts_with(root.as_path()))
@@ -648,39 +673,65 @@ impl<'a> TreeMutator<'a> {
policies: &[PolicyState], policies: &[PolicyState],
directories: &[DirectoryPath], directories: &[DirectoryPath],
) -> Result<(), MutationError> { ) -> Result<(), MutationError> {
let mut failure = None;
for directory in directories { for directory in directories {
self.repository.ensure_directory(directory)?; remember_rollback_failure(
&mut failure,
self.repository
.ensure_directory(directory)
.map(|_| ())
.map_err(MutationError::from),
);
} }
for policy in policies { for policy in policies {
self.repository.replace_policy_file( remember_rollback_failure(
&policy.directory, &mut failure,
false,
Some(&policy.recipients),
)?;
if let Some(signature) = &policy.signature {
self.repository self.repository
.replace_policy_file(&policy.directory, true, Some(signature))?; .replace_policy_file(&policy.directory, false, Some(&policy.recipients))
.map_err(MutationError::from),
);
if let Some(signature) = &policy.signature {
remember_rollback_failure(
&mut failure,
self.repository
.replace_policy_file(&policy.directory, true, Some(signature))
.map_err(MutationError::from),
);
} }
} }
for entry in entries { for entry in entries {
remember_rollback_failure(
&mut failure,
self.repository self.repository
.write_entry(&entry.source, &entry.ciphertext)?; .write_entry(&entry.source, &entry.ciphertext)
.map_err(MutationError::from),
);
} }
Ok(()) failure.map_or(Ok(()), Err)
} }
fn remove_created_directories( fn remove_created_directories(
&self, &self,
directories: &[DirectoryPath], directories: &[DirectoryPath],
) -> Result<(), MutationError> { ) -> Result<(), MutationError> {
let mut failure = None;
for directory in directories.iter().rev() { for directory in directories.iter().rev() {
if !self.repository.remove_empty_directory(directory)? { let result = self
return Err(MutationError::TreeChanged { .repository
path: directory.as_path().to_owned(), .remove_empty_directory(directory)
}); .map_err(MutationError::from)
} .and_then(|removed| {
} if removed {
Ok(()) Ok(())
} else {
Err(MutationError::TreeChanged {
path: directory.as_path().to_owned(),
})
}
});
remember_rollback_failure(&mut failure, result);
}
failure.map_or(Ok(()), Err)
} }
fn restore_destinations( fn restore_destinations(
@@ -688,25 +739,63 @@ impl<'a> TreeMutator<'a> {
entries: &[TransferEntry], entries: &[TransferEntry],
policies: &[TransferPolicy], policies: &[TransferPolicy],
) -> Result<(), MutationError> { ) -> Result<(), MutationError> {
let mut failure = None;
for policy in policies.iter().rev() { for policy in policies.iter().rev() {
if policy.source.signature.is_some() { if policy.source.signature.is_some() {
remember_rollback_failure(
&mut failure,
self.repository self.repository
.replace_policy_file(&policy.destination, true, None)?; .replace_policy_file(&policy.destination, true, None)
.map_err(MutationError::from),
);
} }
remember_rollback_failure(
&mut failure,
self.repository self.repository
.replace_policy_file(&policy.destination, false, None)?; .replace_policy_file(&policy.destination, false, None)
.map_err(MutationError::from),
);
} }
for entry in entries.iter().rev() { for entry in entries.iter().rev() {
if let Some(original) = &entry.old_destination { let result = if let Some(original) = &entry.old_destination {
self.repository.write_entry(&entry.destination, original)?; self.repository
.write_entry(&entry.destination, original)
.map_err(MutationError::from)
} else { } else {
match self.repository.remove_entry(&entry.destination) { match self.repository.remove_entry(&entry.destination) {
Ok(_) | Err(RepositoryError::NotFound { .. }) => {} Ok(_) | Err(RepositoryError::NotFound { .. }) => Ok(()),
Err(error) => return Err(error.into()), Err(error) => Err(error.into()),
} }
};
remember_rollback_failure(&mut failure, result);
} }
failure.map_or(Ok(()), Err)
} }
Ok(())
#[allow(clippy::too_many_arguments)]
fn rollback_transfer(
&self,
moving: bool,
source_entries: &[EntryState],
source_policies: &[PolicyState],
source_directories: &[DirectoryPath],
entries: &[TransferEntry],
policies: &[TransferPolicy],
created_directories: &[DirectoryPath],
) -> Result<(), MutationError> {
let mut failure = None;
if moving {
remember_rollback_failure(
&mut failure,
self.restore_sources(source_entries, source_policies, source_directories),
);
}
remember_rollback_failure(&mut failure, self.restore_destinations(entries, policies));
remember_rollback_failure(
&mut failure,
self.remove_created_directories(created_directories),
);
failure.map_or(Ok(()), Err)
} }
} }
@@ -741,6 +830,25 @@ struct TransferPolicy {
destination: DirectoryPath, destination: DirectoryPath,
} }
fn remember_rollback_failure(
failure: &mut Option<MutationError>,
result: Result<(), MutationError>,
) {
if failure.is_none() {
*failure = result.err();
}
}
fn with_rollback(operation: MutationError, rollback: Result<(), MutationError>) -> MutationError {
match rollback {
Ok(()) => operation,
Err(rollback) => MutationError::RollbackFailed {
operation: Box::new(operation),
rollback: Box::new(rollback),
},
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum MutationError { pub enum MutationError {
Repository(RepositoryError), Repository(RepositoryError),
@@ -751,13 +859,29 @@ pub enum MutationError {
RootMutation, RootMutation,
SameObject, SameObject,
DestinationInsideSource, DestinationInsideSource,
DestinationDirectoryMissing { directory: DirectoryPath }, DestinationDirectoryMissing {
DestinationTypeCollision { path: std::path::PathBuf }, directory: DirectoryPath,
UnsafePolicyOverwrite { directory: DirectoryPath }, },
UnsupportedAuxiliary { path: std::path::PathBuf }, DestinationTypeCollision {
UnsupportedGitRepository { path: std::path::PathBuf }, path: std::path::PathBuf,
TreeChanged { path: std::path::PathBuf }, },
UnsafePolicyOverwrite {
directory: DirectoryPath,
},
UnsupportedAuxiliary {
path: std::path::PathBuf,
},
UnsupportedGitRepository {
path: std::path::PathBuf,
},
TreeChanged {
path: std::path::PathBuf,
},
Commit(TreeCommitError), Commit(TreeCommitError),
RollbackFailed {
operation: Box<MutationError>,
rollback: Box<MutationError>,
},
} }
impl fmt::Display for MutationError { impl fmt::Display for MutationError {
@@ -804,6 +928,13 @@ impl fmt::Display for MutationError {
path.display() path.display()
), ),
Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"), Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"),
Self::RollbackFailed {
operation,
rollback,
} => write!(
formatter,
"tree mutation failed ({operation}) and rollback failed ({rollback})"
),
} }
} }
} }
@@ -829,3 +960,57 @@ impl From<crate::crypto::CryptoError> for MutationError {
Self::Crypto(error) Self::Crypto(error)
} }
} }
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn directory_mutation_uses_one_repository_snapshot() {
let temporary = tempfile::tempdir().expect("temporary store");
fs::create_dir_all(temporary.path().join("folder/nested")).expect("tree");
fs::write(
temporary.path().join("folder/nested/entry.gpg"),
b"ciphertext",
)
.expect("entry");
let repository = Repository::open(temporary.path()).expect("repository");
let keys = KeyStore::new();
let mut committer = NoGitTreeCommitter;
TreeMutator::new(&repository, &keys)
.remove(
&RemoveRequest {
entry: "folder/".to_owned(),
recursive: true,
force: true,
},
OverwriteDecision::Decline,
&mut committer,
)
.expect("remove directory");
assert_eq!(repository.snapshot_count(), 1);
}
#[test]
fn rollback_error_retains_both_failures() {
let operation = MutationError::Commit(TreeCommitError::new("operation"));
let rollback = MutationError::TreeChanged {
path: "rollback".into(),
};
let combined = with_rollback(operation, Err(rollback));
let MutationError::RollbackFailed {
operation,
rollback,
} = combined
else {
panic!("both failures must be retained");
};
assert!(matches!(*operation, MutationError::Commit(_)));
assert!(matches!(*rollback, MutationError::TreeChanged { .. }));
}
}
+8 -37
View File
@@ -3,15 +3,13 @@
use std::{error::Error, fmt, ops::Range, str, sync::Mutex}; use std::{error::Error, fmt, ops::Range, str, sync::Mutex};
use data_encoding::BASE32_NOPAD; use data_encoding::BASE32_NOPAD;
use hmac::Hmac;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use zeroize::Zeroize as _; use zeroize::Zeroize as _;
use crate::{ use crate::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{CryptoError, KeyStore, SecretProvider}, crypto::{CryptoError, KeyStore, SecretProvider},
git::{AutomaticEntryCommitter, GitError, GitIdentity}, git::{AutomaticEntryCommitter, GitError, GitIdentity},
otp_core,
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy}, recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision}, write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision},
@@ -27,12 +25,7 @@ pub enum OtpKind {
Hotp, Hotp,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub use crate::otp_core::OtpAlgorithm;
pub enum OtpAlgorithm {
Sha1,
Sha256,
Sha512,
}
/// A validated key URI whose encoded and decoded secrets zeroize on drop. /// A validated key URI whose encoded and decoded secrets zeroize on drop.
pub struct OtpUri { pub struct OtpUri {
@@ -229,6 +222,10 @@ impl OtpUri {
self.period self.period
} }
pub(crate) fn watch_secret(&self) -> SecretBytes {
SecretBytes::new(self.secret.expose().to_vec())
}
pub fn counter(&self) -> Option<u64> { pub fn counter(&self) -> Option<u64> {
self.counter self.counter
} }
@@ -247,21 +244,8 @@ impl OtpUri {
} }
pub fn code_for_counter(&self, counter: u64) -> Result<SecretBytes, OtpError> { pub fn code_for_counter(&self, counter: u64) -> Result<SecretBytes, OtpError> {
let message = counter.to_be_bytes(); otp_core::code_for_counter(self.algorithm, self.secret.expose(), self.digits, counter)
let mut digest = match self.algorithm { .map_err(|_| OtpError::InvalidSecret)
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(self.secret.expose(), &message)?,
};
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
let binary = (u32::from(digest[offset]) & 0x7f) << 24
| u32::from(digest[offset + 1]) << 16
| u32::from(digest[offset + 2]) << 8
| u32::from(digest[offset + 3]);
digest.zeroize();
let modulus = 10_u32.pow(self.digits);
let code = format!("{:0width$}", binary % modulus, width = self.digits as usize);
Ok(SecretBytes::new(code.into_bytes()))
} }
fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> { fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> {
@@ -1200,19 +1184,6 @@ fn percent_encode(value: &str) -> String {
encoded encoded
} }
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpError>
where
M: hmac::digest::Mac + hmac::digest::KeyInit,
{
let mut mac =
<M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpError::InvalidSecret)?;
mac.update(message);
let mut output = mac.finalize().into_bytes();
let digest = output.to_vec();
output.fill(0);
Ok(digest)
}
pub(crate) fn find_uri( pub(crate) fn find_uri(
plaintext: &SecretBytes, plaintext: &SecretBytes,
entry: &EntryPath, entry: &EntryPath,
+97
View File
@@ -0,0 +1,97 @@
use std::{error::Error, fmt};
use hmac::Hmac;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use zeroize::Zeroize as _;
use crate::secret::SecretBytes;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpAlgorithm {
Sha1,
Sha256,
Sha512,
}
pub fn code_for_counter(
algorithm: OtpAlgorithm,
secret: &[u8],
digits: u32,
counter: u64,
) -> Result<SecretBytes, OtpCoreError> {
if secret.is_empty() || !matches!(digits, 6 | 8) {
return Err(OtpCoreError);
}
let message = counter.to_be_bytes();
let mut digest = match algorithm {
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(secret, &message)?,
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(secret, &message)?,
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(secret, &message)?,
};
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
let binary = (u32::from(digest[offset]) & 0x7f) << 24
| u32::from(digest[offset + 1]) << 16
| u32::from(digest[offset + 2]) << 8
| u32::from(digest[offset + 3]);
digest.zeroize();
let code = format!(
"{:0width$}",
binary % 10_u32.pow(digits),
width = digits as usize
);
Ok(SecretBytes::new(code.into_bytes()))
}
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpCoreError>
where
M: hmac::digest::Mac + hmac::digest::KeyInit,
{
let mut mac = <M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpCoreError)?;
mac.update(message);
let mut output = mac.finalize().into_bytes();
let digest = output.to_vec();
output.fill(0);
Ok(digest)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OtpCoreError;
impl fmt::Display for OtpCoreError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("OTP parameters are invalid")
}
}
impl Error for OtpCoreError {}
#[cfg(test)]
mod tests {
use super::{OtpAlgorithm, code_for_counter};
#[test]
fn rfc_6238_vectors_cover_every_watch_algorithm() {
let vectors = [
(
OtpAlgorithm::Sha1,
b"12345678901234567890".as_slice(),
"94287082",
),
(
OtpAlgorithm::Sha256,
b"12345678901234567890123456789012".as_slice(),
"46119246",
),
(
OtpAlgorithm::Sha512,
b"1234567890123456789012345678901234567890123456789012345678901234".as_slice(),
"90693936",
),
];
for (algorithm, secret, expected) in vectors {
let code = code_for_counter(algorithm, secret, 8, 59 / 30).expect("RFC vector");
assert_eq!(code.expose(), expected.as_bytes());
}
}
}
+13 -1
View File
@@ -242,7 +242,19 @@ impl QrMatrix {
/// Decode the first QR symbol from an encoded image without exposing the /// Decode the first QR symbol from an encoded image without exposing the
/// secret-derived payload to a presentation adapter. /// secret-derived payload to a presentation adapter.
pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> { pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> {
let grayscale = image::load_from_memory(image.expose()) if image.expose().len() > 32 * 1024 * 1024 {
return Err(QrError::InvalidImage);
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(image.expose()))
.with_guessed_format()
.map_err(|_| QrError::InvalidImage)?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
let grayscale = reader
.decode()
.map_err(|_| QrError::InvalidImage)? .map_err(|_| QrError::InvalidImage)?
.into_luma8(); .into_luma8();
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
+9
View File
@@ -205,6 +205,15 @@ impl<'a> RecipientPolicyManager<'a> {
signing: Option<&SigningPolicy>, signing: Option<&SigningPolicy>,
) -> Result<EffectiveRecipients, RecipientPolicyError> { ) -> Result<EffectiveRecipients, RecipientPolicyError> {
let snapshot = self.repository.snapshot()?; let snapshot = self.repository.snapshot()?;
self.resolve_for_entry_in_snapshot(&snapshot, entry, signing)
}
pub(crate) fn resolve_for_entry_in_snapshot(
&self,
snapshot: &RepositorySnapshot,
entry: &EntryPath,
signing: Option<&SigningPolicy>,
) -> Result<EffectiveRecipients, RecipientPolicyError> {
let policy = snapshot.recipient_policy(entry).ok_or_else(|| { let policy = snapshot.recipient_policy(entry).ok_or_else(|| {
RecipientPolicyError::MissingRecipientPolicy { RecipientPolicyError::MissingRecipientPolicy {
entry: entry.clone(), entry: entry.clone(),
+94 -42
View File
@@ -11,10 +11,12 @@ use std::{
#[cfg(test)] #[cfg(test)]
use std::collections::BTreeSet; use std::collections::BTreeSet;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
pub use crate::secret::SecretBytes;
use cap_std::{ambient_authority, fs::Dir}; use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile; use cap_tempfile::TempFile;
use zeroize::Zeroize;
const ENTRY_EXTENSION: &str = "gpg"; const ENTRY_EXTENSION: &str = "gpg";
const RECIPIENT_FILE: &str = ".gpg-id"; const RECIPIENT_FILE: &str = ".gpg-id";
@@ -132,35 +134,6 @@ impl fmt::Debug for EncryptedEntry {
} }
} }
/// Decrypted bytes that are redacted in diagnostics and zeroed when dropped.
pub struct SecretBytes(Vec<u8>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretBytes([REDACTED])")
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryRecord { pub struct EntryRecord {
path: EntryPath, path: EntryPath,
@@ -345,6 +318,8 @@ impl RepositorySnapshot {
pub struct Repository { pub struct Repository {
root_path: PathBuf, root_path: PathBuf,
root: Dir, root: Dir,
#[cfg(test)]
snapshot_count: AtomicUsize,
} }
impl fmt::Debug for Repository { impl fmt::Debug for Repository {
@@ -375,7 +350,12 @@ impl Repository {
.map_err(|error| io_error("canonicalize repository root", requested, error))?; .map_err(|error| io_error("canonicalize repository root", requested, error))?;
let root = Dir::open_ambient_dir(&root_path, ambient_authority()) let root = Dir::open_ambient_dir(&root_path, ambient_authority())
.map_err(|error| io_error("open repository root", &root_path, error))?; .map_err(|error| io_error("open repository root", &root_path, error))?;
Ok(Self { root_path, root }) Ok(Self {
root_path,
root,
#[cfg(test)]
snapshot_count: AtomicUsize::new(0),
})
} }
pub fn root_path(&self) -> &Path { pub fn root_path(&self) -> &Path {
@@ -383,11 +363,18 @@ impl Repository {
} }
pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> { pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> {
#[cfg(test)]
self.snapshot_count.fetch_add(1, Ordering::Relaxed);
let mut snapshot = RepositorySnapshot::default(); let mut snapshot = RepositorySnapshot::default();
scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?; scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?;
Ok(snapshot) Ok(snapshot)
} }
#[cfg(test)]
pub(crate) fn snapshot_count(&self) -> usize {
self.snapshot_count.load(Ordering::Relaxed)
}
pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> { pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
let (parent, file_name) = self.open_entry_parent(path)?; let (parent, file_name) = self.open_entry_parent(path)?;
let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())? let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())?
@@ -786,29 +773,43 @@ impl Repository {
} }
fn rollback_created<T>( fn rollback_created<T>(
&self,
created: Vec<PathBuf>,
original: RepositoryError,
) -> Result<T, RepositoryError> {
self.rollback_created_with_checkpoint(created, original, |_| Ok(()))
}
fn rollback_created_with_checkpoint<T>(
&self, &self,
mut created: Vec<PathBuf>, mut created: Vec<PathBuf>,
original: RepositoryError, original: RepositoryError,
mut checkpoint: impl FnMut(RollbackStage) -> Result<(), RepositoryError>,
) -> Result<T, RepositoryError> { ) -> Result<T, RepositoryError> {
while let Some(path) = created.pop() { while let Some(path) = created.pop() {
let parent = path.parent().unwrap_or_else(|| Path::new("")); let parent = path.parent().unwrap_or_else(|| Path::new(""));
let rollback = (|| {
checkpoint(RollbackStage::BeforeReopen)?;
let parent_dir = self.open_directory(parent)?; let parent_dir = self.open_directory(parent)?;
let name = path let name = path
.file_name() .file_name()
.expect("created directory path has a file name"); .expect("created directory path has a file name");
match parent_dir.remove_dir(name) { match parent_dir.remove_dir(name) {
Ok(()) => { Ok(()) => {
sync_directory(&parent_dir, parent)?; checkpoint(RollbackStage::BeforeSync)?;
sync_directory(&parent_dir, parent)
} }
Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => { Err(error) => Err(io_error("remove rollback directory", &path, error)),
}
})();
if let Err(rollback) = rollback {
return Err(RepositoryError::RollbackFailed { return Err(RepositoryError::RollbackFailed {
path, operation: Box::new(original),
source: error.kind(), rollback: Box::new(rollback),
}); });
} }
} }
}
Err(original) Err(original)
} }
@@ -845,6 +846,12 @@ enum WriteStage {
AfterRename, AfterRename,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RollbackStage {
BeforeReopen,
BeforeSync,
}
#[cfg(test)] #[cfg(test)]
impl WriteStage { impl WriteStage {
fn fixture_id(self) -> &'static str { fn fixture_id(self) -> &'static str {
@@ -885,8 +892,8 @@ pub enum RepositoryError {
source: io::ErrorKind, source: io::ErrorKind,
}, },
RollbackFailed { RollbackFailed {
path: PathBuf, operation: Box<RepositoryError>,
source: io::ErrorKind, rollback: Box<RepositoryError>,
}, },
DurabilityUncertain { DurabilityUncertain {
path: PathBuf, path: PathBuf,
@@ -948,10 +955,12 @@ impl fmt::Display for RepositoryError {
path, path,
source, source,
} => write!(formatter, "cannot {operation} {}: {source}", path.display()), } => write!(formatter, "cannot {operation} {}: {source}", path.display()),
Self::RollbackFailed { path, source } => write!( Self::RollbackFailed {
operation,
rollback,
} => write!(
formatter, formatter,
"failed to roll back empty password-store directory {}: {source}", "repository operation failed ({operation}) and directory rollback failed ({rollback})"
path.display()
), ),
Self::DurabilityUncertain { path } => write!( Self::DurabilityUncertain { path } => write!(
formatter, formatter,
@@ -1359,4 +1368,47 @@ mod tests {
assert!(!temporary.path().join("vault/one").exists()); assert!(!temporary.path().join("vault/one").exists());
Ok(()) Ok(())
} }
#[test]
fn rollback_retains_operation_and_directory_failure() -> Result<(), Box<dyn Error>> {
for failed_stage in [RollbackStage::BeforeReopen, RollbackStage::BeforeSync] {
let temporary = tempfile::tempdir()?;
let vault = temporary.path().join("vault");
fs::create_dir_all(vault.join("created"))?;
let repository = Repository::open(&vault)?;
let operation = RepositoryError::Io {
operation: "simulated operation",
path: PathBuf::from("entry"),
source: io::ErrorKind::Interrupted,
};
let rollback = RepositoryError::Io {
operation: "simulated rollback",
path: PathBuf::from("created"),
source: io::ErrorKind::Other,
};
let error = repository
.rollback_created_with_checkpoint::<()>(
vec![PathBuf::from("created")],
operation.clone(),
|stage| {
if stage == failed_stage {
Err(rollback.clone())
} else {
Ok(())
}
},
)
.expect_err("rollback checkpoint must fail");
assert_eq!(
error,
RepositoryError::RollbackFailed {
operation: Box::new(operation),
rollback: Box::new(rollback),
}
);
}
Ok(())
}
} }
+32
View File
@@ -0,0 +1,32 @@
use std::fmt;
use zeroize::Zeroize as _;
/// Secret bytes that are redacted in diagnostics and zeroed when dropped.
pub struct SecretBytes(Vec<u8>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretBytes([REDACTED])")
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.0.zeroize();
}
}
+147 -4
View File
@@ -10,9 +10,9 @@ use std::{
}; };
use crate::{ use crate::{
config::{ApplicationId, ServerId}, config::{ApplicationId, ServerId, SshFingerprint},
crypto::{KeyInfo, SecretProvider, SecretProviderError}, crypto::{KeyInfo, SecretProvider, SecretProviderError},
git::{GitCredential, GitCredentialProvider, GitError}, git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
repository::SecretBytes, repository::SecretBytes,
}; };
@@ -27,6 +27,7 @@ const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
const MAX_CACHE_CAPACITY: usize = 128; const MAX_CACHE_CAPACITY: usize = 128;
const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase"; const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase";
const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git"; const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git";
const SSH_KEY_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.ssh-key-passphrase";
/// The purpose and stable, non-secret identity of an OS credential. /// The purpose and stable, non-secret identity of an OS credential.
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
@@ -44,6 +45,9 @@ enum SecretReferenceKind {
application_id: String, application_id: String,
account: String, account: String,
}, },
SshKeyPassphrase {
fingerprint: SshFingerprint,
},
} }
impl SecretReference { impl SecretReference {
@@ -87,10 +91,17 @@ impl SecretReference {
}) })
} }
pub fn ssh_key_passphrase(fingerprint: SshFingerprint) -> Self {
Self {
kind: SecretReferenceKind::SshKeyPassphrase { fingerprint },
}
}
pub fn account(&self) -> Option<&str> { pub fn account(&self) -> Option<&str> {
match &self.kind { match &self.kind {
SecretReferenceKind::OpenPgpPassphrase { .. } => None, SecretReferenceKind::OpenPgpPassphrase { .. } => None,
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account), SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
SecretReferenceKind::SshKeyPassphrase { .. } => None,
} }
} }
@@ -109,6 +120,11 @@ impl SecretReference {
server_id: server_id.clone(), server_id: server_id.clone(),
application_id: application_id.clone(), application_id: application_id.clone(),
}, },
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
SecretLocator::SshKeyPassphrase {
fingerprint: fingerprint.clone(),
}
}
} }
} }
} }
@@ -122,6 +138,9 @@ impl fmt::Debug for SecretReference {
Self { Self {
kind: SecretReferenceKind::HttpsGitCredential { .. }, kind: SecretReferenceKind::HttpsGitCredential { .. },
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"), } => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
Self {
kind: SecretReferenceKind::SshKeyPassphrase { .. },
} => formatter.write_str("SecretReference::SshKeyPassphrase([REDACTED])"),
} }
} }
} }
@@ -137,6 +156,9 @@ pub enum SecretLocator {
server_id: String, server_id: String,
application_id: String, application_id: String,
}, },
SshKeyPassphrase {
fingerprint: SshFingerprint,
},
} }
impl fmt::Debug for SecretLocator { impl fmt::Debug for SecretLocator {
@@ -148,6 +170,9 @@ impl fmt::Debug for SecretLocator {
Self::HttpsGitCredential { .. } => { Self::HttpsGitCredential { .. } => {
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])") formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
} }
Self::SshKeyPassphrase { .. } => {
formatter.write_str("SecretLocator::SshKeyPassphrase([REDACTED])")
}
} }
} }
} }
@@ -162,6 +187,9 @@ impl SecretLocator {
server_id, server_id,
application_id, application_id,
} => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")), } => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
Self::SshKeyPassphrase { fingerprint } => {
(SSH_KEY_PASSPHRASE_SERVICE, fingerprint.to_string())
}
} }
} }
} }
@@ -209,6 +237,7 @@ impl SecretProtectionPolicy {
match &reference.kind { match &reference.kind {
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp, SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
SecretReferenceKind::HttpsGitCredential { .. } => self.git, SecretReferenceKind::HttpsGitCredential { .. } => self.git,
SecretReferenceKind::SshKeyPassphrase { .. } => self.git,
} }
} }
@@ -216,6 +245,7 @@ impl SecretProtectionPolicy {
match locator { match locator {
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp, SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
SecretLocator::HttpsGitCredential { .. } => self.git, SecretLocator::HttpsGitCredential { .. } => self.git,
SecretLocator::SshKeyPassphrase { .. } => self.git,
} }
} }
} }
@@ -325,6 +355,12 @@ pub trait SecretStoreBackend: Send + Sync {
locator: &SecretLocator, locator: &SecretLocator,
protection: SecretProtection, protection: SecretProtection,
) -> Result<(), SecretStoreError>; ) -> Result<(), SecretStoreError>;
fn platform_https_git_credential(
&self,
_endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
Ok(None)
}
fn lock(&self) -> Result<(), SecretStoreError> { fn lock(&self) -> Result<(), SecretStoreError> {
Ok(()) Ok(())
} }
@@ -482,6 +518,22 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) Ok(())
} }
/// Store an SSH key passphrase only after a caller has proved it by
/// completing SSH public-key authentication successfully.
pub fn persist_verified_ssh_passphrase(
&self,
fingerprint: &SshFingerprint,
value: SecretBytes,
) -> Result<(), SecretStoreError> {
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
let candidate = SecretBytes::new(value.expose().to_vec());
match self.create(&reference, candidate) {
Ok(()) => Ok(()),
Err(SecretStoreError::AlreadyExists) => self.replace(&reference, value),
Err(error) => Err(error),
}
}
/// Persist a verified OpenPGP passphrase without first reading the old item. /// Persist a verified OpenPGP passphrase without first reading the old item.
/// This lets Apple replace an item invalidated by biometric enrollment changes. /// This lets Apple replace an item invalidated by biometric enrollment changes.
pub(crate) fn store_openpgp_passphrase( pub(crate) fn store_openpgp_passphrase(
@@ -557,6 +609,29 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) Ok(())
} }
/// Return only the non-secret account identity for one configured Git credential.
pub fn https_git_credential_account(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<String, SecretStoreError> {
self.retrieve_git_record(server, application)?
.reference
.account()
.map(str::to_owned)
.ok_or(SecretStoreError::Corrupted)
}
/// Remove the credential selected by its configured server/application identity.
pub fn delete_https_git_credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<(), SecretStoreError> {
let record = self.retrieve_git_record(server, application)?;
self.delete(&record.reference)
}
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> { pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
let locator = reference.locator(); let locator = reference.locator();
let mut state = self.unlocked_state()?; let mut state = self.unlocked_state()?;
@@ -779,6 +854,32 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
let record = self let record = self
.retrieve_git_record(server, application) .retrieve_git_record(server, application)
.map_err(git_provider_error)?; .map_err(git_provider_error)?;
git_credential_from_record(&record)
}
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
let Some((server, application)) = remote.https_credentials() else {
return Err(GitError::CredentialsUnavailable);
};
let endpoint = remote
.endpoint()
.as_https()
.ok_or(GitError::CredentialsUnavailable)?;
match self
.backend
.platform_https_git_credential(endpoint)
.map_err(git_provider_error)?
{
Some((account, password)) => GitCredential::new(account, password.expose().to_vec()),
None => self
.retrieve_git_record(server, application)
.map_err(git_provider_error)
.and_then(|record| git_credential_from_record(&record)),
}
}
}
fn git_credential_from_record(record: &SecretRecord) -> Result<GitCredential, GitError> {
let Some(account) = record.reference.account() else { let Some(account) = record.reference.account() else {
return Err(GitError::CredentialsUnavailable); return Err(GitError::CredentialsUnavailable);
}; };
@@ -790,6 +891,22 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
} }
GitCredential::new(account, record.value.expose().to_vec()) GitCredential::new(account, record.value.expose().to_vec())
} }
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone()))
.map_err(|error| match error {
SecretStoreError::Cancelled => GitError::SshKeyPassphraseCancelled {
fingerprint: fingerprint.clone(),
},
SecretStoreError::Denied => GitError::SshKeyPassphraseDenied {
fingerprint: fingerprint.clone(),
},
_ => GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
},
})
}
} }
fn provider_error(error: SecretStoreError) -> SecretProviderError { fn provider_error(error: SecretStoreError) -> SecretProviderError {
@@ -855,6 +972,10 @@ fn encode_record(
write_field(&mut encoded, application_id.as_bytes())?; write_field(&mut encoded, application_id.as_bytes())?;
write_field(&mut encoded, account.as_bytes())?; write_field(&mut encoded, account.as_bytes())?;
} }
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
encoded.push(3);
write_field(&mut encoded, fingerprint.as_str().as_bytes())?;
}
} }
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?; let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
encoded.extend_from_slice(&length.to_be_bytes()); encoded.extend_from_slice(&length.to_be_bytes());
@@ -896,6 +1017,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
) )
.map_err(|_| SecretStoreError::Corrupted)? .map_err(|_| SecretStoreError::Corrupted)?
} }
3 => {
let (fingerprint, rest) = read_field(remainder)?;
remainder = rest;
SecretReference::ssh_key_passphrase(
SshFingerprint::parse(read_text(fingerprint)?)
.map_err(|_| SecretStoreError::Corrupted)?,
)
}
_ => return Err(SecretStoreError::Corrupted), _ => return Err(SecretStoreError::Corrupted),
}; };
if remainder.len() < 4 { if remainder.len() < 4 {
@@ -970,8 +1099,8 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes, HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION,
SecretLocator, SecretStoreError, decode_record, SSH_KEY_PASSPHRASE_SERVICE, SecretBytes, SecretLocator, SecretStoreError, decode_record,
}; };
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes { fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
@@ -1010,6 +1139,12 @@ mod tests {
server_id: "server".to_owned(), server_id: "server".to_owned(),
application_id: "application".to_owned(), application_id: "application".to_owned(),
}; };
let ssh = SecretLocator::SshKeyPassphrase {
fingerprint: crate::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.unwrap(),
};
assert_eq!( assert_eq!(
openpgp.service_and_user(), openpgp.service_and_user(),
( (
@@ -1021,7 +1156,15 @@ mod tests {
git.service_and_user(), git.service_and_user(),
(HTTPS_GIT_SERVICE, "server/application".to_owned()) (HTTPS_GIT_SERVICE, "server/application".to_owned())
); );
assert_eq!(
ssh.service_and_user(),
(
SSH_KEY_PASSPHRASE_SERVICE,
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()
)
);
assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage.")); assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage.")); assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage."));
assert!(SSH_KEY_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
} }
} }
@@ -20,6 +20,15 @@ use security_framework::{
}, },
}; };
#[cfg(target_os = "macos")]
use security_framework::{
item::{ItemClass, ItemSearchOptions, Limit},
os::macos::{
keychain::SecKeychain,
passwords::{SecAuthenticationType, SecProtocolType},
},
};
use keyring_core::{CredentialStore, Entry}; use keyring_core::{CredentialStore, Entry};
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
@@ -196,6 +205,95 @@ impl SecretStoreBackend for NativeSecretBackend {
.delete_credential() .delete_credential()
.map_err(map_error) .map_err(map_error)
} }
fn platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
#[cfg(target_os = "macos")]
{
macos_https_git_credential(endpoint)
}
#[cfg(not(target_os = "macos"))]
{
let _ = endpoint;
Ok(None)
}
}
}
#[cfg(target_os = "macos")]
fn macos_https_git_credential(
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
let host = endpoint
.host_str()
.ok_or(SecretStoreError::InvalidReference)?;
let path = endpoint.path();
let mut search = ItemSearchOptions::new();
search
.class(ItemClass::internet_password())
.load_attributes(true)
.limit(Limit::All);
let mut candidates = search
.search()
.map_err(map_security_error)?
.into_iter()
.filter_map(|item| item.simplify_dict())
.filter_map(|attributes| {
let stored_path = attributes.get("path").map_or("", String::as_str);
if attributes.get("srvr").map(String::as_str) == Some(host)
&& attributes.get("ptcl").map(String::as_str) == Some("htps")
&& (stored_path.is_empty() || stored_path == path)
{
attributes
.get("acct")
.map(|account| (account.clone(), stored_path.to_owned()))
} else {
None
}
})
.collect::<Vec<_>>();
candidates.sort_unstable();
candidates.dedup();
let [(account, credential_path)] = candidates.as_slice() else {
return if candidates.is_empty() {
Err(SecretStoreError::Missing)
} else {
Err(SecretStoreError::Corrupted)
};
};
let keychain = SecKeychain::default().map_err(map_security_error)?;
let (password, _) = keychain
.find_internet_password(
host,
None,
account,
credential_path,
endpoint.port(),
SecProtocolType::HTTPS,
SecAuthenticationType::Default,
)
.or_else(|error| {
if endpoint.port().is_some() && error.code() == -25300 {
keychain.find_internet_password(
host,
None,
account,
credential_path,
None,
SecProtocolType::HTTPS,
SecAuthenticationType::Default,
)
} else {
Err(error)
}
})
.map_err(map_security_error)?;
Ok(Some((
account.clone(),
SecretBytes::new(password.as_ref().to_vec()),
)))
} }
#[cfg(any(target_os = "ios", target_os = "macos"))] #[cfg(any(target_os = "ios", target_os = "macos"))]
File diff suppressed because it is too large. Load diff
+127
View File
@@ -0,0 +1,127 @@
#![forbid(unsafe_code)]
use std::{fs, path::PathBuf};
fn workspace_file(path: &str) -> String {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root")
.to_path_buf();
fs::read_to_string(root.join(path)).unwrap_or_else(|error| panic!("read {path}: {error}"))
}
#[test]
fn apple_sources_preserve_the_mobile_security_boundary() {
let app = workspace_file("apple/Sources/App/IronStorageApp.swift");
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
let bridges = format!(
"{}\n{}",
workspace_file("crates/apple/src/lib.rs"),
workspace_file("crates/watch-apple/src/lib.rs")
);
for (name, source) in [
("iPhone", app.as_str()),
("Watch", watch.as_str()),
("AutoFill", autofill.as_str()),
("Apple Rust bridges", bridges.as_str()),
] {
for forbidden in [
"Process(",
"NSTask",
concat!("Command", "::new("),
"posix_spawn(",
] {
assert!(!source.contains(forbidden), "{name} launches {forbidden}");
}
}
for (name, source) in [
("iPhone", app.as_str()),
("Watch", watch.as_str()),
("AutoFill", autofill.as_str()),
] {
for forbidden in ["print(", "NSLog(", "Logger(", "os_log("] {
assert!(
!source.contains(forbidden),
"{name} logs through {forbidden}"
);
}
}
assert!(!app.contains("FileManager.default"));
for forbidden in ["otpauth://", "HMAC", "SHA1", "SHA256", "SHA512", ".gpg-id"] {
assert!(
!watch.contains(forbidden),
"Watch implements storage or OTP domain behavior: {forbidden}"
);
}
assert!(watch.contains("core.presentationAt"));
assert!(watch.contains("SecItemCopyMatching"));
assert!(app.contains("session.sendMessage(payload"));
assert!(app.contains("session.transferUserInfo(payload)"));
assert!(app.contains("session.outstandingUserInfoTransfers"));
assert!(
app.split_once("@main")
.expect("iPhone app delegate")
.0
.contains("UIApplication.didBecomeActiveNotification")
);
assert!(watch.contains("didReceiveMessage message"));
assert!(watch.contains("didReceiveUserInfo userInfo"));
assert!(watch.contains("session.outstandingUserInfoTransfers"));
assert!(watch.contains("session.transferUserInfo(acknowledgement)"));
assert!(
watch
.split_once("func sceneBecameActive()")
.expect("Watch foreground handler")
.1
.split_once("func sceneBecameInactive()")
.expect("Watch inactive handler")
.0
.contains("receivedApplicationContext")
);
assert!(watch.contains("applyAuthoritativeSnapshot"));
}
#[test]
fn iphone_entry_editor_keeps_add_action_accessible_while_typing() {
let app = workspace_file("apple/Sources/App/IronStorageApp.swift");
assert!(app.contains("navigationItem.rightBarButtonItems = [save, add]"));
assert!(app.contains("tableView.keyboardDismissMode = .interactive"));
assert!(app.contains("field.returnKeyType = .done"));
assert!(app.contains("textField.resignFirstResponder()"));
assert!(!app.contains("inputAccessoryView = keyboardToolbar"));
}
#[test]
fn every_apple_bundle_has_an_audited_privacy_manifest() {
let project = workspace_file("apple/project.yml");
for path in [
"Resources/App/PrivacyInfo.xcprivacy",
"Resources/AutoFill/PrivacyInfo.xcprivacy",
"Resources/Watch/PrivacyInfo.xcprivacy",
] {
assert!(project.contains(path), "Xcode target omits {path}");
}
for path in [
"apple/Resources/App/PrivacyInfo.xcprivacy",
"apple/Resources/AutoFill/PrivacyInfo.xcprivacy",
"apple/Resources/Watch/PrivacyInfo.xcprivacy",
] {
let manifest = workspace_file(path);
assert!(manifest.contains("<key>NSPrivacyTracking</key>\n\t<false/>"));
assert!(manifest.contains("<key>NSPrivacyCollectedDataTypes</key>\n\t<array/>"));
}
let app = workspace_file("apple/Resources/App/PrivacyInfo.xcprivacy");
assert!(app.contains("NSPrivacyAccessedAPICategoryFileTimestamp"));
assert!(app.contains("C617.1"));
assert!(app.contains("NSPrivacyAccessedAPICategorySystemBootTime"));
assert!(app.contains("35F9.1"));
assert!(project.contains("NSFaceIDUsageDescription"));
assert!(project.contains("NSCameraUsageDescription"));
}
@@ -249,6 +249,30 @@ fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not()
Ok(()) Ok(())
} }
#[test]
fn timeout_updates_apply_to_the_active_lease_and_later_activity() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture_key(&fixture, &keys, "alice")?;
let backend = MemoryBackend::default();
provision_passphrase(
backend.clone(),
&alice,
fixture.key("alice")?.passphrase.as_bytes(),
)?;
let clock = ManualClock::default();
let session = session(backend, clock.clone(), 120)?;
let handle = session.authenticate(&alice)?;
clock.advance(Duration::from_secs(20));
session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?;
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
clock.advance(Duration::from_secs(10));
handle.touch_user_activity()?;
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
Ok(())
}
#[test] #[test]
fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult { fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;
+341 -10
View File
@@ -4,8 +4,13 @@ use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time:
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use ironstorage::{ use ironstorage::{
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, authentication::{
config::{ConfigError, ConfigLoader, EditorSource}, AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
},
config::{
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
SshRepositoryPath,
},
desktop::DesktopStorage, desktop::DesktopStorage,
git::GitIdentity, git::GitIdentity,
mobile::MobileTab, mobile::MobileTab,
@@ -82,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
assert_eq!(config.git_remotes().len(), 1); assert_eq!(config.git_remotes().len(), 1);
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
assert_eq!(remote.name().as_str(), "origin"); assert_eq!(remote.name().as_str(), "origin");
assert_eq!( assert_eq!(remote.url(), "https://git.example.test/alice/store.git");
remote.url().as_str(), let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
"https://git.example.test/alice/store.git" assert_eq!(server_id.as_str(), "personal-git");
); assert_eq!(application_id.as_str(), "ironstorage-cli");
assert_eq!(remote.server_id().as_str(), "personal-git");
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
assert_eq!( assert_eq!(
config.clipboard_timeout().duration(), config.clipboard_timeout().duration(),
DEFAULT_CLIPBOARD_TIMEOUT DEFAULT_CLIPBOARD_TIMEOUT
@@ -99,6 +102,80 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
Ok(()) Ok(())
} }
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
#[test]
fn desktop_configuration_uses_repository_remotes_without_duplicate_settings() -> TestResult {
let fixture = ConfigurationFixture::new()?;
let vault = fixture.temporary.path().join("cwd/vault");
fs::create_dir_all(vault.join(".git"))?;
fs::write(
vault.join(".git/config"),
"[remote \"origin\"]\n\turl = git@git.example:team/store.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
)?;
fixture.write_explicit(
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
"#,
)?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.git_remotes().len(), 1);
assert_eq!(config.git_remotes()[0].name().as_str(), "origin");
assert_eq!(
config.git_remotes()[0].url(),
"git@git.example:team/store.git"
);
assert!(config.git_remotes()[0].ssh_authentication().is_none());
Ok(())
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
#[test]
fn repository_https_remote_reuses_credentials_only_for_the_same_url() -> TestResult {
let fixture = ConfigurationFixture::new()?;
let vault = fixture.temporary.path().join("cwd/vault");
fs::create_dir_all(vault.join(".git"))?;
fs::write(
vault.join(".git/config"),
r#"[remote "origin"]
url = https://git.example.test/team/store.git
[remote "mirror"]
url = https://mirror.example.test/team/store.git
"#,
)?;
fixture.write_explicit(
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
[[git.remotes]]
name = "legacy-name"
url = "https://git.example.test/team/store.git"
server_id = "existing-server"
application_id = "existing-application"
"#,
)?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let origin = config.git_remote(Some("origin")).expect("origin remote");
let (server, application) = origin.https_credentials().expect("HTTPS credentials");
assert_eq!(server.as_str(), "existing-server");
assert_eq!(application.as_str(), "existing-application");
let mirror = config.git_remote(Some("mirror")).expect("mirror remote");
let (server, application) = mirror.https_credentials().expect("HTTPS credentials");
assert_ne!(server.as_str(), "existing-server");
assert_ne!(application.as_str(), "existing-application");
Ok(())
}
#[test] #[test]
fn git_commit_identity_defaults_validates_and_persists() -> TestResult { fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;
@@ -299,6 +376,29 @@ fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult
Ok(()) Ok(())
} }
#[test]
fn mobile_timeout_and_appearance_share_the_secret_free_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(config.mobile_appearance(), MobileAppearance::System);
config.update_mobile_appearance(MobileAppearance::Dark)?;
config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?;
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark);
assert_eq!(
reloaded.authentication_timeout().duration(),
Duration::from_secs(300)
);
let contents = fs::read_to_string(fixture.explicit_path())?;
assert!(contents.contains("mobile_appearance = \"dark\""));
assert!(contents.contains("inactivity_timeout_seconds = 300"));
assert!(!contents.to_ascii_lowercase().contains("token ="));
Ok(())
}
#[test] #[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;
@@ -539,10 +639,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult {
} }
#[test] #[test]
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult { fn git_configuration_rejects_forbidden_transports_and_embedded_credentials() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;
for url in [ for url in [
"ssh://git@example.test/store.git",
"git://example.test/store.git", "git://example.test/store.git",
"file:///tmp/store.git", "file:///tmp/store.git",
"../store.git", "../store.git",
@@ -577,6 +676,238 @@ application_id = "application"
Ok(()) Ok(())
} }
#[test]
fn ssh_remote_endpoints_parse_to_one_typed_contract() -> TestResult {
let absolute = RemoteEndpoint::parse("ssh://git@example.test:2222/repos/store.git")?;
assert_eq!(absolute.transport(), RemoteTransport::Ssh);
let ssh = absolute.as_ssh().expect("SSH endpoint");
assert_eq!(ssh.user(), Some("git"));
assert_eq!(ssh.host(), "example.test");
assert_eq!(ssh.port(), 2222);
assert_eq!(
ssh.path(),
&SshRepositoryPath::Absolute("/repos/store.git".to_owned())
);
assert_eq!(
RemoteEndpoint::parse("ssh://git@example.test/repos/store.git")?,
RemoteEndpoint::parse("git@example.test:/repos/store.git")?
);
assert_eq!(
RemoteEndpoint::parse("ssh://git@example.test/~alice/store.git")?,
RemoteEndpoint::parse("git@example.test:~alice/store.git")?
);
let relative = RemoteEndpoint::parse("git@example.test:team/store.git")?;
assert_eq!(
relative.as_ssh().expect("SSH endpoint").path(),
&SshRepositoryPath::Relative("team/store.git".to_owned())
);
let ipv6 = RemoteEndpoint::parse("ssh://git@[2001:db8::1]:2200/store.git")?;
assert_eq!(ipv6.as_ssh().expect("IPv6 endpoint").port(), 2200);
let ipv4 = RemoteEndpoint::parse("git@192.0.2.10:team/store.git")?;
assert_eq!(ipv4.as_ssh().expect("IPv4 endpoint").host(), "192.0.2.10");
let unicode = RemoteEndpoint::parse("git@bücher.example:team/密码.git")?;
let unicode = unicode.as_ssh().expect("Unicode endpoint");
assert_eq!(unicode.host(), "xn--bcher-kva.example");
assert_eq!(unicode.path().as_str(), "team/密码.git");
let inert = "team/repo';touch${IFS}pwned.git";
assert_eq!(
RemoteEndpoint::parse(&format!("git@example.test:{inert}"))?
.as_ssh()
.expect("literal path")
.path()
.as_str(),
inert
);
Ok(())
}
#[test]
fn remote_credentials_are_explicitly_transport_specific() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "git@example.test:team/store.git"
server_id = "server"
application_id = "application"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("HTTPS credentials cannot configure SSH"),
ConfigError::InvalidField {
field: "git.remotes.https_credentials"
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "https://example.test/team/store.git"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("HTTPS credentials are required"),
ConfigError::MissingField {
field: "git.remotes.server_id"
}
);
Ok(())
}
#[test]
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "git@example.test:team/store.git"
ssh_identity_file = "keys/id_ed25519"
ssh_known_hosts_file = "known_hosts"
"#,
)?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
let remote = &config.git_remotes()[0];
assert_eq!(remote.url(), "git@example.test:team/store.git");
assert!(remote.https_credentials().is_none());
let authentication = remote.ssh_authentication().expect("SSH authentication");
assert_eq!(
authentication.identity().key_file(),
Some(
fs::canonicalize(fixture.temporary.path())?
.join("cwd/config/keys/id_ed25519")
.as_path()
)
);
assert_eq!(
authentication.known_hosts_file(),
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
);
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(reloaded.git_remotes(), config.git_remotes());
let persisted = fs::read_to_string(fixture.explicit_path())?;
assert!(!persisted.contains("server_id"));
assert!(!persisted.contains("application_id"));
assert!(!persisted.contains("passphrase"));
assert!(persisted.contains("ssh_identity_file"));
assert!(persisted.contains("ssh_known_hosts_file"));
Ok(())
}
#[test]
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "ssh://git@example.test/team/store.git"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("SSH identity is required"),
ConfigError::InvalidField {
field: "git.remotes.ssh_authentication"
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "ssh://git@example.test/team/store.git"
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
ssh_agent_socket = "agent.sock"
ssh_known_hosts_file = "known_hosts"
"#,
)?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
let authentication = config.git_remotes()[0]
.ssh_authentication()
.expect("SSH authentication");
assert_eq!(
authentication
.identity()
.agent_fingerprint()
.expect("agent fingerprint")
.as_str(),
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
);
assert_eq!(
authentication.identity().agent_socket(),
Some(
fs::canonicalize(fixture.temporary.path())?
.join("cwd/config/agent.sock")
.as_path()
)
);
Ok(())
}
#[test]
fn ssh_remote_parser_rejects_ambiguous_local_and_executable_inputs() {
for remote in [
"",
"ssh://example.test",
"ssh://example.test/",
"ssh://user:secret@example.test/store.git",
"ssh://example.test/store.git?option=value",
"ssh://example.test/store.git#fragment",
"ssh://example.test/%0Acommand",
"git://example.test/store.git",
"file:///tmp/store.git",
"ext::helper command",
"../store.git",
"/tmp/store.git",
"C:/store.git",
"git@2001:db8::1:store.git",
"git@example.test:-upload-pack=evil",
"git@example.test:repo\ncommand",
] {
assert!(
RemoteEndpoint::parse(remote).is_err(),
"forbidden remote should fail closed: {remote:?}"
);
}
}
#[test] #[test]
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult { fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;
+81
View File
@@ -801,6 +801,87 @@ fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestR
Ok(()) Ok(())
} }
#[test]
fn mobile_otp_import_creates_or_appends_one_validated_field() -> TestResult {
use ironstorage::presentation::QrMatrix;
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let service = EntryDocumentService::new(&repository, &keys);
let uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example";
let mut document = service.open("otp/new-camera", &mut secrets)?;
document.add(0, EntryFieldDraft::line(Vec::new())?)?;
let mut draft = MobileEntryDraft::new(document, true);
let original = draft.document().serialize().expose().to_vec();
assert!(
draft
.add(MobileEntryEditorFieldKind::OtpUri, None, "invalid".into())
.is_err()
);
assert_eq!(draft.document().serialize().expose(), original);
assert!(
draft
.add_otp_image(SecretBytes::new(b"not an image".to_vec()))
.is_err()
);
assert_eq!(draft.document().serialize().expose(), original);
let matrix = QrMatrix::encode(&SecretBytes::new(uri.as_bytes().to_vec()))?;
let size = (matrix.width() as u32 + 8) * 8;
let image = image::GrayImage::from_fn(size, size, |x, y| {
let dark = x / 8 >= 4
&& y / 8 >= 4
&& matrix.is_dark((x / 8 - 4) as usize, (y / 8 - 4) as usize) == Some(true);
image::Luma([if dark { 0 } else { 255 }])
});
let mut encoded = std::io::Cursor::new(Vec::new());
image.write_to(&mut encoded, image::ImageFormat::Png)?;
draft.add_otp_image(SecretBytes::new(encoded.into_inner()))?;
assert_eq!(draft.page().fields().len(), 1);
assert_eq!(
draft.page().fields()[0].kind(),
MobileEntryEditorFieldKind::OtpUri
);
assert_eq!(draft.document().serialize().expose(), uri.as_bytes());
assert!(matches!(
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into()),
Err(MobileEntryEditorError::ExistingOtp)
));
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
let reopened = service.open("otp/new-camera", &mut secrets)?;
let otp = reopened.fields()[0].metadata().otp().expect("OTP metadata");
assert_eq!(otp.issuer(), Some("Example"));
assert_eq!(otp.account(), "alice");
write_plaintext(
&repository,
&keys,
"otp/existing",
b"keep-password\nusername: original\nnotes: keep notes\n",
)?;
let mut draft = MobileEntryDraft::new(service.open("otp/existing", &mut secrets)?, false);
let before = draft.document().serialize().expose().to_vec();
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into())?;
assert!(draft.document().serialize().expose().starts_with(&before));
assert_eq!(
draft.page().fields().last().unwrap().kind(),
MobileEntryEditorFieldKind::OtpUri
);
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
assert_eq!(
service
.open("otp/existing", &mut secrets)?
.fields()
.last()
.unwrap()
.value(),
uri.as_bytes()
);
Ok(())
}
#[test] #[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;
+190 -19
View File
@@ -12,12 +12,13 @@ use std::{
use ironstorage::{ use ironstorage::{
command::{InsertInput, InsertRequest}, command::{InsertInput, InsertRequest},
config::{Config, ConfigLoader, GitRemote}, config::{Config, ConfigLoader, GitRemote, SshFingerprint},
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{ git::{
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential, GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl, GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome, GitProgressPhase, GitRemoteCredentialProvider, GitRepository, GitSmartHttpTransport,
PullOutcome, SshPassphraseProvider,
}, },
repository::{Repository, SecretBytes}, repository::{Repository, SecretBytes},
write::{InsertContent, OverwriteDecision, VaultWriter}, write::{InsertContent, OverwriteDecision, VaultWriter},
@@ -98,6 +99,23 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
Ok(()) Ok(())
} }
#[test]
fn user_commit_stages_every_current_change_in_storage() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let git = GitRepository::init(&store, identity())?;
fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?;
fs::write(temporary.path().join("mail.gpg"), b"ciphertext")?;
let commit = git.commit_all("Save mobile changes.")?;
assert_eq!(commit.len(), 40);
assert!(git.status()?.is_clean());
assert_eq!(git.log(Some(1))?[0].message(), "Save mobile changes.");
assert_eq!(git.commit_all("Nothing changed"), Err(GitError::NoChanges));
Ok(())
}
#[test] #[test]
fn nested_repository_selection_is_innermost() -> TestResult { fn nested_repository_selection_is_innermost() -> TestResult {
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
@@ -120,13 +138,11 @@ fn nested_repository_selection_is_innermost() -> TestResult {
} }
#[test] #[test]
fn remotes_and_config_are_local_https_only() -> TestResult { fn remotes_and_config_reject_local_helper_and_credential_urls() -> TestResult {
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?; let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
for forbidden in [ for forbidden in [
"ssh://example.test/store.git",
"git@example.test:store.git",
"git://example.test/store.git", "git://example.test/store.git",
"file:///tmp/store.git", "file:///tmp/store.git",
"../store.git", "../store.git",
@@ -155,6 +171,57 @@ fn remotes_and_config_are_local_https_only() -> TestResult {
Ok(()) Ok(())
} }
#[cfg(not(feature = "ssh"))]
#[test]
fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
let remote = GitRemote::ssh("origin", "git@example.test:team/store.git")?;
let unsupported = GitError::UnsupportedRemoteTransport {
transport: ironstorage::config::RemoteTransport::Ssh,
};
assert_eq!(
git.add_remote(remote.name().as_str(), remote.url()),
Err(unsupported.clone())
);
assert!(git.remotes().is_empty());
assert_eq!(
git.config_set("remote.origin.url", remote.url()),
Err(unsupported.clone())
);
assert!(git.config_get("remote.origin.url")?.is_none());
assert_eq!(
GitRepository::discover_remote_branches_with_transport(
temporary.path(),
identity(),
&remote,
&Credentials,
&CloningFetch,
&GitOperationControl::default(),
),
Err(unsupported)
);
Ok(())
}
#[cfg(feature = "ssh")]
#[test]
fn ssh_feature_allows_both_remote_forms_through_add_set_and_get() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", "git@example.test:team/store.git")?;
assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git");
git.set_remote_url("origin", "ssh://git@example.test/team/store.git")?;
assert_eq!(
git.remote_url("origin")?,
"ssh://git@example.test/team/store.git"
);
Ok(())
}
struct Credentials; struct Credentials;
impl GitCredentialProvider for Credentials { impl GitCredentialProvider for Credentials {
@@ -169,6 +236,24 @@ impl GitCredentialProvider for Credentials {
} }
} }
impl SshPassphraseProvider for Credentials {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
Err(GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})
}
}
fn http_credential(
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<GitCredential, GitError> {
let (server, application) = configured
.https_credentials()
.ok_or(GitError::CredentialsUnavailable)?;
credentials.credential(server, application)
}
struct SigningSecret(Vec<u8>); struct SigningSecret(Vec<u8>);
impl SecretProvider for SigningSecret { impl SecretProvider for SigningSecret {
@@ -180,6 +265,7 @@ impl SecretProvider for SigningSecret {
#[derive(Default)] #[derive(Default)]
struct RecordingTransport { struct RecordingTransport {
request: Mutex<Vec<u8>>, request: Mutex<Vec<u8>>,
remote_tip: Option<String>,
} }
struct CloningFetch; struct CloningFetch;
@@ -188,9 +274,10 @@ impl GitFetchTransport for CloningFetch {
fn fetch( fn fetch(
&self, &self,
repository: &GitRepository, repository: &GitRepository,
_configured: &GitRemote, configured: &GitRemote,
credential: &GitCredential, credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> { ) -> Result<bool, GitError> {
let credential = http_credential(configured, credentials)?;
assert_eq!(credential.password(), b"token"); assert_eq!(credential.password(), b"token");
fs::write(repository.root().join(".gpg-id"), b"ALICE\n") fs::write(repository.root().join(".gpg-id"), b"ALICE\n")
.map_err(|error| GitError::InvalidRepository(error.to_string()))?; .map_err(|error| GitError::InvalidRepository(error.to_string()))?;
@@ -211,9 +298,9 @@ impl GitFetchTransport for OccupyingFetch {
&self, &self,
repository: &GitRepository, repository: &GitRepository,
configured: &GitRemote, configured: &GitRemote,
credential: &GitCredential, credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> { ) -> Result<bool, GitError> {
let fetched = CloningFetch.fetch(repository, configured, credential)?; let fetched = CloningFetch.fetch(repository, configured, credentials)?;
fs::write(self.0.join("keep"), b"concurrent contents") fs::write(self.0.join("keep"), b"concurrent contents")
.map_err(|error| GitError::InvalidRepository(error.to_string()))?; .map_err(|error| GitError::InvalidRepository(error.to_string()))?;
Ok(fetched) Ok(fetched)
@@ -226,9 +313,10 @@ impl GitFetchTransport for NoopFetch {
fn fetch( fn fetch(
&self, &self,
_repository: &GitRepository, _repository: &GitRepository,
_configured: &GitRemote, configured: &GitRemote,
credential: &GitCredential, credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> { ) -> Result<bool, GitError> {
let credential = http_credential(configured, credentials)?;
assert_eq!(credential.username(), "alice"); assert_eq!(credential.username(), "alice");
Ok(false) Ok(false)
} }
@@ -241,7 +329,7 @@ impl GitFetchTransport for FailedFetch {
&self, &self,
_repository: &GitRepository, _repository: &GitRepository,
_configured: &GitRemote, _configured: &GitRemote,
_credential: &GitCredential, _credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> { ) -> Result<bool, GitError> {
Err(self.0.clone()) Err(self.0.clone())
} }
@@ -304,7 +392,13 @@ impl GitSmartHttpTransport for RecordingTransport {
assert_eq!(credential.username(), "alice"); assert_eq!(credential.username(), "alice");
let mut output = packet(b"# service=git-receive-pack\n"); let mut output = packet(b"# service=git-receive-pack\n");
output.extend_from_slice(b"0000"); output.extend_from_slice(b"0000");
output.extend_from_slice(&packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs\n")); let reference = self.remote_tip.as_ref().map_or_else(
|| "0000000000000000000000000000000000000000 capabilities^{}".to_owned(),
|tip| format!("{tip} refs/heads/main"),
);
output.extend_from_slice(&packet(
format!("{reference}\0report-status delete-refs\n").as_bytes(),
));
output.extend_from_slice(b"0000"); output.extend_from_slice(b"0000");
Ok(output) Ok(output)
} }
@@ -330,7 +424,7 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
let remote: &GitRemote = &config.git_remotes()[0]; let remote: &GitRemote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?; fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
let head = git.commit("Add secret to store.")?; let head = git.commit("Add secret to store.")?;
@@ -357,6 +451,61 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
Ok(()) Ok(())
} }
#[test]
fn sync_pushes_move_and_move_back_even_when_the_final_tree_is_unchanged() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
let base = git.commit_all("Add secret")?;
set_remote_tracking(config.vault(), &base)?;
fs::rename(
config.vault().join("secret.gpg"),
config.vault().join("moved.gpg"),
)?;
git.commit_all("Move secret")?;
fs::rename(
config.vault().join("moved.gpg"),
config.vault().join("secret.gpg"),
)?;
let head = git.commit_all("Move secret back")?;
assert!(git.status()?.is_clean());
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 2);
let transport = RecordingTransport {
remote_tip: Some(base.clone()),
..Default::default()
};
let (pulled, pushed) = git.sync_with_transports_controlled(
remote,
&Credentials,
&NoopFetch,
&transport,
&GitOperationControl::default(),
)?;
assert_eq!(pulled, PullOutcome::UpToDate);
assert_eq!(pushed.new_id(), head);
let request = transport.request.lock().expect("request lock");
assert!(
request
.windows(base.len())
.any(|bytes| bytes == base.as_bytes())
);
let offset = request
.windows(4)
.position(|bytes| bytes == b"PACK")
.expect("pack");
// Two commits and the intermediate tree; the remote already has the final tree and blob.
assert_eq!(
u32::from_be_bytes(request[offset + 8..offset + 12].try_into()?),
3
);
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 0);
Ok(())
}
#[test] #[test]
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult { fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult {
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
@@ -364,7 +513,7 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
let head = git.log(Some(1))?[0].id().to_owned(); let head = git.log(Some(1))?[0].id().to_owned();
set_remote_tracking(config.vault(), &head)?; set_remote_tracking(config.vault(), &head)?;
let phases = Arc::new(Mutex::new(Vec::new())); let phases = Arc::new(Mutex::new(Vec::new()));
@@ -382,6 +531,20 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
assert!(phases.contains(&GitProgressPhase::Integrating)); assert!(phases.contains(&GitProgressPhase::Integrating));
assert!(phases.contains(&GitProgressPhase::Sending)); assert!(phases.contains(&GitProgressPhase::Sending));
assert!(!push.request.lock().expect("push request").is_empty()); assert!(!push.request.lock().expect("push request").is_empty());
for error in [GitError::NetworkUnavailable, GitError::AuthenticationFailed] {
let push = RecordingTransport::default();
assert_eq!(
git.sync_with_transports_controlled(
remote,
&Credentials,
&FailedFetch(error.clone()),
&push,
&GitOperationControl::default(),
),
Err(error),
);
assert!(push.request.lock().expect("push request").is_empty());
}
Ok(()) Ok(())
} }
@@ -392,7 +555,7 @@ fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
assert_eq!( assert_eq!(
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure), git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
Err(GitError::AuthenticationFailed) Err(GitError::AuthenticationFailed)
@@ -416,7 +579,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"base")?; fs::write(config.vault().join("secret.gpg"), b"base")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
@@ -437,9 +600,17 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
fs::write(config.vault().join("secret.gpg"), b"local")?; fs::write(config.vault().join("secret.gpg"), b"local")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
git.commit("Local change")?; git.commit("Local change")?;
let push = RecordingTransport::default();
let error = git let error = git
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch) .sync_with_transports_controlled(
remote,
&Credentials,
&NoopFetch,
&push,
&GitOperationControl::default(),
)
.expect_err("conflicting histories"); .expect_err("conflicting histories");
assert!(push.request.lock().expect("push request").is_empty());
let GitError::MergeConflicts { conflicts } = error else { let GitError::MergeConflicts { conflicts } = error else {
panic!("expected typed merge conflicts"); panic!("expected typed merge conflicts");
}; };
@@ -494,7 +665,7 @@ fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
let control = GitOperationControl::default(); let control = GitOperationControl::default();
control.cancel(); control.cancel();
assert_eq!( assert_eq!(
+18 -1
View File
@@ -12,8 +12,8 @@ use ironstorage::{
document::EntryDocumentService, document::EntryDocumentService,
mobile_totp::{ mobile_totp::{
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
MobileWatchSnapshotState,
}, },
mobile_watch::MobileWatchSnapshotState,
otp::OtpError, otp::OtpError,
recipient::RecipientPolicyManager, recipient::RecipientPolicyManager,
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
@@ -233,6 +233,23 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
.rows(), .rows(),
page.rows() page.rows()
); );
assert_eq!(
service
.search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ")
.expect("created cache")
.rows()
.iter()
.map(|row| row.path())
.collect::<Vec<_>>(),
["team-a/shared", "team-a"]
);
assert!(
service
.search_cached_page(&BTreeSet::new(), &cache, "missing")
.expect("created cache")
.rows()
.is_empty()
);
write_plaintext( write_plaintext(
&repository, &repository,
+447
View File
@@ -0,0 +1,447 @@
#![forbid(unsafe_code)]
use std::fs;
use ironstorage::{
mobile_watch::{
MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime,
WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
},
otp::OtpAlgorithm,
repository::{EntryPath, SecretBytes},
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn entry(path: &str, issuer: &str, account: &str, secret: &[u8]) -> WatchSnapshotEntry {
WatchSnapshotEntry::new(
EntryPath::parse(path).expect("fixture path").to_string(),
Some(issuer.to_owned()),
account.to_owned(),
OtpAlgorithm::Sha256,
8,
30,
SecretBytes::new(secret.to_vec()),
)
}
#[test]
fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestResult {
let directory = tempfile::tempdir()?;
let journal = directory.path().join("watch-snapshot.toml");
let mut sender = WatchSnapshotSender::load(journal.clone());
let first = sender.prepare(
"paired-watch-a",
vec![entry("otp/alice", "Acme", "alice", b"first-secret")],
)?;
assert_eq!(first.revision(), 1);
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
let mut receiver = WatchSnapshotReceiver::default();
assert_eq!(
receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Replaced
);
assert_eq!(receiver.current().expect("snapshot").entries().len(), 1);
assert_eq!(
sender.acknowledge(first.delivered_receipt())?.state(),
MobileWatchSnapshotState::Delivered
);
assert_eq!(
sender
.acknowledge(&receiver.current_receipt().expect("accepted receipt"))?
.state(),
MobileWatchSnapshotState::Current
);
let duplicate = sender.prepare(
"paired-watch-a",
vec![entry("otp/alice", "Acme", "alice", b"first-secret")],
)?;
assert_eq!(duplicate.revision(), 1);
assert_eq!(duplicate.snapshot().expose(), first.snapshot().expose());
assert_eq!(
receiver.apply(SecretBytes::new(duplicate.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Duplicate
);
let replacement = sender.prepare(
"paired-watch-a",
vec![entry("otp/bob", "Acme", "bob", b"second-secret")],
)?;
assert_eq!(replacement.revision(), 2);
assert_eq!(
receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Replaced
);
assert_eq!(
receiver.current().expect("replacement").entries()[0].account(),
"bob"
);
assert_eq!(
receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
assert_eq!(
receiver.current().expect("stale ignored").entries()[0].account(),
"bob"
);
let revoked = sender.prepare("paired-watch-a", Vec::new())?;
assert_eq!(revoked.revision(), 3);
assert_eq!(
receiver.apply(SecretBytes::new(revoked.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Revoked
);
assert!(
receiver
.current()
.expect("revocation marker")
.is_revocation()
);
assert_eq!(
receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
let reset_journal = directory.path().join("reset-watch-snapshot.toml");
let mut reset_sender = WatchSnapshotSender::load(reset_journal);
let reset_revocation = reset_sender.prepare("paired-watch-a", Vec::new())?;
assert_eq!(reset_revocation.revision(), 1);
assert_eq!(
receiver.apply(SecretBytes::new(
reset_revocation.snapshot().expose().to_vec()
))?,
WatchSnapshotApply::Stale
);
assert_eq!(
receiver.replace_authoritative(SecretBytes::new(
reset_revocation.snapshot().expose().to_vec()
))?,
WatchSnapshotApply::Revoked
);
assert!(
receiver
.current()
.expect("reset revocation")
.is_revocation()
);
let after_reset = reset_sender.prepare(
"paired-watch-a",
vec![entry("otp/reset", "Acme", "reset", b"reset-secret")],
)?;
assert_eq!(after_reset.revision(), 2);
assert_eq!(
receiver.apply(SecretBytes::new(after_reset.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Replaced
);
let changed_watch = sender.prepare(
"paired-watch-b",
vec![entry("otp/carol", "Acme", "carol", b"third-secret")],
)?;
assert_eq!(changed_watch.revision(), 1);
assert_eq!(
receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
assert_eq!(
receiver
.replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::PairingChanged
);
assert_eq!(receiver.current().expect("new pairing").revision(), 1);
let mut fresh_watch = WatchSnapshotReceiver::default();
assert_eq!(
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Replaced
);
let mut damaged = changed_watch.snapshot().expose().to_vec();
damaged[20] ^= 0x55;
assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err());
assert_eq!(
fresh_watch
.current()
.expect("last valid snapshot")
.entries()[0]
.account(),
"carol"
);
assert_eq!(
fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
assert_eq!(
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Duplicate
);
let persisted = fs::read_to_string(journal)?;
for forbidden in [
"first-secret",
"second-secret",
"third-secret",
"reset-secret",
"otpauth://",
"94287082",
] {
assert!(!persisted.contains(forbidden), "journal leaked {forbidden}");
}
Ok(())
}
#[test]
fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult {
let directory = tempfile::tempdir()?;
let journal = directory.path().join("watch-snapshot.toml");
let first = WatchSnapshotSender::load(journal.clone()).prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"secret")],
)?;
assert_eq!(first.revision(), 1);
let same = WatchSnapshotSender::load(journal.clone()).prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"secret")],
)?;
assert_eq!(same.revision(), 1);
let changed = WatchSnapshotSender::load(journal).prepare("paired-watch", Vec::new())?;
assert_eq!(changed.revision(), 2);
Ok(())
}
#[test]
fn journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult {
let directory = tempfile::tempdir()?;
let journal = directory.path().join("watch-snapshot.toml");
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
let legacy = fs::read_to_string(&journal)?
.lines()
.filter(|line| !line.starts_with("generation = "))
.collect::<Vec<_>>()
.join("\n")
.replace("revision = 1", "revision = 42");
fs::write(&journal, legacy)?;
let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
assert_eq!(reset.revision(), 1);
let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?;
assert_eq!(reloaded.revision(), 1);
assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose());
Ok(())
}
#[test]
fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult {
let directory = tempfile::tempdir()?;
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
let transfer = sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"secret")],
)?;
let persisted_snapshot = transfer.snapshot().expose().to_vec();
let mut interrupted_runtime = WatchRuntime::default();
let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?;
assert_eq!(applied.persistence(), WatchPersistenceAction::Replace);
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
let mut restarted_runtime = WatchRuntime::default();
restarted_runtime.apply_snapshot(persisted_snapshot.clone())?;
let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?;
assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate);
assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep);
assert_eq!(
sender.acknowledge(redelivered.receipt())?.state(),
MobileWatchSnapshotState::Current
);
Ok(())
}
#[test]
fn confirmed_unchanged_snapshot_stays_current_when_republished() -> TestResult {
let directory = tempfile::tempdir()?;
let journal = directory.path().join("watch-snapshot.toml");
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
let mut sender = WatchSnapshotSender::load(journal.clone());
let first = sender.prepare("paired-watch", entries())?;
let receipt = WatchRuntime::default()
.apply_snapshot(first.snapshot().expose().to_vec())?
.receipt()
.to_vec();
assert_eq!(
sender.acknowledge(&receipt)?.state(),
MobileWatchSnapshotState::Current
);
assert_eq!(sender.prepare("paired-watch", entries())?.revision(), 1);
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Current);
let mut reloaded = WatchSnapshotSender::load(journal);
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
reloaded.prepare("paired-watch", entries())?;
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
assert_eq!(reloaded.prepare("paired-watch", Vec::new())?.revision(), 2);
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Pending);
Ok(())
}
#[test]
fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult {
let directory = tempfile::tempdir()?;
let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml"));
old_sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"old-secret")],
)?;
for revision in 2..5 {
old_sender.prepare(
"paired-watch",
vec![entry(
"otp/alice",
"Acme",
&format!("alice-{revision}"),
b"old-secret",
)],
)?;
}
let old = old_sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")],
)?;
assert_eq!(old.revision(), 5);
let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml"));
let new = new_sender.prepare(
"paired-watch",
vec![entry("otp/bob", "Acme", "bob", b"new-secret")],
)?;
assert_eq!(new.revision(), 1);
let mut runtime = WatchRuntime::default();
runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale);
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Ready
);
assert_eq!(runtime.records_at(59)?[0].account(), "alice-5");
let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged);
assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace);
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
assert_eq!(
new_sender.acknowledge(replaced.receipt())?.state(),
MobileWatchSnapshotState::Current
);
let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale);
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
let newer = new_sender.prepare(
"paired-watch",
vec![entry("otp/carol", "Acme", "carol", b"newer-secret")],
)?;
runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?;
let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale);
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
);
runtime.sync_unavailable();
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
Ok(())
}
#[test]
fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult {
let directory = tempfile::tempdir()?;
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
let snapshot = sender.prepare(
"paired-watch",
vec![WatchSnapshotEntry::new(
"otp/alice".to_owned(),
Some("Acme".to_owned()),
"alice".to_owned(),
OtpAlgorithm::Sha1,
8,
30,
SecretBytes::new(b"12345678901234567890".to_vec()),
)],
)?;
let mut runtime = WatchRuntime::default();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Syncing
);
runtime.sync_finished();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.sync_started();
runtime.sync_unavailable();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Unavailable
);
runtime.sync_started();
let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(update.apply(), WatchSnapshotApply::Replaced);
assert_eq!(update.persistence(), WatchPersistenceAction::Replace);
assert_eq!(update.selected_entries(), 1);
assert!(!update.receipt().is_empty());
let records = runtime.records_at(59)?;
assert_eq!(records.len(), 1);
assert_eq!(records[0].path(), "otp/alice");
assert_eq!(records[0].code().expose(), b"94287082");
assert_eq!(records[0].valid_until(), 60);
assert_eq!(records[0].remaining_at(59), 1);
let presentation = runtime.presentation_at(59)?;
assert_eq!(presentation.state(), WatchPresentationState::Ready);
assert_eq!(presentation.records()[0].remaining(), 1);
runtime.protected_data_unavailable();
assert!(runtime.records_at(59).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Locked
);
let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(restored.persistence(), WatchPersistenceAction::Replace);
assert_eq!(runtime.records_at(59)?[0].code().expose(), b"94287082");
let revocation = sender.prepare("paired-watch", Vec::new())?;
let revoked = runtime.apply_snapshot(revocation.snapshot().expose().to_vec())?;
assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked);
assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete);
assert!(runtime.records_at(59)?.is_empty());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
);
Ok(())
}
+6
View File
@@ -260,6 +260,12 @@ fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResul
image.write_to(&mut encoded, image::ImageFormat::Png)?; image.write_to(&mut encoded, image::ImageFormat::Png)?;
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?; let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
assert_eq!(decoded.expose(), payload.expose()); assert_eq!(decoded.expose(), payload.expose());
let mut oversized = Cursor::new(Vec::new());
image::GrayImage::new(4097, 1).write_to(&mut oversized, image::ImageFormat::Png)?;
assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(oversized.into_inner())),
Err(QrError::InvalidImage)
));
assert!(matches!( assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())), QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
Err(QrError::InvalidImage) Err(QrError::InvalidImage)
+150 -10
View File
@@ -11,9 +11,9 @@ use std::{
}; };
use ironstorage::{ use ironstorage::{
config::ConfigLoader, config::{ConfigLoader, GitRemote, SshFingerprint},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
git::{GitCredentialProvider as _, GitError}, git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
repository::{EncryptedEntry, SecretBytes}, repository::{EncryptedEntry, SecretBytes},
secret_store::{ secret_store::{
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
@@ -32,6 +32,8 @@ struct MemoryState {
create_fault: Option<SecretStoreError>, create_fault: Option<SecretStoreError>,
retrieves: usize, retrieves: usize,
protections: Vec<SecretProtection>, protections: Vec<SecretProtection>,
platform_https: Option<(String, SecretBytes)>,
platform_https_requests: Vec<String>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -60,6 +62,13 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").protections.clone() self.0.lock().expect("test mutex").protections.clone()
} }
fn with_platform_https(account: &str, password: &[u8]) -> Self {
let backend = Self::default();
backend.0.lock().expect("test mutex").platform_https =
Some((account.to_owned(), SecretBytes::new(password.to_vec())));
backend
}
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> { fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
match state.fault.take() { match state.fault.take() {
Some(error) => Err(error), Some(error) => Err(error),
@@ -138,6 +147,22 @@ impl SecretStoreBackend for MemoryBackend {
.ok_or(SecretStoreError::Missing) .ok_or(SecretStoreError::Missing)
} }
fn platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
state
.platform_https_requests
.push(endpoint.as_str().to_owned());
Ok(state.platform_https.as_ref().map(|(account, password)| {
(
account.clone(),
SecretBytes::new(password.expose().to_vec()),
)
}))
}
fn lock(&self) -> Result<(), SecretStoreError> { fn lock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state) Self::take_fault(&mut state)
@@ -149,6 +174,42 @@ impl SecretStoreBackend for MemoryBackend {
} }
} }
#[test]
fn endpoint_lookup_uses_the_native_https_credential_before_private_storage() -> TestResult {
let backend = MemoryBackend::with_platform_https("alice", b"native-token");
let store = store(backend.clone());
store.unlock()?;
let remote = GitRemote::https(
"origin",
"https://example.test/team/store.git",
"server",
"application",
)?;
let (server, application) = remote.https_credentials().expect("HTTPS credentials");
store.store_https_git_credential(
server,
application,
"private-account",
SecretBytes::new(b"private-token".to_vec()),
)?;
let retrieves_before = backend.retrieves();
let credential = store.credential_for(&remote)?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"native-token");
assert_eq!(backend.retrieves(), retrieves_before);
assert_eq!(
backend
.0
.lock()
.expect("test mutex")
.platform_https_requests,
["https://example.test/team/store.git".to_owned()]
);
Ok(())
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>); struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>);
@@ -274,6 +335,49 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe
Ok(()) Ok(())
} }
#[test]
fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
let backend = MemoryBackend::default();
let store = store(backend.clone());
store.unlock()?;
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
store.create(
&reference,
SecretBytes::new(b"protected-passphrase".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"protected-passphrase"
);
store.persist_verified_ssh_passphrase(
&fingerprint,
SecretBytes::new(b"verified-replacement".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"verified-replacement"
);
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
));
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
if cancelled == fingerprint
));
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
assert!(matches!(
store.ssh_key_passphrase(&missing),
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
));
assert!(!format!("{reference:?}").contains("protected-passphrase"));
Ok(())
}
#[test] #[test]
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult { fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
let backend = MemoryBackend::default(); let backend = MemoryBackend::default();
@@ -325,6 +429,41 @@ fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResu
Ok(()) Ok(())
} }
#[test]
fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
let backend = MemoryBackend::default();
let store = SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
);
let remote = GitRemote::https(
"origin",
"https://git.example.test/alice/store.git",
"personal-git",
"ironstorage-mobile",
)?;
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
store.unlock()?;
store.store_https_git_credential(
server_id,
application_id,
"alice",
SecretBytes::new(b"private-token".to_vec()),
)?;
assert_eq!(
store.https_git_credential_account(server_id, application_id)?,
"alice"
);
store.delete_https_git_credential(server_id, application_id)?;
assert!(matches!(
store.https_git_credential_account(server_id, application_id),
Err(SecretStoreError::Missing)
));
assert!(!format!("{store:?}").contains("private-token"));
Ok(())
}
#[test] #[test]
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult { fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;
@@ -362,28 +501,29 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native")) let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
.load(Some(&temporary.path().join("config.toml")))?; .load(Some(&temporary.path().join("config.toml")))?;
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
backend.fail_next(SecretStoreError::Cancelled); backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!( assert!(matches!(
store.credential(remote.server_id(), remote.application_id()), store.credential(server_id, application_id),
Err(GitError::CredentialCancelled) Err(GitError::CredentialCancelled)
)); ));
backend.fail_next(SecretStoreError::Denied); backend.fail_next(SecretStoreError::Denied);
assert!(matches!( assert!(matches!(
store.credential(remote.server_id(), remote.application_id()), store.credential(server_id, application_id),
Err(GitError::CredentialAccessDenied) Err(GitError::CredentialAccessDenied)
)); ));
let git = SecretReference::https_git_credential( let git = SecretReference::https_git_credential(
remote.server_id().as_str(), server_id.as_str(),
remote.application_id().as_str(), application_id.as_str(),
"alice", "alice",
)?; )?;
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?; store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
let credential = store.credential(remote.server_id(), remote.application_id())?; let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "alice"); assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"https-token"); assert_eq!(credential.password(), b"https-token");
store.store_https_git_credential( store.store_https_git_credential(
remote.server_id(), server_id,
remote.application_id(), application_id,
"bob", "bob",
SecretBytes::new(b"replacement-token".to_vec()), SecretBytes::new(b"replacement-token".to_vec()),
)?; )?;
@@ -391,7 +531,7 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
store.retrieve(&git), store.retrieve(&git),
Err(SecretStoreError::Missing) Err(SecretStoreError::Missing)
)); ));
let credential = store.credential(remote.server_id(), remote.application_id())?; let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "bob"); assert_eq!(credential.username(), "bob");
assert_eq!(credential.password(), b"replacement-token"); assert_eq!(credential.password(), b"replacement-token");
assert!( assert!(
+55
View File
@@ -0,0 +1,55 @@
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
const SSH_SOURCE: &str = include_str!("../src/ssh.rs");
const GIT_SOURCE: &str = include_str!("../src/git.rs");
const AUDIT: &str = include_str!("../../../docs/ssh-transport-audit.md");
const AUDIT_WORKFLOW: &str = include_str!("../../../.gitea/workflows/security-audit.yml");
#[test]
fn release_feature_and_advisory_boundaries_stay_explicit() {
assert!(WORKSPACE.contains(
"russh = { version = \"=0.63.1\", default-features = false, features = [\"ring\", \"rsa\"] }"
));
assert!(STORAGE_MANIFEST.contains("ssh = [\"dep:russh\", \"dep:tokio\"]"));
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
assert!(manifest.contains("features = [\"ssh\"]"));
}
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
assert!(!AUDIT_WORKFLOW.contains("cargo install"));
assert!(AUDIT_WORKFLOW.contains("run: cargo-audit audit --ignore RUSTSEC-2023-0071"));
assert!(AUDIT.contains("RUSTSEC-2023-0071"));
}
#[test]
fn production_ssh_transport_has_no_process_unsafe_or_proxy_escape_hatch() {
let process_command = ["process", "::Command"].concat();
let command_constructor = ["Command", "::new("].concat();
let unsafe_block = ["unsafe", " {"].concat();
for (name, source) in [("ssh.rs", SSH_SOURCE), ("git.rs", GIT_SOURCE)] {
let production = source.split("#[cfg(test)]").next().unwrap_or(source);
for forbidden in [
process_command.as_str(),
command_constructor.as_str(),
unsafe_block.as_str(),
"ProxyCommand",
"proxy_command",
"russh_config",
] {
assert!(
!production.contains(forbidden),
"{name} contains forbidden production token {forbidden}"
);
}
}
assert!(SSH_SOURCE.contains("check_server_key"));
assert!(SSH_SOURCE.contains("verify_known_host"));
assert!(SSH_SOURCE.contains("MAX_SSH_DIAGNOSTIC_BYTES"));
assert!(SSH_SOURCE.contains("MAX_CHANNEL_CHUNK"));
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more