Skip to content

Commit c99c666

Browse files
oratisclaude
andauthored
feat(linux): opt-in headless token-file fallback (#203)
* ci(release): publish Win/Linux installers on tag (decoupled from macOS) Adds build-desktop (Linux deb+appimage, Windows nsis) + release-desktop jobs that attach the installers to the same GitHub Release on every v* tag. The build step is identical to the proven cross-platform-bundle spike. Decoupled from the macOS build/release jobs (mirrors the `mas` job): a flaky AppImage/NSIS build can't block a macOS release and vice versa. Unsigned today (Windows cert is the owner's call) and no auto-updater (Win/Linux updates are manual re-downloads) — both documented inline + in the hardening doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(linux): opt-in headless token-file fallback when no Secret Service Linux Secret Service needs a running keyring daemon, which headless/server boxes lack. keyring errors now carry an actionable hint, and setting MARKUP_TOKEN_FILE_FALLBACK=1 stores the GitHub token in a 0600 file under $XDG_DATA_HOME instead. Opt-in on purpose — never a silent downgrade to on-disk storage. Weaker than the keyring but still keeps the token out of the webview (the original threat); roundtrip + 0600 perms unit-tested (runs on the macOS CI host via cfg(unix)). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6e11420 commit c99c666

3 files changed

Lines changed: 303 additions & 24 deletions

File tree

.github/workflows/release.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ env:
1616
# signed + notarized when the Developer ID secrets are populated,
1717
# unsigned when they're missing.
1818
#
19+
# It also produces Windows + Linux installers (build-desktop → release-desktop):
20+
# Linux .deb + .AppImage and a Windows NSIS .exe, appended to the same GitHub
21+
# Release. These are UNSIGNED today (Windows code-signing needs a cert — see the
22+
# build-desktop job) and ship WITHOUT the auto-updater (the updater latest.json
23+
# stays macOS-only; Win/Linux users update by re-downloading). The desktop jobs
24+
# are decoupled from the macOS build/release jobs — a Win/Linux failure can't
25+
# block a macOS release, and vice versa.
26+
#
1927
# Required secrets for signing (all six must be set, gated on
2028
# APPLE_TEAM_ID since it's the last to provision):
2129
# APPLE_CERTIFICATE_BASE64 .p12 with the Developer ID Application
@@ -234,6 +242,88 @@ jobs:
234242
retention-days: 7
235243
if-no-files-found: error
236244

