Skip to content
236 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1//! The registry's bearer tokens, and the credentials they are given for.
2//!
3//! `docker login` sends Basic credentials to `GET /v2/token` (any
4//! username, a g1t token as the password) and gets back a short-lived
5//! token naming what it may do to which images, signed with
6//! PACKAGES_TOKEN_SECRET. Every later request carries that token, so
7//! nothing is asked of identity again until it expires. A token is
8//! `r1.<claims>.<signature>`: base64url JSON, then base64url HMAC-SHA256
9//! over `r1.<claims>`.
10
11use base64::Engine;
12use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
Merge packages: roles, Actions access, source label, soft delete, API13use g1t_contracts::User;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member14use g1t_contracts::audit::AuditActor;
15use hmac::{Hmac, Mac};
16use serde::{Deserialize, Serialize};
17
18use crate::access::Action;
19
20type HmacSha256 = Hmac<sha2::Sha256>;
21
22const PREFIX: &str = "r1";
23/// How long a token lasts. Clients ask again when it runs out.
24pub const TTL_SECONDS: u64 = 15 * 60;
25
26/// What a token lets its holder do to one image.
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Grant {
29 /// `workspace/name`.
30 pub name: String,
31 pub actions: Vec<Action>,
32}
33
34/// What a token says.
Merge packages: roles, Actions access, source label, soft delete, API35#[derive(Clone, Debug, Serialize, Deserialize)]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member36pub struct Claims {
37 /// Who it was given to, as audit entries name them; absent for an
38 /// anonymous one.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub actor: Option<AuditActor>,
Merge packages: roles, Actions access, source label, soft delete, API41 /// On a token that pushes: who it was given to, cut down to the
42 /// workspaces it pushes to ([`slim`]), so the repository an image's
43 /// source label names can be checked against their role there.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub viewer: Option<Box<User>>,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 pub access: Vec<Grant>,
47 /// Seconds since the epoch.
48 pub iat: u64,
49 pub exp: u64,
50}
51
Merge packages: roles, Actions access, source label, soft delete, API52impl PartialEq for Claims {
53 fn eq(&self, other: &Self) -> bool {
54 serde_json::to_value(self).ok() == serde_json::to_value(other).ok()
55 }
56}
57
58/// `user` with only what decisions in `workspaces` read: its memberships
59/// and repository grants there, and its token's limits.
60pub fn slim(user: &User, workspaces: &[String]) -> User {
61 let within = |slug: &str| workspaces.iter().any(|workspace| workspace.eq_ignore_ascii_case(slug));
62 User {
63 avatar: None,
64 workspaces: user.workspaces.iter().filter(|membership| within(&membership.slug)).cloned().collect(),
65 grants: user.grants.iter().filter(|grant| within(&grant.workspace)).cloned().collect(),
66 ..user.clone()
67 }
68}
69
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member70impl Claims {
71 pub fn allows(&self, name: &str, action: Action) -> bool {
72 self.access
73 .iter()
74 .any(|grant| grant.name == name && grant.actions.contains(&action))
75 }
76}
77
78fn mac(secret: &[u8]) -> HmacSha256 {
79 HmacSha256::new_from_slice(secret).expect("HMAC takes a key of any length")
80}
81
82pub fn sign(claims: &Claims, secret: &[u8]) -> String {
83 let body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
84 let signed = format!("{PREFIX}.{body}");
85 let mut mac = mac(secret);
86 mac.update(signed.as_bytes());
87 format!("{signed}.{}", URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
88}
89
90/// The claims of a token this registry signed and that has not expired.
91pub fn verify(token: &str, secret: &[u8], now_seconds: u64) -> Option<Claims> {
92 let (signed, signature) = token.rsplit_once('.')?;
93 let body = signed.strip_prefix(PREFIX)?.strip_prefix('.')?;
94 let mut mac = mac(secret);
95 mac.update(signed.as_bytes());
96 mac.verify_slice(&URL_SAFE_NO_PAD.decode(signature).ok()?).ok()?;
97 let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(body).ok()?).ok()?;
98 (claims.exp > now_seconds).then_some(claims)
99}
100
101/// Whether `token` looks like one of this registry's, rather than a g1t
102/// access token sent as a bearer token.
103pub fn is_registry_token(token: &str) -> bool {
104 token.starts_with("r1.")
105}
106
107/// One `scope` a client asks a token for: `repository:<name>:<actions>`.
108/// Other resource types (`registry:catalog:*`) are left out.
109pub fn parse_scope(scope: &str) -> Option<(String, Vec<Action>)> {
110 let rest = scope.strip_prefix("repository:")?;
111 let (name, actions) = rest.rsplit_once(':')?;
112 let mut wanted: Vec<Action> = Vec::new();
113 for action in actions.split(',') {
114 let more: &[Action] = match action.trim() {
115 "pull" => &[Action::Pull],
116 "push" => &[Action::Push],
117 "delete" => &[Action::Delete],
118 "*" => &[Action::Pull, Action::Push, Action::Delete],
119 _ => &[],
120 };
121 for action in more {
122 if !wanted.contains(action) {
123 wanted.push(*action);
124 }
125 }
126 }
127 (!name.is_empty() && !wanted.is_empty()).then(|| (name.to_owned(), wanted))
128}
129
130/// The username and secret of an HTTP Basic `Authorization` header.
131pub fn basic(header: &str) -> Option<(String, String)> {
132 let (scheme, encoded) = header.trim().split_once(' ')?;
133 if !scheme.eq_ignore_ascii_case("basic") {
134 return None;
135 }
136 let decoded = String::from_utf8(STANDARD.decode(encoded.trim()).ok()?).ok()?;
137 let (username, secret) = decoded.split_once(':')?;
138 Some((username.to_owned(), secret.to_owned()))
139}
140
141/// The token of a `Bearer` `Authorization` header.
142pub fn bearer(header: &str) -> Option<&str> {
143 let (scheme, token) = header.trim().split_once(' ')?;
144 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn claims(exp: u64) -> Claims {
152 Claims {
153 actor: Some(AuditActor { actor: "ana".into(), actor_id: "usr_1".into(), ..AuditActor::default() }),
Merge packages: roles, Actions access, source label, soft delete, API154 viewer: None,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member155 access: vec![Grant { name: "acme/web".into(), actions: vec![Action::Pull, Action::Push] }],
156 iat: 100,
157 exp,
158 }
159 }
160
161 #[test]
162 fn a_signed_token_reads_back_until_it_expires() {
163 let token = sign(&claims(1000), b"secret");
164 assert!(is_registry_token(&token));
165 let read = verify(&token, b"secret", 999).expect("valid");
166 assert_eq!(read, claims(1000));
167 assert!(read.allows("acme/web", Action::Push));
168 assert!(!read.allows("acme/web", Action::Delete));
169 assert!(!read.allows("acme/other", Action::Pull));
170 assert!(verify(&token, b"secret", 1000).is_none(), "expired");
171 assert!(verify(&token, b"other", 999).is_none(), "another key");
172 }
173
174 #[test]
Merge packages: roles, Actions access, source label, soft delete, API175 fn a_pushs_token_carries_who_pushes_cut_down_to_its_workspaces() {
176 use g1t_contracts::Membership;
177 use g1t_contracts::access::{RepoGrant, RepoRole};
178 let user = User {
179 id: "usr_1".into(),
180 username: "ana".into(),
181 workspaces: vec![Membership::member("acme"), Membership::member("other")],
182 grants: vec![
183 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Write, team: None },
184 RepoGrant { repo_id: "rep_9".into(), workspace: "elsewhere".into(), role: RepoRole::Admin, team: None },
185 ],
186 ..User::default()
187 };
188 let slim = slim(&user, &["acme".to_owned()]);
189 assert_eq!(slim.workspaces.len(), 1);
190 assert_eq!(slim.grants.len(), 1);
191 let mut with = claims(1000);
192 with.viewer = Some(Box::new(slim));
193 let token = sign(&with, b"secret");
194 let read = verify(&token, b"secret", 0).unwrap();
195 assert_eq!(read.viewer.unwrap().workspaces[0].slug, "acme");
196 }
197
198 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member199 fn a_changed_token_is_refused() {
200 let token = sign(&claims(1000), b"secret");
201 let (signed, signature) = token.rsplit_once('.').unwrap();
202 let mut forged = claims(1000);
203 forged.access[0].actions.push(Action::Delete);
204 let forged_body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap());
205 assert!(verify(&format!("r1.{forged_body}.{signature}"), b"secret", 0).is_none());
206 assert!(verify(&format!("{signed}.AAAA"), b"secret", 0).is_none());
207 assert!(verify("g1t_abc", b"secret", 0).is_none());
208 assert!(verify("", b"secret", 0).is_none());
209 }
210
211 #[test]
212 fn scopes_are_read_as_docker_writes_them() {
213 assert_eq!(
214 parse_scope("repository:acme/web/api:pull,push"),
215 Some(("acme/web/api".into(), vec![Action::Pull, Action::Push]))
216 );
217 assert_eq!(
218 parse_scope("repository:acme/web:*"),
219 Some(("acme/web".into(), vec![Action::Pull, Action::Push, Action::Delete]))
220 );
221 assert_eq!(parse_scope("repository:acme/web:pull,pull"), Some(("acme/web".into(), vec![Action::Pull])));
222 assert_eq!(parse_scope("registry:catalog:*"), None);
223 assert_eq!(parse_scope("repository:acme/web:unknown"), None);
224 }
225
226 #[test]
227 fn credentials_are_read_from_either_scheme() {
228 let header = format!("Basic {}", STANDARD.encode("ana:g1t_secret:with:colons"));
229 assert_eq!(basic(&header), Some(("ana".into(), "g1t_secret:with:colons".into())));
230 assert_eq!(basic("Bearer abc"), None);
231 assert_eq!(basic("Basic !!!"), None);
232 assert_eq!(bearer("Bearer r1.abc.def"), Some("r1.abc.def"));
233 assert_eq!(bearer("bearer g1t_x "), Some("g1t_x"));
234 assert_eq!(bearer("Basic abc"), None);
235 }
236}

This file's history is long; its oldest lines are credited to the oldest commit read.