Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions busbar-auth-github/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ license = "Apache-2.0"
busbar-contract = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
zeroize = { workspace = true }
97 changes: 97 additions & 0 deletions busbar-auth-github/src/held.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (C) 2026 Busbar Inc and contributors

//! [`Held`] — a credential THIS PLUGIN holds in its own memory: the GitHub access token it receives
//! from the token endpoint, and the OAuth `state` nonce / confidential-client secret it keeps for the
//! duration of a login flow.
//!
//! These are the plugin's own material, not a value the kernel resolved and lent it, so they are not
//! the contract's sealed `Secret<T>` (busbar #1021: that type's one raw accessor takes a kernel seal,
//! which a plugin neither holds nor can obtain). `Held` keeps exactly the hygiene the plugin relied on
//! when it wrapped them in the contract's former redaction wrapper:
//!
//! * **`Debug` / `Display` never reveal it.** Both print the literal `"[REDACTED]"`.
//! * **It does not serialize.** `Held` implements neither `Serialize` nor `Deserialize`.
//! * **It zeroizes its backing memory on drop.**
//! * **Equality is constant-time** (the contract's `constant_time_eq` over the byte form).
//!
//! The value is reached ONLY through [`Held::reveal`]: every call site is an audit point where the
//! plaintext escapes on purpose.

use core::fmt;

use zeroize::Zeroize;

/// A credential this plugin holds. `Debug`/`Display` print `"[REDACTED]"`; it never serializes; its
/// backing memory is zeroized on drop. See the module docs.
pub(crate) struct Held<T: Zeroize>(T);

impl<T: Zeroize> Held<T> {
/// Hold `secret`.
pub(crate) fn new(secret: T) -> Self {
Self(secret)
}

/// Borrow the held value. AUDIT POINT: the plaintext escapes redaction here.
pub(crate) fn reveal(&self) -> &T {
&self.0
}
}

/// `Debug` NEVER reveals the value.
impl<T: Zeroize> fmt::Debug for Held<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}

/// `Display` NEVER reveals the value either.
impl<T: Zeroize> fmt::Display for Held<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}

impl<T: Zeroize> Drop for Held<T> {
fn drop(&mut self) {
self.0.zeroize();
}
}

impl<T: Zeroize + Clone> Clone for Held<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}

/// Equality on two held values is CONSTANT-TIME (the contract's `constant_time_eq`), never a
/// byte-by-byte compare with a data-dependent early exit.
impl<T: Zeroize + AsRef<[u8]>> PartialEq for Held<T> {
fn eq(&self, other: &Self) -> bool {
busbar_contract::constant_time_eq(&self.0, &other.0)
}
}

impl<T: Zeroize + AsRef<[u8]>> Eq for Held<T> {}

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

#[test]
fn debug_and_display_print_only_the_redaction_marker() {
let h = Held::new(String::from("sk-live-plaintext"));
assert_eq!(format!("{h:?}"), "[REDACTED]");
assert_eq!(format!("{h}"), "[REDACTED]");
assert_eq!(format!("{:?}", Some(&h)), "Some([REDACTED])");
assert_eq!(h.reveal(), "sk-live-plaintext");
}

#[test]
fn equality_compares_the_held_bytes() {
let a = Held::new(String::from("same"));
assert_eq!(a, a.clone());
assert_ne!(a, Held::new(String::from("other")));
assert_ne!(a, Held::new(String::from("sam")));
}
}
12 changes: 5 additions & 7 deletions busbar-auth-github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@

pub mod door;
pub mod guard;
mod held;
pub mod login;

