Skip to content

Commit 2cadf51

Browse files
quarcksterclaude
andcommitted
tests: add Sequoia sq CLI parity tests for sign/verify and revocation
The existing integration tests prove our outputs are accepted by GnuPG. Add a parallel set of tests that route the same artefacts through Sequoia's own sq CLI, so we catch any regression where we accidentally produce output that depends on GnuPG's leniency. Three new tests: - rsa_sign_verify_with_sq, ec_sign_verify_with_sq: mirror the existing rsa_/ec_sign_verify_with_gpg tests but verify the sq-pkcs11 signature with `sq verify --signer-file <cert> --signature-file <sig> <payload>`. Different OpenPGP implementation, different crypto backend (Nettle), different policy engine — useful as a sanity check. - sq_honours_standalone_subkey_revocation: confirms that the SubkeyRevocation packet sq-pkcs11 emits is structurally valid (sq applies it cleanly, unlike GnuPG which silently drops it — see the README caveat). Sign with the subkey, verify (must succeed), revoke the subkey as compromised, merge cert+revocation, verify again (must fail). Adds a small sq_cli() / fresh_sq_home() helper pair, parallel to the existing gpg_in() / fresh_gpg_home(). Tests panic if sq is not installed; install via `apt install sq` (Debian) or `cargo install sequoia-sq` to run them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 44a8c87 commit 2cadf51

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

‎tests/nshield_integration.rs‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,204 @@ fn ec_sign_verify_with_gpg() {
329329
sign_verify_roundtrip(&env, &env.ec_label, "Test EC <ec@example.com>");
330330
}
331331

