Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/src/reference/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,42 @@ operations. An attacker that gains access to the node is unable to
provide either these signatures and will therefore fail to convince
the signer of its injected commands.

### Request-context signatures

The client signs the following bytes with ECDSA-P256/SHA256:

```text
"greenlight/request-context/v1\0"
|| u64be(len(uri)) || uri
|| u64be(len(frame)) || frame
|| u64be(timestamp_ms)
```

The `uri` is the gRPC path, such as `/cln.Node/Pay`. The `frame` is the
gRPC frame, including its 5-byte header. Covering the URI prevents the
node from presenting a signed request as a different RPC with a compatible
protobuf encoding. The timestamp is authenticated, but its freshness is
not checked.

The signer ignores any context that does not verify, including those signed
by older clients. An ignored context authorizes nothing: an enforcing signer
refuses operations that depend on it, and a `permissive` signer behaves as if
it were absent. Upgrade clients and signers together.

A `Configure` context cached before this format was introduced is ignored in
the same way. If a signer already applied it, the close address stays
allowlisted because the allowlist is part of the signer state. An ignored
context cannot restore an address that was never applied or is missing from
that state.

Upgrade clients before allowing the first `Configure` call on a node with an
updated signer. The plugin stores this request using a create-only datastore
write, so a first call from an old client can cache an unverifiable context
that a later `Configure` call cannot replace. Recovery requires removing the
cached `glconf/request` entry and configuring again with an updated client.
This is a configuration migration limitation, not a reason to accept a legacy
signature as authorization.

## Signer State Integrity

The signer does not store its own state. The node stores the
Expand Down
8 changes: 8 additions & 0 deletions libs/gl-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## Unreleased

### Changed

- **BREAKING**: RPC request-context signatures now cover the gRPC method, so a node can no longer present a signed request as a different RPC. Signers ignore contexts signed by older clients, so upgrade clients and signers together. See the security reference.

### Fixed

- Validate the `Configure` close address against the signer's network before adding it to the allowlist.

## [0.7.0] - 2026-10-12

### Added
Expand Down
2 changes: 2 additions & 0 deletions libs/gl-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub mod scheduler;
/// move your funds.
pub mod signer;

mod request_context;

pub mod metrics;
pub mod persist;
pub mod psbt;
Expand Down
6 changes: 2 additions & 4 deletions libs/gl-client/src/node/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,11 @@ impl Service<Request<BoxBody>> for AuthService {

let data = body.data().await.unwrap().unwrap();

// Copy used to create the signature (payload + associated data)
let mut buf = data.to_vec();

// Associated data that is covered by the signature
let mut ts = vec![];
ts.put_u64(time.try_into()?);
buf.put_u64(time.try_into()?);

let buf = crate::request_context::payload(parts.uri.path(), &data, time.try_into()?);

let rng = rand::SystemRandom::new();
let pubkey = keypair.public_key().as_ref();
Expand Down
36 changes: 36 additions & 0 deletions libs/gl-client/src/request_context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const DOMAIN: &[u8] = b"greenlight/request-context/v1\0";

/// The bytes a client signs and the signer verifies to authorize an RPC.
/// Integers are big-endian.
///
/// A protobuf body can decode as several request types, so the URI is covered
/// to stop the node relabeling a signed request as a different RPC.
pub(crate) fn payload(uri: &str, frame: &[u8], timestamp: u64) -> Vec<u8> {
[
DOMAIN,
&(uri.len() as u64).to_be_bytes(),
uri.as_bytes(),
&(frame.len() as u64).to_be_bytes(),
frame,
&timestamp.to_be_bytes(),
]
.concat()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn payload_wire_format() {
// Fixed bytes document the protocol independently of the encoder.
let expected = b"greenlight/request-context/v1\0\
\0\0\0\0\0\0\0\x11/cln.Node/Getinfo\
\0\0\0\0\0\0\0\x05\0\0\0\0\0\
\x01\x02\x03\x04\x05\x06\x07\x08";
assert_eq!(
payload("/cln.Node/Getinfo", &[0; 5], 0x0102030405060708),
expected
);
}
}
21 changes: 13 additions & 8 deletions libs/gl-client/src/signer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ pub use backup::{
};
pub mod model;
mod report;
#[cfg(test)]
mod request_context_tests;
mod resolve;
mod splice_policy;

Expand Down Expand Up @@ -597,14 +599,7 @@ impl Signer {
.filter(|r| !r.pubkey.is_empty() && !r.signature.is_empty() && !r.rune.is_empty())
.map(|r| {
let pk = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &r.pubkey);
let mut data = r.request.clone();

// If we have a timestamp associated we must add it to
// the payload being checked. Same thing happens on
// the client too.
if r.timestamp != 0 {
data.put_u64(r.timestamp);
}
let data = crate::request_context::payload(&r.uri, &r.request, r.timestamp);

pk.verify(&data, &r.signature)
.map_err(|e| anyhow!("signature verification failed: {}", e))?;
Expand Down Expand Up @@ -874,6 +869,16 @@ impl Signer {
for parsed_request in ctxrequests.iter() {
match parsed_request {
model::Request::GlConfig(gl_config) => {
// The node is untrusted; only a valid address for this
// network may authorize on-chain destinations.
if let Err(e) = gl_config
.close_to_addr
.parse::<lightning_signer::bitcoin::Address<_>>()
.and_then(|a| a.require_network(self.network))
{
warn!("Ignoring Configure with invalid close_to_addr: {}", e);
continue;
}
let pubkey = PublicKey::from_slice(&self.id);
match pubkey {
Ok(p) => {
Expand Down
Loading
Loading