use crate::held::Held;
use busbar_contract::auth::{
AuthModule, AuthVerdict, BeginLogin, CompleteLogin, LoginHop, LoginHttpResponse, LoginModule,
LoginOutcome, Principal,
};
use busbar_contract::Redacted;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
Expand Down Expand Up @@ -480,7 +481,7 @@ const PENDING_MAX: usize = 4096;
struct PendingLogin {
/// The opaque GitHub bearer, wrapped so a stray `{:?}` on this struct, or on the map that holds
/// it, cannot print it. Nothing formats these today; this is to keep it that way.
access_token: Redacted<String>,
access_token: Held<String>,
user: Option<GhUser>,
/// When this entry was stashed, for the TTL sweep.
inserted_at: Instant,
Expand Down Expand Up @@ -629,7 +630,7 @@ impl GithubModule {
pending.insert(
key.to_string(),
PendingLogin {
access_token: Redacted::new(token),
access_token: Held::new(token),
user: None,
inserted_at: Instant::now(),
},
Expand Down Expand Up @@ -674,10 +675,7 @@ impl GithubModule {
entry.user = Some(user);
// The `/user/orgs` GET is authenticated with the SAME opaque access token stashed at the token
// step, attached via the ABI v2 `headers` field.
LoginOutcome::Exchange(build_orgs_get(
&self.cfg,
entry.access_token.expose_secret(),
))
LoginOutcome::Exchange(build_orgs_get(&self.cfg, entry.access_token.reveal()))
}

/// `/user/orgs` array was fed back: pair the stashed `/user` identity with the parsed
Expand Down
22 changes: 11 additions & 11 deletions busbar-auth-github/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@
//! [`LoginFlow`] per ticket and each [`Exchange`] one framed `exchange()` over the plugin's need.

use crate::guard::{collect_allowed_hosts, sanitize_hop_header, vet_hop_url, Refused};
use crate::held::Held;
use crate::{
build_github_authorize_url, build_orgs_get, build_principal, build_token_exchange,
build_userinfo_get, is_json_array, parse_access_token, parse_org_groups, parse_user, GhUser,
GitHubConfig,
};
use busbar_contract::auth::{CompleteLogin, LoginHop, LoginHttpResponse, LoginOutcome, Principal};
use busbar_contract::Redacted;
use serde_json::Value;
use std::collections::HashSet;
use std::fmt;
Expand Down Expand Up @@ -122,7 +122,7 @@ pub enum LoginStep {
/// The state 1.5.5 kept per flow between hops (its `PendingLogin`), owned by the flow.
#[derive(Default)]
struct FlowState {
access_token: Option<Redacted<String>>,
access_token: Option<Held<String>>,
user: Option<GhUser>,
}

Expand All @@ -136,7 +136,7 @@ pub struct LoginFlow {
inflight: Option<HopRequest>,
done: Option<LoginStep>,
/// The nonce the kernel minted at begin (`CompleteLoginIn.nonce`), bound to any `id_token`.
nonce: Option<Redacted<String>>,
nonce: Option<Held<String>>,
}

impl fmt::Debug for LoginFlow {
Expand All @@ -153,7 +153,7 @@ impl fmt::Debug for LoginFlow {
/// The GitHub login, holding its own client secret.
pub struct GithubLogin {
cfg: GitHubConfig,
client_secret: Option<Redacted<String>>,
client_secret: Option<Held<String>>,
allowed_hosts: HashSet<String>,
}

Expand Down Expand Up @@ -191,7 +191,7 @@ impl GithubLogin {
cfg,
client_secret: client_secret
.filter(|s| !s.is_empty())
.map(|s| Redacted::new(s.to_string())),
.map(|s| Held::new(s.to_string())),
allowed_hosts: collect_allowed_hosts(&raw),
})
}
Expand Down Expand Up @@ -235,7 +235,7 @@ impl GithubLogin {
turns: 0,
inflight: None,
done: None,
nonce: nonce.map(|n| Redacted::new(n.to_string())),
nonce: nonce.map(|n| Held::new(n.to_string())),
}
}

Expand Down Expand Up @@ -276,7 +276,7 @@ impl GithubLogin {
// trusted. GitHub issues no id_token, so this never fires against github.com.
if !id_token_nonce_binds(
&body,
flow.nonce.as_ref().map(|n| n.expose_secret().as_str()),
flow.nonce.as_ref().map(|n| n.reveal().as_str()),
) {
return finish(flow, LoginStep::SecurityCheckFailed);
}
Expand Down Expand Up @@ -325,7 +325,7 @@ impl GithubLogin {
if let Some(token) = parse_access_token(&resp.body) {
let hop = build_userinfo_get(&self.cfg, &token);
flow.state = FlowState {
access_token: Some(Redacted::new(token)),
access_token: Some(Held::new(token)),
user: None,
};
return LoginOutcome::Exchange(hop);
Expand All @@ -351,7 +351,7 @@ impl GithubLogin {
let Some(token) = &flow.state.access_token else {
return LoginOutcome::Reject;
};
let hop = build_orgs_get(&self.cfg, token.expose_secret());
let hop = build_orgs_get(&self.cfg, token.reveal());
flow.state.user = Some(user);
LoginOutcome::Exchange(hop)
}
Expand All @@ -367,9 +367,9 @@ impl GithubLogin {
match &self.client_secret {
Some(secret) => {
if let Some(slot) = form.iter_mut().find(|(k, _)| k == field) {
slot.1 = secret.expose_secret().clone();
slot.1 = secret.reveal().clone();
} else {
form.push((field.to_string(), secret.expose_secret().clone()));
form.push((field.to_string(), secret.reveal().clone()));
}
}
None => form.retain(|(k, _)| k != field),
Expand Down
Loading