332+
// ---------------------------------------------------------------------------
333+
// sq (Sequoia CLI) parity tests.
334+
//
335+
// The gpg-based tests above prove our outputs are accepted by GnuPG.
336+
// The tests below assert the same artefacts verify cleanly through
337+
// Sequoia's own `sq` CLI — different OpenPGP implementation, different
338+
// crypto backend, useful as a sanity check that we don't accidentally
339+
// produce GnuPG-leniency-shaped artefacts.
340+
// ---------------------------------------------------------------------------
341+
342+
/// `sq` invocation pointed at an isolated home directory so tests don't
343+
/// touch the operator's real Sequoia keystore.
344+
fn sq_cli(home: &Path) -> StdCommand {
345+
let mut c = StdCommand::new("sq");
346+
c.arg("--home").arg(home).arg("--batch");
347+
c
348+
}
349+
350+
/// Set up a fresh sq home directory inside `tmp` and return its path.
351+
fn fresh_sq_home(tmp: &TempDir) -> PathBuf {
352+
let home = tmp.path().join("sq");
353+
std::fs::create_dir_all(&home).unwrap();
354+
#[cfg(unix)]
355+
{
356+
use std::os::unix::fs::PermissionsExt;
357+
std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap();
358+
}
359+
home
360+
}
361+
362+
fn sq_sign_verify_roundtrip(env: &TestEnv, key_label: &str, userid: &str) {
363+
let tmp = TempDir::new().unwrap();
364+
let cert_path = tmp.path().join("cert.asc");
365+
let payload = tmp.path().join("payload.txt");
366+
let signature = tmp.path().join("payload.txt.asc");
367+
let sq_home = fresh_sq_home(&tmp);
368+
369+
std::fs::write(&payload, b"test payload bytes\n").unwrap();
370+
371+
// 1. Export the cert via sq-pkcs11.
372+
sq_pkcs11(env)
373+
.args(["cert-export"])
374+
.args(["--key-label", key_label])
375+
.args(["--userid", userid])
376+
.args(["--creation-time", STABLE_TIME])
377+
.args(["--output"])
378+
.arg(&cert_path)
379+
.assert()
380+
.success();
381+
382+
// 2. Produce a detached signature via sq-pkcs11.
383+
sq_pkcs11(env)
384+
.args(["sign"])
385+
.args(["--key-label", key_label])
386+
.args(["--creation-time", STABLE_TIME])
387+
.arg(&payload)
388+
.assert()
389+
.success();
390+
assert!(signature.exists(), "sign did not create {signature:?}");
391+
392+
// 3. Verify with sq. --signer-file gives sq the cert directly so
393+
// we don't have to import into a keystore first; --signature-file
394+
// points at the detached signature. sq exits 0 on success.
395+
let verify = sq_cli(&sq_home)
396+
.arg("verify")
397+
.arg("--signer-file")
398+
.arg(&cert_path)
399+
.arg("--signature-file")
400+
.arg(&signature)
401+
.arg(&payload)
402+
.output()
403+
.expect("sq verify");
404+
assert!(
405+
verify.status.success(),
406+
"sq verify failed for {key_label}:\nstdout: {}\nstderr: {}",
407+
String::from_utf8_lossy(&verify.stdout),
408+
String::from_utf8_lossy(&verify.stderr),
409+
);
410+
}
411+
412+
#[test]
413+
fn rsa_sign_verify_with_sq() {
414+
let env = require_env!();
415+
sq_sign_verify_roundtrip(&env, &env.rsa_label, "SQ Test RSA <sq-rsa@example.com>");
416+
}
417+
418+
#[test]
419+
fn ec_sign_verify_with_sq() {
420+
let env = require_env!();
421+
sq_sign_verify_roundtrip(&env, &env.ec_label, "SQ Test EC <sq-ec@example.com>");
422+
}
423+
424+
#[test]
425+
fn sq_honours_standalone_subkey_revocation() {
426+
// Sequoia's sq (unlike GnuPG — see README's caveat under
427+
// "Caveat: GnuPG ignores standalone subkey-revocation files")
428+
// *does* apply a SubkeyRevocation packet imported on its own.
429+
// This test confirms our subkey-revoke output is structurally
430+
// correct and would Just Work with any Sequoia-based verifier.
431+
//
432+
// Flow: build a two-tier cert, sign a payload with the subkey,
433+
// verify (must succeed), then issue a "compromised" subkey
434+
// revocation, merge cert+revocation into one file, and verify
435+
// again (must fail because the signing subkey is now revoked
436+
// and "compromised" invalidates past signatures).
437+
438+
let env = require_env!();
439+
let tmp = TempDir::new().unwrap();
440+
let cert_path = tmp.path().join("cert.asc");
441+
let merged_cert = tmp.path().join("cert-with-revocation.asc");
442+
let payload = tmp.path().join("payload.txt");
443+
let signature = tmp.path().join("payload.txt.asc");
444+
let revocation = tmp.path().join("subkey-revocation.asc");
445+
let sq_home = fresh_sq_home(&tmp);
446+
447+
std::fs::write(&payload, b"sq subkey-revocation parity\n").unwrap();
448+
449+
// 1. Two-tier cert.
450+
sq_pkcs11(&env)
451+
.args(["cert-export"])
452+
.args(["--key-label", &env.primary_label])
453+
.args(["--subkey-label", &env.subkey_label])
454+
.args(["--userid", "SQ Subkey Revoke <sq-skrev@example.com>"])
455+
.args(["--creation-time", STABLE_TIME])
456+
.args(["--subkey-creation-time", STABLE_TIME])
457+
.args(["--output"])
458+
.arg(&cert_path)
459+
.assert()
460+
.success();
461+
462+
// 2. Sign with the subkey.
463+
sq_pkcs11(&env)
464+
.args(["sign"])
465+
.args(["--key-label", &env.subkey_label])
466+
.args(["--creation-time", STABLE_TIME])
467+
.arg(&payload)
468+
.assert()
469+
.success();
470+
471+
// 3. Verify against the un-revoked cert — must succeed.
472+
let verify_before = sq_cli(&sq_home)
473+
.arg("verify")
474+
.arg("--signer-file")
475+
.arg(&cert_path)
476+
.arg("--signature-file")
477+
.arg(&signature)
478+
.arg(&payload)
479+
.output()
480+
.expect("sq verify (before revocation)");
481+
assert!(
482+
verify_before.status.success(),
483+
"sq verify against the un-revoked cert must succeed:\nstderr: {}",
484+
String::from_utf8_lossy(&verify_before.stderr),
485+
);
486+
487+
// 4. Issue a "compromised" subkey revocation.
488+
sq_pkcs11(&env)
489+
.args(["subkey-revoke"])
490+
.args(["--key-label", &env.primary_label])
491+
.args(["--subkey-label", &env.subkey_label])
492+
.args(["--creation-time", STABLE_TIME])
493+
.args(["--subkey-creation-time", STABLE_TIME])
494+
.args(["--reason", "compromised"])
495+
.args(["--message", "sq subkey revocation parity test"])
496+
.args(["--output"])
497+
.arg(&revocation)
498+
.assert()
499+
.success();
500+
501+
// 5. Merge cert + standalone revocation into one file. Sequoia's
502+
// Cert parser handles the concatenation natively (a TPK followed
503+
// by a SubkeyRevocation Signature merges into a cert with the
504+
// subkey marked revoked).
505+
let cert_bytes = std::fs::read(&cert_path).unwrap();
506+
let rev_bytes = std::fs::read(&revocation).unwrap();
507+
std::fs::write(&merged_cert, [&cert_bytes[..], &rev_bytes[..]].concat()).unwrap();
508+
509+
// 6. Verify against the merged cert — must FAIL because the signing
510+
// subkey is now revoked with reason "compromised", which
511+
// invalidates past signatures.
512+
let verify_after = sq_cli(&sq_home)
513+
.arg("verify")
514+
.arg("--signer-file")
515+
.arg(&merged_cert)
516+
.arg("--signature-file")
517+
.arg(&signature)
518+
.arg(&payload)
519+
.output()
520+
.expect("sq verify (after revocation)");
521+
assert!(
522+
!verify_after.status.success(),
523+
"sq verify against the cert-with-subkey-revocation must FAIL when \
524+
the subkey was revoked as compromised:\nstdout: {}\nstderr: {}",
525+
String::from_utf8_lossy(&verify_after.stdout),
526+
String::from_utf8_lossy(&verify_after.stderr),
527+
);
528+
}
529+
332530
// ---------------------------------------------------------------------------
333531
// Output format
334532
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)