245+
# Windows + Linux installers. Decoupled from the macOS build/release jobs (it
246+
# neither blocks nor is blocked by them), so a flaky AppImage/NSIS build can't
247+
# break a macOS release. UNSIGNED today:
248+
# • Windows: SmartScreen warns until a code-signing cert is wired. To sign,
249+
# add the cert to tauri.conf.json → bundle.windows (certificateThumbprint +
250+
# timestampUrl, or a signCommand / Azure Trusted Signing) and pass its
251+
# secrets here — mirror the HAS_*_SIGNING gate idiom used by the mac jobs.
252+
# • Linux: .deb/.AppImage are conventionally unsigned; the repo is the trust
253+
# anchor.
254+
# No auto-updater artifacts (createUpdaterArtifacts is unset) — Win/Linux
255+
# updates are manual re-downloads for now (docs/CROSS-PLATFORM-HARDENING.md P2).
256+
build-desktop:
257+
name: Build installers (${{ matrix.os }})
258+
runs-on: ${{ matrix.os }}
259+
timeout-minutes: 90
260+
strategy:
261+
fail-fast: false
262+
matrix:
263+
include:
264+
- os: ubuntu-latest
265+
bundles: deb,appimage
266+
- os: windows-latest
267+
bundles: nsis
268+
steps:
269+
- uses: actions/checkout@v6
270+
271+
- uses: pnpm/action-setup@v6
272+
with:
273+
version: 10
274+
275+
- uses: actions/setup-node@v6
276+
with:
277+
node-version: 22
278+
cache: pnpm
279+
280+
# Linux: WebKitGTK toolchain + libfuse2 (AppImage runtime needs FUSE 2).
281+
- name: Install Linux system deps
282+
if: matrix.os == 'ubuntu-latest'
283+
run: |
284+
sudo apt-get update
285+
sudo apt-get install -y \
286+
libwebkit2gtk-4.1-dev \
287+
libappindicator3-dev \
288+
librsvg2-dev \
289+
patchelf \
290+
libxdo-dev \
291+
libssl-dev \
292+
libfuse2
293+
294+
- uses: dtolnay/rust-toolchain@stable
295+
296+
- uses: Swatinem/rust-cache@v2
297+
with:
298+
workspaces: src-tauri
299+
key: ${{ matrix.os }}
300+
301+
- name: Install
302+
run: pnpm install --frozen-lockfile
303+
304+
# `--bundles` overrides the macOS-only config targets; createUpdaterArtifacts
305+
# is unset so this needs no signing key.
306+
- name: Build installers (${{ matrix.bundles }})
307+
run: pnpm tauri build --bundles ${{ matrix.bundles }}
308+
309+
- name: Stage installers
310+
shell: bash
311+
run: |
312+
set -euo pipefail
313+
mkdir -p staged
314+
find src-tauri/target/release/bundle -maxdepth 2 \
315+
\( -name '*.deb' -o -name '*.AppImage' -o -name '*.exe' \) \
316+
-exec cp {} staged/ \;
317+
ls -lh staged/
318+
319+
- name: Upload artifacts
320+
uses: actions/upload-artifact@v7
321+
with:
322+
name: desktop-${{ matrix.os }}
323+
path: staged/*
324+
retention-days: 7
325+
if-no-files-found: error
326+
237327
# Mac App Store flavor: a sandboxed, Apple-Distribution-signed .pkg built by
238328
# scripts/build-mas.sh. Independent of the DMG build/release jobs (it neither
239329
# blocks nor is blocked by them), so a misconfig here can't break direct
@@ -397,3 +487,41 @@ jobs:
397487
dmg-staging/*.app.tar.gz
398488
dmg-staging/latest.json
399489
dmg-staging/SHA256SUMS
490+
491+
# Attach the Windows/Linux installers to the same GitHub Release. Separate from
492+
# the macOS `release` job (which owns release-notes generation) so the two
493+
# platforms can't block each other; action-gh-release upserts by tag, so this
494+
# just adds files to the release the macOS job creates (or creates it if this
495+
# wins the race — both are idempotent on the tag).
496+
release-desktop:
497+
needs: build-desktop
498+
runs-on: ubuntu-latest
499+
timeout-minutes: 15
500+
steps:
501+
- name: Download desktop installer artifacts
502+
uses: actions/download-artifact@v8
503+
with:
504+
pattern: desktop-*
505+
path: desktop-staging
506+
merge-multiple: true
507+
508+
- name: Compute SHA256SUMS (desktop)
509+
run: |
510+
set -euo pipefail
511+
cd desktop-staging
512+
shopt -s nullglob
513+
ls -lh
514+
shasum -a 256 *.deb *.AppImage *.exe > SHA256SUMS-desktop
515+
cat SHA256SUMS-desktop
516+
517+
- name: Add installers to the GitHub Release
518+
uses: softprops/action-gh-release@v3
519+
with:
520+
name: ${{ github.ref_name }}
521+
tag_name: ${{ github.ref_name }}
522+
fail_on_unmatched_files: false
523+
files: |
524+
desktop-staging/*.deb
525+
desktop-staging/*.AppImage
526+
desktop-staging/*.exe
527+
desktop-staging/SHA256SUMS-desktop

docs/CROSS-PLATFORM-HARDENING.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ Ordered by user-visible impact.
3636
`PendingOpenFiles` / `"open-files"` path. Double-clicking a `.md` should focus
3737
the running window and open the file. **Still needs a manual runtime check on
3838
real Windows/Linux** (CI only proves it compiles).
39-
2. **✅ GitHub token credential store***done (resolution-verified).* `keyring`
40-
is now declared per target: `apple-native` (macOS), `windows-native`
41-
(Windows Credential Manager), `sync-secret-service` + `crypto-rust` (Linux
42-
Secret Service via `dbus-secret-service`, pure-Rust crypto). ⚠️ Linux Secret
43-
Service needs a running keyring daemon — **headless/server fallback still
44-
undecided** (e.g. detect-and-warn, or an encrypted-file backend).
39+
2. **✅ GitHub token credential store + headless fallback***done.* `keyring`
40+
is declared per target: `apple-native` (macOS), `windows-native` (Windows
41+
Credential Manager), `sync-secret-service` + `crypto-rust` (Linux Secret
42+
Service via `dbus-secret-service`, pure-Rust crypto). **Headless Linux**
43+
(no Secret Service daemon): keyring errors now carry an actionable hint, and
44+
a user on a trusted box can opt in with **`MARKUP_TOKEN_FILE_FALLBACK=1`** to
45+
store the token in a `0600` file under `$XDG_DATA_HOME` (`token_store.rs`).
46+
Opt-in on purpose — we never silently downgrade to on-disk storage. It's
47+
weaker than the keyring but still keeps the token out of the webview (the
48+
threat the keyring move addressed); roundtrip + `0600` perms unit-tested.
4549

4650
### P2 — packaging & distribution (can't ship without)
4751

@@ -53,19 +57,20 @@ Ordered by user-visible impact.
5357
(`libfuse2` needed on the runner for AppImage)
5458
- Windows → `Markup_1.0.1_x64-setup.exe` (NSIS)
5559

56-
No bundler errors. **Remaining:** decide whether to bake these targets into
57-
`tauri.conf.json` per-OS (vs CLI `--bundles`) and wire them into a real
58-
release pipeline (today `release.yml` is macOS-only). `flatpak` / `msi` still
59-
optional/unbuilt.
60+
No bundler errors. **Now wired into `release.yml`** — the `build-desktop` +
61+
`release-desktop` jobs build these on every `v*` tag and attach them to the
62+
GitHub Release, decoupled from the macOS jobs (a Win/Linux failure can't block
63+
a macOS release). `flatpak` / `msi` still optional/unbuilt.
6064
4. **Code signing — owner's call.** The installers above are **unsigned**:
6165
Windows needs a **code-signing certificate** (EV or OV) or SmartScreen warns
6266
on every download; Linux AppImage/flatpak signing is lighter. Budget +
6367
procure the Windows cert (the real cost flagged in GTM §3).
64-
5. **Updater per-platform.** The updater endpoint (`latest.json`) and signed
65-
artifacts are currently macOS-only. Extend the release pipeline
66-
(`.github/workflows/release.yml`) to build, sign, and publish Win/Linux
67-
updater artifacts, or scope the updater to macOS and document manual updates
68-
elsewhere.
68+
5. **Updater scoped to macOS (decided).** `latest.json` + signed updater
69+
artifacts stay macOS-only; Win/Linux ship installers **without** the
70+
auto-updater, so those users update by re-downloading. Adding a Win/Linux
71+
updater later means producing signed `createUpdaterArtifacts` bundles (needs
72+
the Windows cert from item 4) and extending `latest.json` with
73+
`windows-x86_64` / `linux-x86_64` entries.
6974

7075
### P3 — verify behaviour on the real webviews (manual, can't be done in headless CI)
7176

src-tauri/src/token_store.rs

Lines changed: 155 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1-
//! GitHub access-token storage in the macOS login Keychain.
1+
//! GitHub access-token storage in the OS credential store.
22
//!
33
//! The desktop frontend used to keep the OAuth token in the webview's
44
//! `localStorage`, where any script running in the webview could read it.
5-
//! These commands move it into the Keychain (via the `keyring` crate's
6-
//! Security-framework backend), keyed by the app's bundle identifier. The
7-
//! frontend mirrors the value in a synchronous in-memory cache and calls
8-
//! these to hydrate / persist it.
5+
//! These commands move it into the platform credential store (via the
6+
//! `keyring` crate — Keychain on macOS, Credential Manager on Windows, Secret
7+
//! Service on Linux), keyed by the app's bundle identifier. The frontend
8+
//! mirrors the value in a synchronous in-memory cache and calls these to
9+
//! hydrate / persist it.
10+
//!
11+
//! ## Linux headless fallback
12+
//!
13+
//! Secret Service needs a running keyring daemon (gnome-keyring / KWallet),
14+
//! which a headless or server box may not have. There, a user on a trusted
15+
//! single-user machine can opt in with `MARKUP_TOKEN_FILE_FALLBACK=1` to store
16+
//! the token in a `0600` file under `$XDG_DATA_HOME` instead. This is **weaker
17+
//! than the system keyring** (a local process running as the same user can read
18+
//! the file), but it still keeps the token out of the webview — the threat the
19+
//! keyring move was made to address — and unblocks GitHub features. It is
20+
//! **opt-in on purpose**: we never silently downgrade to on-disk storage.
921
1022
use keyring::{Entry, Error as KeyringError};
1123

@@ -15,30 +27,164 @@ const SERVICE: &str = "com.appkon.markup";
1527
const ACCOUNT: &str = "github-access-token";
1628

1729
fn entry() -> Result<Entry, String> {
18-
Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string())
30+
Entry::new(SERVICE, ACCOUNT).map_err(annotate)
31+
}
32+
33+
/// On Linux, append an actionable hint about the headless file fallback to
34+
/// keyring errors (the common cause is "no Secret Service daemon running").
35+
/// A no-op on macOS/Windows.
36+
#[cfg(target_os = "linux")]
37+
fn annotate(e: impl std::fmt::Display) -> String {
38+
format!(
39+
"{e}. If this machine has no Secret Service keyring (headless/server), \
40+
set MARKUP_TOKEN_FILE_FALLBACK=1 to store the token in a 0600 file under \
41+
$XDG_DATA_HOME (less secure than the system keyring)."
42+
)
43+
}
44+
45+
#[cfg(not(target_os = "linux"))]
46+
fn annotate(e: impl std::fmt::Display) -> String {
47+
e.to_string()
1948
}
2049

2150
/// Read the stored token, or `None` when nothing is saved.
2251
#[tauri::command]
2352
pub fn github_token_load() -> Result<Option<String>, String> {
53+
#[cfg(target_os = "linux")]
54+
if file_fallback::enabled() {
55+
return file_fallback::load();
56+
}
2457
match entry()?.get_password() {
2558
Ok(token) => Ok(Some(token)),
2659
Err(KeyringError::NoEntry) => Ok(None),
27-
Err(e) => Err(e.to_string()),
60+
Err(e) => Err(annotate(e)),
2861
}
2962
}
3063

3164
/// Persist the token, overwriting any existing one.
3265
#[tauri::command]
3366
pub fn github_token_save(token: String) -> Result<(), String> {
34-
entry()?.set_password(&token).map_err(|e| e.to_string())
67+
#[cfg(target_os = "linux")]
68+
if file_fallback::enabled() {
69+
return file_fallback::save(&token);
70+
}
71+
entry()?.set_password(&token).map_err(annotate)
3572
}
3673

3774
/// Delete the stored token. Idempotent — a missing entry is treated as success.
3875
#[tauri::command]
3976
pub fn github_token_delete() -> Result<(), String> {
77+
#[cfg(target_os = "linux")]
78+
if file_fallback::enabled() {
79+
return file_fallback::delete();
80+
}
4081
match entry()?.delete_credential() {
4182
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
42-
Err(e) => Err(e.to_string()),
83+
Err(e) => Err(annotate(e)),
84+
}
85+
}
86+
87+
/// Opt-in plaintext token file for Linux headless boxes without a Secret
88+
/// Service daemon. See the module docs for the threat-model rationale.
89+
///
90+
/// Compiled on all Unix so its logic is unit-tested on the macOS CI host, but
91+
/// only **activated** on Linux — see the `#[cfg(target_os = "linux")]` call
92+
/// sites in the command fns above.
93+
#[cfg(unix)]
94+
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
95+
mod file_fallback {
96+
use std::fs;
97+
use std::io::Write;
98+
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
99+
use std::path::PathBuf;
100+
101+
/// True iff the user explicitly opted into the file fallback.
102+
pub fn enabled() -> bool {
103+
matches!(
104+
std::env::var("MARKUP_TOKEN_FILE_FALLBACK").as_deref(),
105+
Ok("1") | Ok("true")
106+
)
107+
}
108+
109+
/// `$XDG_DATA_HOME/<service>/github-token`, falling back to
110+
/// `$HOME/.local/share/<service>/github-token`.
111+
fn path() -> Result<PathBuf, String> {
112+
let base = std::env::var_os("XDG_DATA_HOME")
113+
.map(PathBuf::from)
114+
.filter(|p| p.is_absolute())
115+
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
116+
.ok_or_else(|| "no XDG_DATA_HOME or HOME for the token file fallback".to_string())?;
117+
Ok(base.join(super::SERVICE).join("github-token"))
118+
}
119+
120+
pub fn load() -> Result<Option<String>, String> {
121+
let p = path()?;
122+
match fs::read_to_string(&p) {
123+
Ok(s) => {
124+
let token = s.trim();
125+
Ok(if token.is_empty() {
126+
None
127+
} else {
128+
Some(token.to_string())
129+
})
130+
}
131+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
132+
Err(e) => Err(e.to_string()),
133+
}
134+
}
135+
136+
pub fn save(token: &str) -> Result<(), String> {
137+
let p = path()?;
138+
if let Some(dir) = p.parent() {
139+
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
140+
// Best-effort tighten the parent dir to owner-only.
141+
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
142+
}
143+
// Create/truncate with 0600 from the start, so the token is never
144+
// briefly world-readable.
145+
let mut f = fs::OpenOptions::new()
146+
.write(true)
147+
.create(true)
148+
.truncate(true)
149+
.mode(0o600)
150+
.open(&p)
151+
.map_err(|e| e.to_string())?;
152+
f.write_all(token.as_bytes()).map_err(|e| e.to_string())?;
153+
Ok(())
154+
}
155+
156+
pub fn delete() -> Result<(), String> {
157+
match fs::remove_file(path()?) {
158+
Ok(()) => Ok(()),
159+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
160+
Err(e) => Err(e.to_string()),
161+
}
162+
}
163+
164+
#[cfg(test)]
165+
mod tests {
166+
use super::*;
167+
168+
#[test]
169+
fn roundtrip_via_xdg_data_home() {
170+
let tmp = std::env::temp_dir().join(format!("markup-tok-{}", std::process::id()));
171+
let _ = fs::remove_dir_all(&tmp);
172+
// SAFETY: single-threaded test; we set XDG_DATA_HOME for this process.
173+
unsafe { std::env::set_var("XDG_DATA_HOME", &tmp) };
174+
175+
assert_eq!(load().unwrap(), None, "empty before save");
176+
save("ghp_example").unwrap();
177+
assert_eq!(load().unwrap(), Some("ghp_example".to_string()));
178+
179+
// File must be owner-only (0600).
180+
let mode = fs::metadata(path().unwrap()).unwrap().permissions().mode();
181+
assert_eq!(mode & 0o777, 0o600);
182+
183+
delete().unwrap();
184+
assert_eq!(load().unwrap(), None, "empty after delete");
185+
delete().unwrap(); // idempotent
186+
187+
let _ = fs::remove_dir_all(&tmp);
188+
}
43189
}
44190
}

0 commit comments

Comments
 (0)