1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
//! **The appliance's actor-ticket key: an OpenSSH ed25519 keypair that makes
//! RAW Ed25519 signatures.**
//!
//! [`ActorTicketKey`] is the one thing in this repository that signs with a key
//! outside SSH's own framing, and this module exists so that fact lives in one
//! place with the reason attached rather than being spelled out wherever a
//! signature is needed.
//!
//! # Stored as OpenSSH, signed as raw Ed25519
//!
//! The key is minted by [`gunnar_core::mint_ed25519`] and kept in the secret
//! store as an OpenSSH private key, byte for byte like the host key, the
//! transparency key, the provenance key and the webhook key — one mint, one
//! encoding, one thing to back up, and a key an operator can read with ordinary
//! tools if they ever have to.
//!
//! What it produces is not `sshsig`. The verifier on the other side of the seam
//! is `monetize_embed::ticket::verify_ticket`, which holds an
//! `ed25519_dalek::VerifyingKey` and checks a 64-byte signature over the
//! canonical bytes that crate defines. gunnar writes no second canonical form
//! and wraps it in no envelope of its own, because a second encoder is a second
//! thing that can disagree with the verifier, and the whole point of a ticket
//! is that a party gunnar does not run can check it without asking.
//!
//! # Why the private half never leaves this type
//!
//! [`ActorTicketKey`] hands out a signature and a public key and nothing else.
//! The service that mints tickets lives in `gunnar-grpc`, on the far side of
//! the control-plane seam, and it asks this half to sign — it is never handed
//! key material to sign with itself. That keeps the number of places that hold
//! a private key at the number it already was.
use anyhow::{anyhow, Context, Result};
use ed25519_dalek::{Signer, SigningKey};
/// The appliance's actor-ticket signing key.
///
/// `Debug` prints the public half only. Private key material must not be one
/// `{:?}` away from a log file — the same rule `gunnar_core::HostKey` keeps,
/// and for the same reason.
#[derive(Clone)]
pub struct ActorTicketKey {
signing: SigningKey,
public_hex: String,
}
impl ActorTicketKey {
/// Adopt an OpenSSH ed25519 private key as an actor-ticket signer.
///
/// Refuses anything that is not ed25519 rather than falling back: the
/// ticket format names one algorithm, and a key of another type here could
/// only ever produce signatures the verifier will not check.
pub fn from_openssh(private: &gunnar_core::ssh_key::PrivateKey) -> Result<ActorTicketKey> {
let pair = private.key_data().ed25519().ok_or_else(|| {
anyhow!(
"the stored actor-ticket key is {} and the ticket format is ed25519",
private.algorithm().as_str()
)
})?;
// `ssh_key` keeps the 32-byte seed; `ed25519_dalek` derives the public
// half from it. Deriving rather than trusting the stored public half
// means a store that was edited to swap one of the two cannot produce
// a key that advertises one public half and signs under another.
let signing = SigningKey::from_bytes(pair.private.as_ref());
let public_hex = signing
.verifying_key()
.to_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(ActorTicketKey {
signing,
public_hex,
})
}
/// The public half, 64 lowercase hex characters.
///
/// This is the string `Billing.ApplianceIdentity` serves and an operator
/// pastes into their configuration. Hex and not base64: it is copied
/// between two config files by a human, and hex has no alphabet variant, no
/// padding, and no `+/` versus `-_` question to get wrong on the way.
pub fn public_key_hex(&self) -> &str {
&self.public_hex
}
/// Sign `message` — raw Ed25519, 64 bytes, no envelope.
///
/// `message` is `monetize_embed::ticket::ticket_message` of an unsigned
/// ticket in the one use this has. It is a `&[u8]` and not an `ActorTicket`
/// deliberately: this crate is the half that holds the key, not the half
/// that knows the ticket format, and giving it an opinion about the format
/// would be a second place the format is written down.
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
self.signing.sign(message).to_bytes()
}
}
impl std::fmt::Debug for ActorTicketKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActorTicketKey")
.field("public_key_hex", &self.public_hex)
.finish_non_exhaustive()
}
}
/// Parse the 64 hex characters [`ActorTicketKey::public_key_hex`] renders back
/// into the 32 bytes a verifier needs.
///
/// Here rather than at each call site because both halves of the contract need
/// it — the appliance renders, a verifier parses — and two spellings of the
/// same decode is how one of them ends up accepting an odd-length string.
pub fn public_key_from_hex(hex: &str) -> Result<[u8; 32]> {
if hex.len() != 64 {
return Err(anyhow!(
"an ed25519 public key is 64 hex characters and this is {}",
hex.len()
));
}
let mut out = [0u8; 32];
for (slot, pair) in out.iter_mut().zip(hex.as_bytes().chunks(2)) {
let pair = std::str::from_utf8(pair).context("the key is not ascii")?;
*slot = u8::from_str_radix(pair, 16).context("the key is not hex")?;
}
Ok(out)
}