Skip to content

Commit 7bd76e1

Browse files
committed
Add vkey commits github and /settings github: gh signed in, key added
1 parent 404fa26 commit 7bd76e1

5 files changed

Lines changed: 94 additions & 33 deletions

File tree

‎.github/workflows/audit.yml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ name: audit
55

66
on:
77
push:
8-
paths: ['**/Cargo.lock', '**/Cargo.toml', '.github/workflows/audit.yml']
8+
paths: ['**/Cargo.lock', '**/Cargo.toml', '**/.cargo/audit.toml', '.github/workflows/audit.yml']
99
pull_request:
10-
paths: ['**/Cargo.lock', '**/Cargo.toml']
10+
paths: ['**/Cargo.lock', '**/Cargo.toml', '**/.cargo/audit.toml']
1111
schedule:
1212
- cron: '17 6 * * 1'
1313
workflow_dispatch:
@@ -24,5 +24,9 @@ jobs:
2424
persist-credentials: false
2525
# rust-toolchain.toml pins the version; rustup installs it on the first call.
2626
- run: cargo install cargo-audit --locked
27-
- run: cargo audit --file firmware/Cargo.lock
28-
- run: cargo audit --file cli/Cargo.lock
27+
# From each folder, as tools/checks.sh does: cargo audit reads .cargo/audit.toml
28+
# from the working directory, not from beside the lock file.
29+
- run: cargo audit
30+
working-directory: firmware
31+
- run: cargo audit
32+
working-directory: cli

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,18 @@ with apt first if it is missing.
168168
```bash
169169
vkey commits enable # a tap: an SSH key on the key, git signs every commit with it
170170
vkey commits disable # git stops signing; the key stays for the next enable
171+
vkey commits github # the key on GitHub as a signing key: commits show as verified
171172
```
172173

