g1t/services/packages/src/token.rs

187 lines7,242 bytesCodeBlame
1//! 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};
13use g1t_contracts::audit::AuditActor;
14use hmac::{Hmac, Mac};
15use serde::{Deserialize, Serialize};
16
17use crate::access::Action;
18
19type HmacSha256 = Hmac<sha2::Sha256>;
20
21const PREFIX: &str = "r1";
22/// How long a token lasts. Clients ask again when it runs out.
23pub const TTL_SECONDS: u64 = 15 * 60;
24
25/// What a token lets its holder do to one image.
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Grant {
28 /// `workspace/name`.
29 pub name: String,
30 pub actions: Vec<Action>,
31}
32
33/// What a token says.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct Claims {
36 /// Who it was given to, as audit entries name them; absent for an
37 /// anonymous one.
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub actor: Option<AuditActor>,
40 pub access: Vec<Grant>,
41 /// Seconds since the epoch.
42 pub iat: u64,
43 pub exp: u64,
44}
45
46impl Claims {
47 pub fn allows(&self, name: &str, action: Action) -> bool {
48 self.access
49 .iter()
50 .any(|grant| grant.name == name && grant.actions.contains(&action))
51 }
52}
53
54fn mac(secret: &[u8]) -> HmacSha256 {
55 HmacSha256::new_from_slice(secret).expect("HMAC takes a key of any length")
56}
57
58pub fn sign(claims: &Claims, secret: &[u8]) -> String {
59 let body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
60 let signed = format!("{PREFIX}.{body}");
61 let mut mac = mac(secret);
62 mac.update(signed.as_bytes());
63 format!("{signed}.{}", URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
64}
65
66/// The claims of a token this registry signed and that has not expired.
67pub fn verify(token: &str, secret: &[u8], now_seconds: u64) -> Option<Claims> {
68 let (signed, signature) = token.rsplit_once('.')?;
69 let body = signed.strip_prefix(PREFIX)?.strip_prefix('.')?;
70 let mut mac = mac(secret);
71 mac.update(signed.as_bytes());
72 mac.verify_slice(&URL_SAFE_NO_PAD.decode(signature).ok()?).ok()?;
73 let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(body).ok()?).ok()?;
74 (claims.exp > now_seconds).then_some(claims)
75}
76
77/// Whether `token` looks like one of this registry's, rather than a g1t
78/// access token sent as a bearer token.
79pub fn is_registry_token(token: &str) -> bool {
80 token.starts_with("r1.")
81}
82
83/// One `scope` a client asks a token for: `repository:<name>:<actions>`.
84/// Other resource types (`registry:catalog:*`) are left out.
85pub fn parse_scope(scope: &str) -> Option<(String, Vec<Action>)> {
86 let rest = scope.strip_prefix("repository:")?;
87 let (name, actions) = rest.rsplit_once(':')?;
88 let mut wanted: Vec<Action> = Vec::new();
89 for action in actions.split(',') {
90 let more: &[Action] = match action.trim() {
91 "pull" => &[Action::Pull],
92 "push" => &[Action::Push],
93 "delete" => &[Action::Delete],
94 "*" => &[Action::Pull, Action::Push, Action::Delete],
95 _ => &[],
96 };
97 for action in more {
98 if !wanted.contains(action) {
99 wanted.push(*action);
100 }
101 }
102 }
103 (!name.is_empty() && !wanted.is_empty()).then(|| (name.to_owned(), wanted))
104}
105
106/// The username and secret of an HTTP Basic `Authorization` header.
107pub fn basic(header: &str) -> Option<(String, String)> {
108 let (scheme, encoded) = header.trim().split_once(' ')?;
109 if !scheme.eq_ignore_ascii_case("basic") {
110 return None;
111 }
112 let decoded = String::from_utf8(STANDARD.decode(encoded.trim()).ok()?).ok()?;
113 let (username, secret) = decoded.split_once(':')?;
114 Some((username.to_owned(), secret.to_owned()))
115}
116
117/// The token of a `Bearer` `Authorization` header.
118pub fn bearer(header: &str) -> Option<&str> {
119 let (scheme, token) = header.trim().split_once(' ')?;
120 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn claims(exp: u64) -> Claims {
128 Claims {
129 actor: Some(AuditActor { actor: "ana".into(), actor_id: "usr_1".into(), ..AuditActor::default() }),
130 access: vec![Grant { name: "acme/web".into(), actions: vec![Action::Pull, Action::Push] }],
131 iat: 100,
132 exp,
133 }
134 }
135
136 #[test]
137 fn a_signed_token_reads_back_until_it_expires() {
138 let token = sign(&claims(1000), b"secret");
139 assert!(is_registry_token(&token));
140 let read = verify(&token, b"secret", 999).expect("valid");
141 assert_eq!(read, claims(1000));
142 assert!(read.allows("acme/web", Action::Push));
143 assert!(!read.allows("acme/web", Action::Delete));
144 assert!(!read.allows("acme/other", Action::Pull));
145 assert!(verify(&token, b"secret", 1000).is_none(), "expired");
146 assert!(verify(&token, b"other", 999).is_none(), "another key");
147 }
148
149 #[test]
150 fn a_changed_token_is_refused() {
151 let token = sign(&claims(1000), b"secret");
152 let (signed, signature) = token.rsplit_once('.').unwrap();
153 let mut forged = claims(1000);
154 forged.access[0].actions.push(Action::Delete);
155 let forged_body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap());
156 assert!(verify(&format!("r1.{forged_body}.{signature}"), b"secret", 0).is_none());
157 assert!(verify(&format!("{signed}.AAAA"), b"secret", 0).is_none());
158 assert!(verify("g1t_abc", b"secret", 0).is_none());
159 assert!(verify("", b"secret", 0).is_none());
160 }
161
162 #[test]
163 fn scopes_are_read_as_docker_writes_them() {
164 assert_eq!(
165 parse_scope("repository:acme/web/api:pull,push"),
166 Some(("acme/web/api".into(), vec![Action::Pull, Action::Push]))
167 );
168 assert_eq!(
169 parse_scope("repository:acme/web:*"),
170 Some(("acme/web".into(), vec![Action::Pull, Action::Push, Action::Delete]))
171 );
172 assert_eq!(parse_scope("repository:acme/web:pull,pull"), Some(("acme/web".into(), vec![Action::Pull])));
173 assert_eq!(parse_scope("registry:catalog:*"), None);
174 assert_eq!(parse_scope("repository:acme/web:unknown"), None);
175 }
176
177 #[test]
178 fn credentials_are_read_from_either_scheme() {
179 let header = format!("Basic {}", STANDARD.encode("ana:g1t_secret:with:colons"));
180 assert_eq!(basic(&header), Some(("ana".into(), "g1t_secret:with:colons".into())));
181 assert_eq!(basic("Bearer abc"), None);
182 assert_eq!(basic("Basic !!!"), None);
183 assert_eq!(bearer("Bearer r1.abc.def"), Some("r1.abc.def"));
184 assert_eq!(bearer("bearer g1t_x "), Some("g1t_x"));
185 assert_eq!(bearer("Basic abc"), None);
186 }
187}