173174
`enable` makes an OpenSSH `ecdsa-sk` key on the nRF52840 (a tap) and keeps its handle in
174175
`~/.config/vkey/commit-signing-key`, which signs nothing without the key (not in
175176
`~/.ssh`, where GNOME Keyring's agent would take it and refuse to sign). It sets git's `gpg.format`,
176177
`user.signingkey`, `gpg.ssh.allowedSignersFile` and `commit.gpgsign` globally, refuses
177178
to overwrite a signing setup that is not its own, and offers to add the public half to
178-
GitHub as a signing key (`gh`), so the commits show as verified there. From then on every
179-
commit waits for a tap. In the shell it is `/settings` → `commits`, on or off. Linux only
179+
GitHub as a signing key (`gh`), so the commits show as verified there. `github` does that
180+
step alone: it signs `gh` in, or grants it the `admin:ssh_signing_key` permission, in the
181+
browser when it has to, then adds the key. From then on every commit waits for a tap. In
182+
the shell it is `/settings` → `commits`, on or off, and `/settings` → `github`. Linux only
180183
for now; macOS and Windows are issues #18 and #19. What it costs is in
181184
[the threat model](docs/threat-model.md#git-commits-signed-with-a-tap).
182185

‎cli/src/commits.rs‎

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! ```text
44
//! vkey commits enable an SSH key on the key, git told to sign with it
55
//! vkey commits disable git stops signing; the key stays for the next enable
6+
//! vkey commits github the key on GitHub, so the commits show as verified
67
//! ```
78
//!
89
//! The key is a FIDO2 authenticator (the nRF52840's passkeys), and OpenSSH keeps a
@@ -138,42 +139,78 @@ fn make_key(key: &Path) -> Result<(), Error> {
138139
}
139140

140141
/// GitHub shows a commit as verified only when it knows the key as a signing key.
141-
/// Uploading publishes it, so it is asked, and needs `gh` with the scope for it.
142+
/// Uploading publishes it, so `enable` asks first.
142143
fn offer_to_github(key: &Path) -> Result<(), Error> {
143-
let public = pub_path(key);
144-
let by_hand = format!(
145-
" gh ssh-key add {} --type signing --title vkey",
146-
public.display()
147-
);
148-
let gh = Command::new("gh").args(["auth", "status"]).output();
149-
if !matches!(gh, Ok(ref o) if o.status.success()) {
144+
if !prompt::is_tty()
145+
|| !prompt::yes_no("add the key to GitHub, so the commits show as verified?")
146+
{
150147
println!(
151-
"for GitHub to show the commits as verified, add the key as a signing key:\n{by_hand}"
148+
"to add it later: vkey commits github (or /settings → github)\n public key: {}",
149+
pub_path(key).display()
152150
);
153151
return Ok(());
154152
}
155-
if !prompt::is_tty() || !prompt::yes_no("add the key to GitHub as a signing key?") {
156-
println!("to add it later:\n{by_hand}");
157-
return Ok(());
153+
github().map(drop)
154+
}
155+
156+
/// The scope `gh` needs to add a signing key; a plain `gh auth login` does not ask it.
157+
const SCOPE: &str = "admin:ssh_signing_key";
158+
159+
/// `vkey commits github`: the signing key's public half on GitHub as a signing key,
160+
/// signing `gh` in - or giving it the scope - in the browser first when it has to.
161+
pub fn github() -> Result<u8, Error> {
162+
let public = pub_path(&home()?.join(KEY));
163+
if !public.exists() {
164+
return Err(Error::Value(
165+
"no signing key yet: vkey commits enable".into(),
166+
));
158167
}
159-
let out = Command::new("gh")
160-
.args(["ssh-key", "add"])
161-
.arg(&public)
162-
.args(["--type", "signing", "--title", "vkey"])
163-
.output()?;
164-
if out.status.success() {
165-
println!("added to GitHub: commits signed by this key show as verified");
166-
return Ok(());
168+
let signed_in = match Command::new("gh").args(["auth", "status"]).output() {
169+
Ok(out) => out.status.success(),
170+
Err(_) => {
171+
return Err(Error::Value(
172+
"gh, GitHub's CLI, is missing: sudo apt install gh".into(),
173+
));
174+
}
175+
};
176+
if !signed_in {
177+
println!("gh signs in to GitHub in the browser first");
178+
gh_interactive(&["auth", "login", "-h", "github.com", "-s", SCOPE])?;
167179
}
180+
let mut out = add_key(&public)?;
181+
if !out.status.success() && String::from_utf8_lossy(&out.stderr).contains(SCOPE) {
182+
println!("gh needs one more permission, to add signing keys: grant it in the browser");
183+
gh_interactive(&["auth", "refresh", "-h", "github.com", "-s", SCOPE])?;
184+
out = add_key(&public)?;
185+
}
186+
// gh answers success for a key GitHub already has, so both read the same.
168187
let why = String::from_utf8_lossy(&out.stderr);
169-
if why.contains("admin:ssh_signing_key") {
170-
println!(
171-
"gh needs the scope to add signing keys first:\n gh auth refresh -h github.com -s admin:ssh_signing_key\nthen:\n{by_hand}"
172-
);
173-
} else if why.contains("already") {
174-
println!("GitHub already has this key");
188+
if out.status.success() || why.contains("already") {
189+
println!("the key is on GitHub: commits signed with it show as verified");
175190
} else {
176-
println!("GitHub refused the key: {}\n{by_hand}", why.trim());
191+
return Err(Error::Value(format!(
192+
"GitHub refused the key: {}",
193+
why.trim()
194+
)));
195+
}
196+
Ok(0)
197+
}
198+
199+
fn add_key(public: &Path) -> Result<std::process::Output, Error> {
200+
Ok(Command::new("gh")
201+
.args(["ssh-key", "add"])
202+
.arg(public)
203+
.args(["--type", "signing", "--title", "vkey"])
204+
.output()?)
205+
}
206+
207+
/// `gh` with the terminal: it prints a one-time code and opens the browser.
208+
fn gh_interactive(args: &[&str]) -> Result<(), Error> {
209+
if !Command::new("gh").args(args).status()?.success() {
210+
return Err(Error::Value(format!(
211+
"gh {} did not finish",
212+
args.join(" ")
213+
)));
177214
}
178215
Ok(())
179216
}

‎cli/src/main.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ enum CommitsCmd {
216216
Enable,
217217
/// git stops signing; the key stays for the next enable
218218
Disable,
219+
/// The signing key on GitHub, so the commits show as verified; signs gh in, or
220+
/// gives it the permission, in the browser when it has to
221+
Github,
219222
}
220223

221224
#[derive(Subcommand, Clone, Copy)]
@@ -328,6 +331,9 @@ fn run_off_port(cli: &Cli) -> Option<Result<u8, Error>> {
328331
Cmd::Commits {
329332
cmd: CommitsCmd::Disable,
330333
} => commits::disable(),
334+
Cmd::Commits {
335+
cmd: CommitsCmd::Github,
336+
} => commits::github(),
331337
Cmd::Setup { erase, board } => cmd_setup(cli.port.as_deref(), board.as_deref(), *erase),
332338
_ => return None,
333339
})

‎cli/src/shell.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ const SETTINGS: &[(&str, &str)] = &[
7070
"commits",
7171
"sign every git commit with a tap - turn on or off",
7272
),
73+
(
74+
"github",
75+
"sign in to GitHub and add the signing key, so commits show as verified",
76+
),
7377
(
7478
"wipe",
7579
"factory reset - every secret and the PIN; the button is held, not tapped",
@@ -2434,6 +2438,13 @@ impl Shell {
24342438
let r = self.cmd_commits();
24352439
return self.report(r);
24362440
}
2441+
"github" => {
2442+
let r = std::env::current_exe()
2443+
.map_err(Error::from)
2444+
.and_then(|exe| self.outside(Command::new(exe).args(["commits", "github"])))
2445+
.map(drop);
2446+
return self.report(r);
2447+
}
24372448
_ => {}
24382449
}
24392450
if !self.connected() && !self.reconnect() {

0 commit comments

Comments
 (0)