Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +87 −2
| @@ -69,6 +69,18 @@ name = "api" | |||
| 69 | 69 | version = "0.1.0" | |
| 70 | 70 | ||
| 71 | 71 | [[package]] | |
| 72 | + | name = "argon2" | |
| 73 | + | version = "0.5.3" | |
| 74 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 76 | + | dependencies = [ | |
| 77 | + | "base64ct", | |
| 78 | + | "blake2", | |
| 79 | + | "cpufeatures 0.2.17", | |
| 80 | + | "password-hash", | |
| 81 | + | ] | |
| 82 | + | ||
| 83 | + | [[package]] | |
| 72 | 84 | name = "atoi" | |
| 73 | 85 | version = "2.0.0" | |
| 74 | 86 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -89,6 +101,17 @@ dependencies = [ | |||
| 89 | 101 | [[package]] | |
| 90 | 102 | name = "auth" | |
| 91 | 103 | version = "0.1.0" | |
| 104 | + | dependencies = [ | |
| 105 | + | "argon2", | |
| 106 | + | "base64", | |
| 107 | + | "hmac 0.12.1", | |
| 108 | + | "model", | |
| 109 | + | "rand_core 0.6.4", | |
| 110 | + | "serde", | |
| 111 | + | "serde_json", | |
| 112 | + | "sha2 0.10.9", | |
| 113 | + | "thiserror", | |
| 114 | + | ] | |
| 92 | 115 | ||
| 93 | 116 | [[package]] | |
| 94 | 117 | name = "autocfg" | |
| @@ -103,6 +126,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 103 | 126 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 104 | 127 | ||
| 105 | 128 | [[package]] | |
| 129 | + | name = "base64ct" | |
| 130 | + | version = "1.8.3" | |
| 131 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 133 | + | ||
| 134 | + | [[package]] | |
| 106 | 135 | name = "bitflags" | |
| 107 | 136 | version = "2.13.1" | |
| 108 | 137 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -112,6 +141,15 @@ dependencies = [ | |||
| 112 | 141 | ] | |
| 113 | 142 | ||
| 114 | 143 | [[package]] | |
| 144 | + | name = "blake2" | |
| 145 | + | version = "0.10.6" | |
| 146 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 147 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 148 | + | dependencies = [ | |
| 149 | + | "digest 0.10.7", | |
| 150 | + | ] | |
| 151 | + | ||
| 152 | + | [[package]] | |
| 115 | 153 | name = "block-buffer" | |
| 116 | 154 | version = "0.10.4" | |
| 117 | 155 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -346,6 +384,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |||
| 346 | 384 | dependencies = [ | |
| 347 | 385 | "block-buffer 0.10.4", | |
| 348 | 386 | "crypto-common 0.1.6", | |
| 387 | + | "subtle", | |
| 349 | 388 | ] | |
| 350 | 389 | ||
| 351 | 390 | [[package]] | |
| @@ -566,6 +605,17 @@ dependencies = [ | |||
| 566 | 605 | ||
| 567 | 606 | [[package]] | |
| 568 | 607 | name = "getrandom" | |
| 608 | + | version = "0.2.17" | |
| 609 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 610 | + | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 611 | + | dependencies = [ | |
| 612 | + | "cfg-if", | |
| 613 | + | "libc", | |
| 614 | + | "wasi", | |
| 615 | + | ] | |
| 616 | + | ||
| 617 | + | [[package]] | |
| 618 | + | name = "getrandom" | |
| 569 | 619 | version = "0.3.4" | |
| 570 | 620 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 571 | 621 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| @@ -640,7 +690,16 @@ version = "0.13.0" | |||
| 640 | 690 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 641 | 691 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | |
| 642 | 692 | dependencies = [ | |
| 643 | - | "hmac", | |
| 693 | + | "hmac 0.13.0", | |
| 694 | + | ] | |
| 695 | + | ||
| 696 | + | [[package]] | |
| 697 | + | name = "hmac" | |
| 698 | + | version = "0.12.1" | |
| 699 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 700 | + | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | |
| 701 | + | dependencies = [ | |
| 702 | + | "digest 0.10.7", | |
| 644 | 703 | ] | |
| 645 | 704 | ||
| 646 | 705 | [[package]] | |
| @@ -935,6 +994,17 @@ dependencies = [ | |||
| 935 | 994 | ] | |
| 936 | 995 | ||
| 937 | 996 | [[package]] | |
| 997 | + | name = "password-hash" | |
| 998 | + | version = "0.5.0" | |
| 999 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1000 | + | checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" | |
| 1001 | + | dependencies = [ | |
| 1002 | + | "base64ct", | |
| 1003 | + | "rand_core 0.6.4", | |
| 1004 | + | "subtle", | |
| 1005 | + | ] | |
| 1006 | + | ||
| 1007 | + | [[package]] | |
| 938 | 1008 | name = "pear" | |
| 939 | 1009 | version = "0.2.9" | |
| 940 | 1010 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1069,6 +1139,15 @@ dependencies = [ | |||
| 1069 | 1139 | ||
| 1070 | 1140 | [[package]] | |
| 1071 | 1141 | name = "rand_core" | |
| 1142 | + | version = "0.6.4" | |
| 1143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1144 | + | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" | |
| 1145 | + | dependencies = [ | |
| 1146 | + | "getrandom 0.2.17", | |
| 1147 | + | ] | |
| 1148 | + | ||
| 1149 | + | [[package]] | |
| 1150 | + | name = "rand_core" | |
| 1072 | 1151 | version = "0.9.5" | |
| 1073 | 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1074 | 1153 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | |
| @@ -1370,7 +1449,7 @@ dependencies = [ | |||
| 1370 | 1449 | "futures-util", | |
| 1371 | 1450 | "hex", | |
| 1372 | 1451 | "hkdf", | |
| 1373 | - | "hmac", | |
| 1452 | + | "hmac 0.13.0", | |
| 1374 | 1453 | "itoa", | |
| 1375 | 1454 | "log", | |
| 1376 | 1455 | "md-5", | |
| @@ -1451,6 +1530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1451 | 1530 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 1452 | 1531 | ||
| 1453 | 1532 | [[package]] | |
| 1533 | + | name = "subtle" | |
| 1534 | + | version = "2.6.1" | |
| 1535 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1536 | + | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 1537 | + | ||
| 1538 | + | [[package]] | |
| 1454 | 1539 | name = "syn" | |
| 1455 | 1540 | version = "2.0.119" | |
| 1456 | 1541 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +11 −0
| @@ -72,6 +72,17 @@ sqlx = { version = "0.9", default-features = false, features = [ | |||
| 72 | 72 | ulid = "1" | |
| 73 | 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } | |
| 74 | 74 | tempfile = "3" | |
| 75 | + | # Authentication primitives. Argon2id password hashing; SHA-256 for session and | |
| 76 | + | # invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring` | |
| 77 | + | # dependency, whose license set is not in our allow-list — see docs/decisions.md); | |
| 78 | + | # base64url for token encoding; OsRng for salts and session bytes. | |
| 79 | + | argon2 = { version = "0.5", features = ["std"] } | |
| 80 | + | sha2 = "0.10" | |
| 81 | + | hmac = "0.12" | |
| 82 | + | base64 = "0.22" | |
| 83 | + | rand_core = { version = "0.6", features = ["getrandom"] } | |
| 84 | + | # Interactive, non-echoing password entry for the CLI. | |
| 85 | + | rpassword = "7" | |
| 75 | 86 | ||
| 76 | 87 | [workspace.lints.rust] | |
| 77 | 88 | unsafe_code = "forbid" | |
crates/auth/Cargo.toml +9 −0
| @@ -10,6 +10,15 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | model = { workspace = true } | |
| 14 | + | argon2 = { workspace = true } | |
| 15 | + | sha2 = { workspace = true } | |
| 16 | + | hmac = { workspace = true } | |
| 17 | + | base64 = { workspace = true } | |
| 18 | + | rand_core = { workspace = true } | |
| 19 | + | serde = { workspace = true } | |
| 20 | + | serde_json = { workspace = true } | |
| 21 | + | thiserror = { workspace = true } | |
| 13 | 22 | ||
| 14 | 23 | [lints] | |
| 15 | 24 | workspace = true | |
crates/auth/src/access.rs +304 −0
| @@ -0,0 +1,304 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! The permission model: one function, [`access`], that every route funnels | |
| 6 | + | //! through. | |
| 7 | + | //! | |
| 8 | + | //! Keeping authorization in a single pure function is what makes it testable to | |
| 9 | + | //! exhaustion (see the matrix test below) and impossible for a route to get | |
| 10 | + | //! subtly wrong. The function takes everything it needs by value or reference and | |
| 11 | + | //! returns a verdict; the *consequences* of that verdict — in particular that | |
| 12 | + | //! [`Access::None`] on a private repo renders `404`, never `403`, so existence | |
| 13 | + | //! does not leak — belong to the web and API layers. | |
| 14 | + | ||
| 15 | + | use model::Repo; | |
| 16 | + | ||
| 17 | + | /// The access level a viewer has to a repository, in increasing order of power. | |
| 18 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 19 | + | pub enum Access { | |
| 20 | + | /// No access. On a private repo the caller MUST render `404`, not `403`. | |
| 21 | + | None, | |
| 22 | + | /// May read: browse, clone, and read-only API. | |
| 23 | + | Read, | |
| 24 | + | /// May read and push. | |
| 25 | + | Write, | |
| 26 | + | /// May read, push, and administer (settings, collaborators, deletion). | |
| 27 | + | Admin, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A stored collaborator permission — the `repo_collaborators.permission` column. | |
| 31 | + | /// | |
| 32 | + | /// Distinct from [`Access`] because a collaborator row can never say "no access" | |
| 33 | + | /// (its absence does); the two convert one way, [`Permission`] into [`Access`]. | |
| 34 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 35 | + | pub enum Permission { | |
| 36 | + | /// Read-only collaborator. | |
| 37 | + | Read, | |
| 38 | + | /// Read-write collaborator. | |
| 39 | + | Write, | |
| 40 | + | /// Administrative collaborator. | |
| 41 | + | Admin, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl Permission { | |
| 45 | + | /// The wire/database spelling (`read` | `write` | `admin`). | |
| 46 | + | #[must_use] | |
| 47 | + | pub fn as_str(self) -> &'static str { | |
| 48 | + | match self { | |
| 49 | + | Self::Read => "read", | |
| 50 | + | Self::Write => "write", | |
| 51 | + | Self::Admin => "admin", | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Parse a stored permission string, returning `None` for anything | |
| 56 | + | /// unrecognized (a corrupt row grants nothing rather than panicking). | |
| 57 | + | #[must_use] | |
| 58 | + | pub fn parse(s: &str) -> Option<Self> { | |
| 59 | + | match s { | |
| 60 | + | "read" => Some(Self::Read), | |
| 61 | + | "write" => Some(Self::Write), | |
| 62 | + | "admin" => Some(Self::Admin), | |
| 63 | + | _ => None, | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl From<Permission> for Access { | |
| 69 | + | fn from(permission: Permission) -> Self { | |
| 70 | + | match permission { | |
| 71 | + | Permission::Read => Self::Read, | |
| 72 | + | Permission::Write => Self::Write, | |
| 73 | + | Permission::Admin => Self::Admin, | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// The authenticated identity making a request, as far as authorization cares. | |
| 79 | + | /// | |
| 80 | + | /// Anonymous requests pass `None` in place of a `Viewer`. A disabled account must | |
| 81 | + | /// never be turned into a `Viewer` — that check belongs to the authentication | |
| 82 | + | /// layer, upstream of every call here. | |
| 83 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 84 | + | pub struct Viewer { | |
| 85 | + | /// The viewer's user id (`users.id`). | |
| 86 | + | pub id: String, | |
| 87 | + | /// Whether the viewer is a site administrator. | |
| 88 | + | pub is_admin: bool, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Resolve the access `viewer` has to `repo`. | |
| 92 | + | /// | |
| 93 | + | /// The rules are applied strictly in order; the first that matches wins: | |
| 94 | + | /// | |
| 95 | + | /// 1. a site admin gets [`Access::Admin`]; | |
| 96 | + | /// 2. the repo's owner gets [`Access::Admin`]; | |
| 97 | + | /// 3. an explicit collaborator gets exactly their stored permission; | |
| 98 | + | /// 4. a public repo, when the instance allows anonymous access, grants | |
| 99 | + | /// [`Access::Read`] to anyone; | |
| 100 | + | /// 5. otherwise [`Access::None`]. | |
| 101 | + | /// | |
| 102 | + | /// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if | |
| 103 | + | /// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`. | |
| 104 | + | /// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous | |
| 105 | + | /// access closes public repos to non-collaborators as well — matching the spec's | |
| 106 | + | /// private-by-default posture. | |
| 107 | + | #[must_use] | |
| 108 | + | pub fn access( | |
| 109 | + | viewer: Option<&Viewer>, | |
| 110 | + | repo: &Repo, | |
| 111 | + | collaborator: Option<Permission>, | |
| 112 | + | allow_anonymous: bool, | |
| 113 | + | ) -> Access { | |
| 114 | + | if let Some(v) = viewer { | |
| 115 | + | if v.is_admin { | |
| 116 | + | return Access::Admin; | |
| 117 | + | } | |
| 118 | + | if v.id == repo.owner_id { | |
| 119 | + | return Access::Admin; | |
| 120 | + | } | |
| 121 | + | if let Some(permission) = collaborator { | |
| 122 | + | return permission.into(); | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if !repo.is_private && allow_anonymous { | |
| 126 | + | return Access::Read; | |
| 127 | + | } | |
| 128 | + | Access::None | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[cfg(test)] | |
| 132 | + | mod tests { | |
| 133 | + | #![allow(clippy::unwrap_used)] | |
| 134 | + | ||
| 135 | + | use super::*; | |
| 136 | + | ||
| 137 | + | /// A repo owned by `owner`, with the given visibility. Only the fields | |
| 138 | + | /// `access` reads are meaningful; the rest are filler. | |
| 139 | + | fn repo(owner: &str, is_private: bool) -> Repo { | |
| 140 | + | Repo { | |
| 141 | + | id: "01reporeporeporeporeporepo".to_string(), | |
| 142 | + | owner_id: owner.to_string(), | |
| 143 | + | group_id: None, | |
| 144 | + | name: "r".to_string(), | |
| 145 | + | path: "r".to_string(), | |
| 146 | + | description: None, | |
| 147 | + | is_private, | |
| 148 | + | default_branch: "main".to_string(), | |
| 149 | + | archived_at: None, | |
| 150 | + | size_bytes: 0, | |
| 151 | + | pushed_at: None, | |
| 152 | + | created_at: 0, | |
| 153 | + | updated_at: 0, | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | fn viewer(id: &str, is_admin: bool) -> Viewer { | |
| 158 | + | Viewer { | |
| 159 | + | id: id.to_string(), | |
| 160 | + | is_admin, | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | #[test] | |
| 165 | + | fn permission_strings_round_trip() { | |
| 166 | + | for p in [Permission::Read, Permission::Write, Permission::Admin] { | |
| 167 | + | assert_eq!(Permission::parse(p.as_str()), Some(p)); | |
| 168 | + | } | |
| 169 | + | assert_eq!(Permission::parse("owner"), None); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn access_is_ordered() { | |
| 174 | + | assert!(Access::None < Access::Read); | |
| 175 | + | assert!(Access::Read < Access::Write); | |
| 176 | + | assert!(Access::Write < Access::Admin); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// One row of the access matrix: the inputs and the verdict they must yield. | |
| 180 | + | struct Case { | |
| 181 | + | viewer: Option<Viewer>, | |
| 182 | + | collaborator: Option<Permission>, | |
| 183 | + | is_private: bool, | |
| 184 | + | allow_anonymous: bool, | |
| 185 | + | expect: Access, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// The whole point of this crate: enumerate every combination of viewer kind, | |
| 189 | + | /// visibility, collaborator permission, and the anonymous-access flag, and | |
| 190 | + | /// assert the exact verdict. Nothing here is sampled — it is the full matrix. | |
| 191 | + | #[test] | |
| 192 | + | fn access_matrix_is_exhaustive() { | |
| 193 | + | // Viewer kinds, expressed relative to a repo owned by "owner". | |
| 194 | + | let admin = viewer("boss", true); | |
| 195 | + | let owner = viewer("owner", false); | |
| 196 | + | let other = viewer("stranger", false); | |
| 197 | + | ||
| 198 | + | let mut cases = Vec::new(); | |
| 199 | + | let mut push = |viewer: Option<&Viewer>, | |
| 200 | + | collaborator: Option<Permission>, | |
| 201 | + | is_private: bool, | |
| 202 | + | allow_anonymous: bool, | |
| 203 | + | expect: Access| { | |
| 204 | + | cases.push(Case { | |
| 205 | + | viewer: viewer.cloned(), | |
| 206 | + | collaborator, | |
| 207 | + | is_private, | |
| 208 | + | allow_anonymous, | |
| 209 | + | expect, | |
| 210 | + | }); | |
| 211 | + | }; | |
| 212 | + | ||
| 213 | + | // Rule 1: a site admin is Admin everywhere, regardless of every other | |
| 214 | + | // dimension. | |
| 215 | + | for &private in &[true, false] { | |
| 216 | + | for &anon in &[true, false] { | |
| 217 | + | for collab in [None, Some(Permission::Read)] { | |
| 218 | + | push(Some(&admin), collab, private, anon, Access::Admin); | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Rule 2: the owner is Admin everywhere, even with a lesser collaborator | |
| 224 | + | // row somehow present, and even when anonymous access is off. | |
| 225 | + | for &private in &[true, false] { | |
| 226 | + | for &anon in &[true, false] { | |
| 227 | + | for collab in [None, Some(Permission::Read), Some(Permission::Write)] { | |
| 228 | + | push(Some(&owner), collab, private, anon, Access::Admin); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Rule 3: an explicit collaborator gets exactly their permission — this | |
| 234 | + | // beats rule 4, so it holds even on a public repo, and is unaffected by | |
| 235 | + | // the anonymous flag or visibility. | |
| 236 | + | for &private in &[true, false] { | |
| 237 | + | for &anon in &[true, false] { | |
| 238 | + | push( | |
| 239 | + | Some(&other), | |
| 240 | + | Some(Permission::Read), | |
| 241 | + | private, | |
| 242 | + | anon, | |
| 243 | + | Access::Read, | |
| 244 | + | ); | |
| 245 | + | push( | |
| 246 | + | Some(&other), | |
| 247 | + | Some(Permission::Write), | |
| 248 | + | private, | |
| 249 | + | anon, | |
| 250 | + | Access::Write, | |
| 251 | + | ); | |
| 252 | + | push( | |
| 253 | + | Some(&other), | |
| 254 | + | Some(Permission::Admin), | |
| 255 | + | private, | |
| 256 | + | anon, | |
| 257 | + | Access::Admin, | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous) | |
| 263 | + | // reads a public repo only when anonymous access is allowed; a private | |
| 264 | + | // repo is always None; and anon-off closes public repos too. | |
| 265 | + | for viewer in [Some(&other), None] { | |
| 266 | + | // Public repo, anonymous allowed -> Read. | |
| 267 | + | push(viewer, None, false, true, Access::Read); | |
| 268 | + | // Public repo, anonymous disallowed -> None. | |
| 269 | + | push(viewer, None, false, false, Access::None); | |
| 270 | + | // Private repo -> None regardless of the flag. | |
| 271 | + | push(viewer, None, true, true, Access::None); | |
| 272 | + | push(viewer, None, true, false, Access::None); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | for case in &cases { | |
| 276 | + | let repo = repo("owner", case.is_private); | |
| 277 | + | let got = access( | |
| 278 | + | case.viewer.as_ref(), | |
| 279 | + | &repo, | |
| 280 | + | case.collaborator, | |
| 281 | + | case.allow_anonymous, | |
| 282 | + | ); | |
| 283 | + | assert_eq!( | |
| 284 | + | got, case.expect, | |
| 285 | + | "viewer={:?} collab={:?} private={} anon={}", | |
| 286 | + | case.viewer, case.collaborator, case.is_private, case.allow_anonymous | |
| 287 | + | ); | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn private_repo_never_leaks_to_a_stranger() { | |
| 293 | + | // The security-critical invariant, called out on its own: no combination | |
| 294 | + | // of a non-privileged stranger on a private repo yields anything but None. | |
| 295 | + | let stranger = viewer("stranger", false); | |
| 296 | + | for &anon in &[true, false] { | |
| 297 | + | assert_eq!( | |
| 298 | + | access(Some(&stranger), &repo("owner", true), None, anon), | |
| 299 | + | Access::None | |
| 300 | + | ); | |
| 301 | + | assert_eq!(access(None, &repo("owner", true), None, anon), Access::None); | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } | |
crates/auth/src/lib.rs +54 −1
| @@ -2,4 +2,57 @@ | |||
| 2 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | - | //! Authentication and authorization: argon2id, sessions, JWTs, and `access()`. | |
| 5 | + | //! Authentication and authorization primitives. | |
| 6 | + | //! | |
| 7 | + | //! This crate is deliberately I/O-free: it turns bytes into decisions and never | |
| 8 | + | //! touches the database or the network. The [`store`](../store/index.html) crate | |
| 9 | + | //! persists what these functions produce (password hashes, session-id digests, | |
| 10 | + | //! token rows) and the `web`/`api`/`ssh` crates call in at request time. | |
| 11 | + | //! | |
| 12 | + | //! Four concerns, one per module: | |
| 13 | + | //! | |
| 14 | + | //! * [`password`] — Argon2id hashing and constant-time verification. A missing | |
| 15 | + | //! hash still does the work of a real verify so account existence never leaks | |
| 16 | + | //! through timing. | |
| 17 | + | //! * [`session`] — opaque session tokens: 32 bytes of OS randomness handed to the | |
| 18 | + | //! browser, only their SHA-256 kept server-side. | |
| 19 | + | //! * [`token`] — HS256 JSON Web Tokens for the API, minted and verified against | |
| 20 | + | //! the instance secret. Revocation is a database concern; this module only | |
| 21 | + | //! proves a token is authentic and unexpired. | |
| 22 | + | //! * [`scope`] and [`access`] — the authorization vocabulary. [`access::access`] | |
| 23 | + | //! is the single, exhaustively tested function every route funnels through. | |
| 24 | + | ||
| 25 | + | pub mod access; | |
| 26 | + | pub mod password; | |
| 27 | + | pub mod scope; | |
| 28 | + | pub mod session; | |
| 29 | + | pub mod token; | |
| 30 | + | ||
| 31 | + | pub use access::{Access, Permission, Viewer, access}; | |
| 32 | + | pub use password::{HashParams, hash_password, verify_password}; | |
| 33 | + | pub use scope::{Scope, format_scopes, parse_scopes}; | |
| 34 | + | pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id}; | |
| 35 | + | pub use token::{Claims, mint_jwt, verify_jwt}; | |
| 36 | + | ||
| 37 | + | /// Errors produced by the authentication primitives. | |
| 38 | + | /// | |
| 39 | + | /// Variants stay coarse on purpose: a caller authenticating a request should not | |
| 40 | + | /// be able to tell *why* a credential failed (bad signature vs. expired vs. | |
| 41 | + | /// malformed), so verification failures collapse into a single | |
| 42 | + | /// [`AuthError::InvalidToken`]. | |
| 43 | + | #[derive(Debug, thiserror::Error)] | |
| 44 | + | pub enum AuthError { | |
| 45 | + | /// A password could not be hashed, or a stored hash could not be parsed. | |
| 46 | + | /// Carries the library's message; never contains the password itself. | |
| 47 | + | #[error("password hashing failed: {0}")] | |
| 48 | + | Hash(String), | |
| 49 | + | ||
| 50 | + | /// A JWT was malformed, carried a bad signature, or had expired. One variant | |
| 51 | + | /// for every failure mode so nothing distinguishes them to the caller. | |
| 52 | + | #[error("invalid or expired token")] | |
| 53 | + | InvalidToken, | |
| 54 | + | ||
| 55 | + | /// A scope string held a token outside the known set. | |
| 56 | + | #[error("unknown scope {0:?}")] | |
| 57 | + | UnknownScope(String), | |
| 58 | + | } | |
crates/auth/src/password.rs +151 −0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Argon2id password hashing and verification. | |
| 6 | + | //! | |
| 7 | + | //! Hashes are stored in PHC string format, which embeds the algorithm, version, | |
| 8 | + | //! and cost parameters — so a stored hash is self-describing and verification | |
| 9 | + | //! never needs the config that produced it. That matters when the operator tunes | |
| 10 | + | //! the cost parameters over time: old hashes keep verifying against the costs | |
| 11 | + | //! they were made with. | |
| 12 | + | ||
| 13 | + | use argon2::password_hash::rand_core::OsRng; | |
| 14 | + | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; | |
| 15 | + | use argon2::{Algorithm, Argon2, Params, Version}; | |
| 16 | + | ||
| 17 | + | use crate::AuthError; | |
| 18 | + | ||
| 19 | + | /// Argon2id cost parameters, mirroring the `[auth]` config keys. Kept as a plain | |
| 20 | + | /// value type so the `auth` crate need not depend on `config`. | |
| 21 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | + | pub struct HashParams { | |
| 23 | + | /// Memory cost, in KiB (`auth.argon2_m_cost`). | |
| 24 | + | pub m_cost: u32, | |
| 25 | + | /// Time cost — the number of iterations (`auth.argon2_t_cost`). | |
| 26 | + | pub t_cost: u32, | |
| 27 | + | /// Degree of parallelism — lanes (`auth.argon2_p_cost`). | |
| 28 | + | pub p_cost: u32, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl Default for HashParams { | |
| 32 | + | /// The spec's baseline: 19 MiB, two iterations, one lane. | |
| 33 | + | fn default() -> Self { | |
| 34 | + | Self { | |
| 35 | + | m_cost: 19_456, | |
| 36 | + | t_cost: 2, | |
| 37 | + | p_cost: 1, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl HashParams { | |
| 43 | + | /// Build the concrete Argon2 hasher for these parameters. | |
| 44 | + | fn hasher(self) -> Result<Argon2<'static>, AuthError> { | |
| 45 | + | let params = Params::new(self.m_cost, self.t_cost, self.p_cost, None) | |
| 46 | + | .map_err(|e| AuthError::Hash(e.to_string()))?; | |
| 47 | + | Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Hash `password` with Argon2id, returning a PHC string ready to store. | |
| 52 | + | /// | |
| 53 | + | /// A fresh random salt is drawn from the OS CSPRNG for every call, so identical | |
| 54 | + | /// passwords produce different hashes. | |
| 55 | + | /// | |
| 56 | + | /// # Errors | |
| 57 | + | /// | |
| 58 | + | /// Returns [`AuthError::Hash`] if the cost parameters are invalid or the hasher | |
| 59 | + | /// fails. | |
| 60 | + | pub fn hash_password(password: &str, params: HashParams) -> Result<String, AuthError> { | |
| 61 | + | let salt = SaltString::generate(&mut OsRng); | |
| 62 | + | let hash = params | |
| 63 | + | .hasher()? | |
| 64 | + | .hash_password(password.as_bytes(), &salt) | |
| 65 | + | .map_err(|e| AuthError::Hash(e.to_string()))?; | |
| 66 | + | Ok(hash.to_string()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Verify `password` against a stored PHC hash. | |
| 70 | + | /// | |
| 71 | + | /// `stored` is [`Option`] because a user may have no password yet (the invite is | |
| 72 | + | /// still outstanding). In that case — and whenever the stored hash fails to parse | |
| 73 | + | /// — we still run a full Argon2 computation against a throwaway salt before | |
| 74 | + | /// returning `false`, so the "no such credential" path costs the same as a real | |
| 75 | + | /// verification and account state does not leak through response timing. | |
| 76 | + | /// | |
| 77 | + | /// Returns `true` only when the password matches a well-formed stored hash. | |
| 78 | + | #[must_use] | |
| 79 | + | pub fn verify_password(password: &str, stored: Option<&str>) -> bool { | |
| 80 | + | let Some(hash) = stored else { | |
| 81 | + | dummy_verify(password); | |
| 82 | + | return false; | |
| 83 | + | }; | |
| 84 | + | let Ok(parsed) = PasswordHash::new(hash) else { | |
| 85 | + | dummy_verify(password); | |
| 86 | + | return false; | |
| 87 | + | }; | |
| 88 | + | // Argon2id verification reads its parameters from the PHC string itself, so a | |
| 89 | + | // default-configured verifier honours whatever costs the hash was made with. | |
| 90 | + | Argon2::default() | |
| 91 | + | .verify_password(password.as_bytes(), &parsed) | |
| 92 | + | .is_ok() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Perform throwaway hashing work to equalise the timing of the failure paths in | |
| 96 | + | /// [`verify_password`]. The result is intentionally discarded. | |
| 97 | + | fn dummy_verify(password: &str) { | |
| 98 | + | let _ = hash_password(password, HashParams::default()); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[cfg(test)] | |
| 102 | + | mod tests { | |
| 103 | + | #![allow(clippy::unwrap_used)] | |
| 104 | + | ||
| 105 | + | use super::*; | |
| 106 | + | ||
| 107 | + | // Cheap parameters keep the hashing tests fast; production costs come from | |
| 108 | + | // config. The minimum Argon2 memory cost is 8 KiB. | |
| 109 | + | const FAST: HashParams = HashParams { | |
| 110 | + | m_cost: 8, | |
| 111 | + | t_cost: 1, | |
| 112 | + | p_cost: 1, | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn hash_is_phc_argon2id_and_verifies() { | |
| 117 | + | let hash = hash_password("correct horse", FAST).unwrap(); | |
| 118 | + | assert!(hash.starts_with("$argon2id$"), "PHC prefix, got {hash}"); | |
| 119 | + | assert!(verify_password("correct horse", Some(&hash))); | |
| 120 | + | assert!(!verify_password("wrong horse", Some(&hash))); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn hashing_is_salted() { | |
| 125 | + | let a = hash_password("same", FAST).unwrap(); | |
| 126 | + | let b = hash_password("same", FAST).unwrap(); | |
| 127 | + | assert_ne!(a, b, "distinct salts should yield distinct hashes"); | |
| 128 | + | assert!(verify_password("same", Some(&a))); | |
| 129 | + | assert!(verify_password("same", Some(&b))); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | #[test] | |
| 133 | + | fn absent_hash_never_verifies() { | |
| 134 | + | assert!(!verify_password("anything", None)); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn malformed_hash_never_verifies() { | |
| 139 | + | assert!(!verify_password("anything", Some("not-a-phc-string"))); | |
| 140 | + | assert!(!verify_password("anything", Some("$argon2id$garbage"))); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn params_round_trip_through_the_hash() { | |
| 145 | + | // A hash made with non-default costs still verifies with the default | |
| 146 | + | // verifier, because the costs travel inside the PHC string. | |
| 147 | + | let hash = hash_password("pw", FAST).unwrap(); | |
| 148 | + | assert!(hash.contains("m=8"), "params embedded, got {hash}"); | |
| 149 | + | assert!(verify_password("pw", Some(&hash))); | |
| 150 | + | } | |
| 151 | + | } | |
crates/auth/src/scope.rs +135 −0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! API token scopes. | |
| 6 | + | //! | |
| 7 | + | //! A token carries a comma-separated scope list, stored verbatim in | |
| 8 | + | //! `api_tokens.scopes` and echoed inside the JWT. The wire spelling | |
| 9 | + | //! (`repo:read`, …) is the single source of truth; this module is the only place | |
| 10 | + | //! that maps those strings to and from the [`Scope`] enum. | |
| 11 | + | ||
| 12 | + | use crate::AuthError; | |
| 13 | + | ||
| 14 | + | /// A capability a token may grant. The string forms match the spec exactly and | |
| 15 | + | /// are what appears in config, the CLI, and the JWT payload. | |
| 16 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 17 | + | pub enum Scope { | |
| 18 | + | /// Read repository contents (clone, browse, read API). | |
| 19 | + | RepoRead, | |
| 20 | + | /// Push to repositories and mutate their contents. | |
| 21 | + | RepoWrite, | |
| 22 | + | /// Administer repositories (settings, collaborators, deletion). | |
| 23 | + | RepoAdmin, | |
| 24 | + | /// Read user and account information. | |
| 25 | + | UserRead, | |
| 26 | + | /// Mutate user and account information. | |
| 27 | + | UserWrite, | |
| 28 | + | /// Full administrative access, equivalent to a site admin. | |
| 29 | + | Admin, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl Scope { | |
| 33 | + | /// The canonical wire spelling. | |
| 34 | + | #[must_use] | |
| 35 | + | pub fn as_str(self) -> &'static str { | |
| 36 | + | match self { | |
| 37 | + | Self::RepoRead => "repo:read", | |
| 38 | + | Self::RepoWrite => "repo:write", | |
| 39 | + | Self::RepoAdmin => "repo:admin", | |
| 40 | + | Self::UserRead => "user:read", | |
| 41 | + | Self::UserWrite => "user:write", | |
| 42 | + | Self::Admin => "admin", | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Parse a single scope token. | |
| 47 | + | /// | |
| 48 | + | /// # Errors | |
| 49 | + | /// | |
| 50 | + | /// Returns [`AuthError::UnknownScope`] if `s` is not a known scope. Leading | |
| 51 | + | /// and trailing whitespace is tolerated so a hand-written `--scopes a, b` | |
| 52 | + | /// list parses. | |
| 53 | + | pub fn parse(s: &str) -> Result<Self, AuthError> { | |
| 54 | + | match s.trim() { | |
| 55 | + | "repo:read" => Ok(Self::RepoRead), | |
| 56 | + | "repo:write" => Ok(Self::RepoWrite), | |
| 57 | + | "repo:admin" => Ok(Self::RepoAdmin), | |
| 58 | + | "user:read" => Ok(Self::UserRead), | |
| 59 | + | "user:write" => Ok(Self::UserWrite), | |
| 60 | + | "admin" => Ok(Self::Admin), | |
| 61 | + | other => Err(AuthError::UnknownScope(other.to_string())), | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Parse a comma-separated scope list. Empty entries (a trailing comma, or an | |
| 67 | + | /// entirely blank string) are skipped rather than rejected. | |
| 68 | + | /// | |
| 69 | + | /// # Errors | |
| 70 | + | /// | |
| 71 | + | /// Returns [`AuthError::UnknownScope`] on the first unrecognized entry. | |
| 72 | + | pub fn parse_scopes(list: &str) -> Result<Vec<Scope>, AuthError> { | |
| 73 | + | list.split(',') | |
| 74 | + | .map(str::trim) | |
| 75 | + | .filter(|s| !s.is_empty()) | |
| 76 | + | .map(Scope::parse) | |
| 77 | + | .collect() | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Render scopes as the canonical comma-separated string stored in the database. | |
| 81 | + | #[must_use] | |
| 82 | + | pub fn format_scopes(scopes: &[Scope]) -> String { | |
| 83 | + | scopes | |
| 84 | + | .iter() | |
| 85 | + | .map(|s| s.as_str()) | |
| 86 | + | .collect::<Vec<_>>() | |
| 87 | + | .join(",") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[cfg(test)] | |
| 91 | + | mod tests { | |
| 92 | + | #![allow(clippy::unwrap_used)] | |
| 93 | + | ||
| 94 | + | use super::*; | |
| 95 | + | ||
| 96 | + | #[test] | |
| 97 | + | fn every_scope_round_trips() { | |
| 98 | + | for scope in [ | |
| 99 | + | Scope::RepoRead, | |
| 100 | + | Scope::RepoWrite, | |
| 101 | + | Scope::RepoAdmin, | |
| 102 | + | Scope::UserRead, | |
| 103 | + | Scope::UserWrite, | |
| 104 | + | Scope::Admin, | |
| 105 | + | ] { | |
| 106 | + | assert_eq!(Scope::parse(scope.as_str()).unwrap(), scope); | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn list_parses_and_reformats_canonically() { | |
| 112 | + | let scopes = parse_scopes(" repo:read, repo:write ,admin ").unwrap(); | |
| 113 | + | assert_eq!(scopes, [Scope::RepoRead, Scope::RepoWrite, Scope::Admin]); | |
| 114 | + | assert_eq!(format_scopes(&scopes), "repo:read,repo:write,admin"); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn blank_and_trailing_commas_are_ignored() { | |
| 119 | + | assert!(parse_scopes("").unwrap().is_empty()); | |
| 120 | + | assert_eq!( | |
| 121 | + | parse_scopes("repo:read,,").unwrap(), | |
| 122 | + | [Scope::RepoRead], | |
| 123 | + | "empty entries dropped, not rejected" | |
| 124 | + | ); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[test] | |
| 128 | + | fn unknown_scope_is_rejected() { | |
| 129 | + | let err = parse_scopes("repo:read,repo:destroy").unwrap_err(); | |
| 130 | + | assert!( | |
| 131 | + | matches!(&err, AuthError::UnknownScope(s) if s == "repo:destroy"), | |
| 132 | + | "got {err:?}" | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | } | |
crates/auth/src/session.rs +94 −0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Opaque session tokens. | |
| 6 | + | //! | |
| 7 | + | //! A session token is 32 bytes of OS randomness, base64url-encoded and handed to | |
| 8 | + | //! the browser in a cookie. The database stores **only** the SHA-256 of that | |
| 9 | + | //! cookie value as the session-row id, so a leak of the database does not leak | |
| 10 | + | //! usable session tokens: an attacker would still need the pre-image. Lookup | |
| 11 | + | //! hashes the presented cookie the same way and matches on the digest. | |
| 12 | + | ||
| 13 | + | use base64::Engine; | |
| 14 | + | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 15 | + | use rand_core::{OsRng, RngCore}; | |
| 16 | + | use sha2::{Digest, Sha256}; | |
| 17 | + | ||
| 18 | + | /// Number of random bytes behind a session token, before encoding. | |
| 19 | + | pub const SESSION_TOKEN_BYTES: usize = 32; | |
| 20 | + | ||
| 21 | + | /// A freshly minted session token, split into the two halves that live in | |
| 22 | + | /// different places. | |
| 23 | + | #[derive(Debug, Clone)] | |
| 24 | + | pub struct SessionToken { | |
| 25 | + | /// The value placed in the user's cookie. Secret — treat like a password. | |
| 26 | + | pub cookie: String, | |
| 27 | + | /// The SHA-256 of [`SessionToken::cookie`], stored as the `sessions.id` | |
| 28 | + | /// primary key. Safe to persist and log. | |
| 29 | + | pub id: String, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Mint a new session token: draw 32 bytes from the OS CSPRNG, encode them for | |
| 33 | + | /// the cookie, and derive the id the database will key on. | |
| 34 | + | #[must_use] | |
| 35 | + | pub fn new_session_token() -> SessionToken { | |
| 36 | + | let mut bytes = [0u8; SESSION_TOKEN_BYTES]; | |
| 37 | + | OsRng.fill_bytes(&mut bytes); | |
| 38 | + | let cookie = URL_SAFE_NO_PAD.encode(bytes); | |
| 39 | + | let id = session_id(&cookie); | |
| 40 | + | SessionToken { cookie, id } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Derive the `sessions.id` for a presented cookie value. This is the one place | |
| 44 | + | /// the cookie-to-id mapping is defined; login (minting) and every subsequent | |
| 45 | + | /// request (lookup) both route through it so they cannot disagree. | |
| 46 | + | #[must_use] | |
| 47 | + | pub fn session_id(cookie: &str) -> String { | |
| 48 | + | let digest = Sha256::digest(cookie.as_bytes()); | |
| 49 | + | hex(&digest) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Lowercase hex encoding of a byte slice. | |
| 53 | + | fn hex(bytes: &[u8]) -> String { | |
| 54 | + | use std::fmt::Write as _; | |
| 55 | + | let mut out = String::with_capacity(bytes.len() * 2); | |
| 56 | + | for b in bytes { | |
| 57 | + | // Writing to a String is infallible. | |
| 58 | + | let _ = write!(out, "{b:02x}"); | |
| 59 | + | } | |
| 60 | + | out | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[cfg(test)] | |
| 64 | + | mod tests { | |
| 65 | + | use super::*; | |
| 66 | + | ||
| 67 | + | #[test] | |
| 68 | + | fn tokens_are_unique_and_hex_ided() { | |
| 69 | + | let a = new_session_token(); | |
| 70 | + | let b = new_session_token(); | |
| 71 | + | assert_ne!(a.cookie, b.cookie, "each token draws fresh randomness"); | |
| 72 | + | assert_ne!(a.id, b.id); | |
| 73 | + | // SHA-256 hex is 64 characters, all hex digits. | |
| 74 | + | assert_eq!(a.id.len(), 64); | |
| 75 | + | assert!(a.id.bytes().all(|c| c.is_ascii_hexdigit())); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn id_is_a_pure_function_of_the_cookie() { | |
| 80 | + | let token = new_session_token(); | |
| 81 | + | assert_eq!( | |
| 82 | + | session_id(&token.cookie), | |
| 83 | + | token.id, | |
| 84 | + | "lookup must reproduce the stored id" | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn different_cookies_hash_differently() { | |
| 90 | + | assert_ne!(session_id("a"), session_id("b")); | |
| 91 | + | // Known-answer: SHA-256("") has a well-known digest prefix. | |
| 92 | + | assert!(session_id("").starts_with("e3b0c44298fc1c14")); | |
| 93 | + | } | |
| 94 | + | } | |
crates/auth/src/token.rs +216 −0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! HS256 JSON Web Tokens for the API. | |
| 6 | + | //! | |
| 7 | + | //! We hand-roll the (small, well-specified) HS256 construction rather than pull | |
| 8 | + | //! in a JWT library, because the mainstream crates depend on `ring`, whose | |
| 9 | + | //! license set falls outside this project's allow-list. The surface we need is | |
| 10 | + | //! tiny: base64url of a fixed header and a JSON payload, an HMAC-SHA256 over the | |
| 11 | + | //! two, and constant-time verification of the same. | |
| 12 | + | //! | |
| 13 | + | //! Signature and expiry are all this module proves. **Revocation is not checked | |
| 14 | + | //! here** — a valid signature only means the token is authentic and unexpired. | |
| 15 | + | //! Callers MUST additionally confirm the `jti` row in `api_tokens` has | |
| 16 | + | //! `revoked_at IS NULL`; that database check is what makes revocation real. | |
| 17 | + | ||
| 18 | + | use base64::Engine; | |
| 19 | + | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 20 | + | use hmac::{Hmac, Mac}; | |
| 21 | + | use serde::{Deserialize, Serialize}; | |
| 22 | + | use sha2::Sha256; | |
| 23 | + | ||
| 24 | + | use crate::AuthError; | |
| 25 | + | ||
| 26 | + | type HmacSha256 = Hmac<Sha256>; | |
| 27 | + | ||
| 28 | + | /// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`. | |
| 29 | + | /// Pre-encoded so minting never re-serializes it, and matched exactly on verify | |
| 30 | + | /// so a token advertising any other algorithm is rejected out of hand (closing | |
| 31 | + | /// the classic "alg" confusion attack). | |
| 32 | + | const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; | |
| 33 | + | ||
| 34 | + | /// The claims carried by an API token. Field names are the JWT wire names. | |
| 35 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 36 | + | pub struct Claims { | |
| 37 | + | /// Subject: the owning user's id (`users.id`). | |
| 38 | + | pub sub: String, | |
| 39 | + | /// JWT id: the `api_tokens` row id, checked against `revoked_at` on every | |
| 40 | + | /// request to enforce revocation. | |
| 41 | + | pub jti: String, | |
| 42 | + | /// Comma-separated scope list (see [`crate::scope`]). | |
| 43 | + | pub scopes: String, | |
| 44 | + | /// Issued-at, Unix seconds. | |
| 45 | + | pub iat: i64, | |
| 46 | + | /// Expiry, Unix seconds. A token is rejected once `exp <= now`. | |
| 47 | + | pub exp: i64, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Mint a signed HS256 token for `claims`, signed with the instance `secret`. | |
| 51 | + | /// | |
| 52 | + | /// # Errors | |
| 53 | + | /// | |
| 54 | + | /// Returns [`AuthError::Hash`] if the claims cannot be serialized (in practice | |
| 55 | + | /// only an allocation failure), reusing that variant to avoid widening the error | |
| 56 | + | /// enum for an all-but-impossible case. | |
| 57 | + | pub fn mint_jwt(secret: &str, claims: &Claims) -> Result<String, AuthError> { | |
| 58 | + | let payload_json = | |
| 59 | + | serde_json::to_vec(claims).map_err(|e| AuthError::Hash(format!("claims: {e}")))?; | |
| 60 | + | let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); | |
| 61 | + | let signing_input = format!("{HEADER_B64}.{payload_b64}"); | |
| 62 | + | let signature = sign(secret, signing_input.as_bytes()); | |
| 63 | + | Ok(format!("{signing_input}.{signature}")) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Verify a token's signature and expiry against `secret` and the current time | |
| 67 | + | /// `now` (Unix seconds), returning its claims on success. | |
| 68 | + | /// | |
| 69 | + | /// Every failure — wrong shape, unexpected algorithm, bad signature, malformed | |
| 70 | + | /// payload, or expiry — collapses to [`AuthError::InvalidToken`] so nothing about | |
| 71 | + | /// *why* a token was rejected reaches the caller. | |
| 72 | + | /// | |
| 73 | + | /// # Errors | |
| 74 | + | /// | |
| 75 | + | /// Returns [`AuthError::InvalidToken`] on any verification failure. | |
| 76 | + | pub fn verify_jwt(secret: &str, token: &str, now: i64) -> Result<Claims, AuthError> { | |
| 77 | + | let mut parts = token.split('.'); | |
| 78 | + | let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) = | |
| 79 | + | (parts.next(), parts.next(), parts.next(), parts.next()) | |
| 80 | + | else { | |
| 81 | + | return Err(AuthError::InvalidToken); | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | // Reject anything not signed with our fixed HS256 header before spending an | |
| 85 | + | // HMAC on it. | |
| 86 | + | if header_b64 != HEADER_B64 { | |
| 87 | + | return Err(AuthError::InvalidToken); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Constant-time signature check via the MAC's own verifier. | |
| 91 | + | let provided = URL_SAFE_NO_PAD | |
| 92 | + | .decode(signature_b64) | |
| 93 | + | .map_err(|_| AuthError::InvalidToken)?; | |
| 94 | + | let signing_input = format!("{header_b64}.{payload_b64}"); | |
| 95 | + | let mut mac = | |
| 96 | + | HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| AuthError::InvalidToken)?; | |
| 97 | + | mac.update(signing_input.as_bytes()); | |
| 98 | + | mac.verify_slice(&provided) | |
| 99 | + | .map_err(|_| AuthError::InvalidToken)?; | |
| 100 | + | ||
| 101 | + | // Signature is good; now decode and check the claims. | |
| 102 | + | let payload = URL_SAFE_NO_PAD | |
| 103 | + | .decode(payload_b64) | |
| 104 | + | .map_err(|_| AuthError::InvalidToken)?; | |
| 105 | + | let claims: Claims = serde_json::from_slice(&payload).map_err(|_| AuthError::InvalidToken)?; | |
| 106 | + | if claims.exp <= now { | |
| 107 | + | return Err(AuthError::InvalidToken); | |
| 108 | + | } | |
| 109 | + | Ok(claims) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Compute the base64url HMAC-SHA256 signature of `message` under `secret`. | |
| 113 | + | fn sign(secret: &str, message: &[u8]) -> String { | |
| 114 | + | // `new_from_slice` only fails for key sizes HMAC cannot accept, which is | |
| 115 | + | // none — every byte length is valid — so this cannot error in practice. | |
| 116 | + | let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { | |
| 117 | + | return String::new(); | |
| 118 | + | }; | |
| 119 | + | mac.update(message); | |
| 120 | + | URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[cfg(test)] | |
| 124 | + | mod tests { | |
| 125 | + | #![allow(clippy::unwrap_used)] | |
| 126 | + | ||
| 127 | + | use super::*; | |
| 128 | + | ||
| 129 | + | fn claims(exp: i64) -> Claims { | |
| 130 | + | Claims { | |
| 131 | + | sub: "01useruseruseruseruseruser".to_string(), | |
| 132 | + | jti: "01tokentokentokentokentoken".to_string(), | |
| 133 | + | scopes: "repo:read,repo:write".to_string(), | |
| 134 | + | iat: 1_000, | |
| 135 | + | exp, | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn round_trips_a_valid_token() { | |
| 141 | + | let token = mint_jwt("s3cr3t", &claims(2_000)).unwrap(); | |
| 142 | + | // Three base64url segments. | |
| 143 | + | assert_eq!(token.split('.').count(), 3); | |
| 144 | + | let decoded = verify_jwt("s3cr3t", &token, 1_500).unwrap(); | |
| 145 | + | assert_eq!(decoded, claims(2_000)); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn header_is_the_canonical_hs256_header() { | |
| 150 | + | let token = mint_jwt("s", &claims(2_000)).unwrap(); | |
| 151 | + | let header = token.split('.').next().unwrap(); | |
| 152 | + | let json = URL_SAFE_NO_PAD.decode(header).unwrap(); | |
| 153 | + | assert_eq!(&json, br#"{"alg":"HS256","typ":"JWT"}"#); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn wrong_secret_is_rejected() { | |
| 158 | + | let token = mint_jwt("right", &claims(2_000)).unwrap(); | |
| 159 | + | assert!(matches!( | |
| 160 | + | verify_jwt("wrong", &token, 1_500), | |
| 161 | + | Err(AuthError::InvalidToken) | |
| 162 | + | )); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn expired_token_is_rejected() { | |
| 167 | + | let token = mint_jwt("s", &claims(2_000)).unwrap(); | |
| 168 | + | // At exactly exp the token is already expired (exp <= now). | |
| 169 | + | assert!(matches!( | |
| 170 | + | verify_jwt("s", &token, 2_000), | |
| 171 | + | Err(AuthError::InvalidToken) | |
| 172 | + | )); | |
| 173 | + | assert!(verify_jwt("s", &token, 1_999).is_ok()); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn tampered_payload_is_rejected() { | |
| 178 | + | let token = mint_jwt("s", &claims(2_000)).unwrap(); | |
| 179 | + | let mut parts: Vec<&str> = token.split('.').collect(); | |
| 180 | + | // Re-encode a payload that escalates scopes; the signature no longer fits. | |
| 181 | + | let forged = Claims { | |
| 182 | + | scopes: "admin".to_string(), | |
| 183 | + | ..claims(2_000) | |
| 184 | + | }; | |
| 185 | + | let forged_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap()); | |
| 186 | + | parts[1] = &forged_b64; | |
| 187 | + | let tampered = parts.join("."); | |
| 188 | + | assert!(matches!( | |
| 189 | + | verify_jwt("s", &tampered, 1_500), | |
| 190 | + | Err(AuthError::InvalidToken) | |
| 191 | + | )); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn malformed_shapes_are_rejected() { | |
| 196 | + | for bad in ["", "onlyonepart", "two.parts", "a.b.c.d", "..", "x.y.z"] { | |
| 197 | + | assert!( | |
| 198 | + | matches!(verify_jwt("s", bad, 0), Err(AuthError::InvalidToken)), | |
| 199 | + | "should reject {bad:?}" | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn algorithm_confusion_is_rejected() { | |
| 206 | + | // A token whose header claims "alg":"none" but is otherwise well-formed | |
| 207 | + | // must not verify, even with an empty signature. | |
| 208 | + | let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims(2_000)).unwrap()); | |
| 209 | + | let none_header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); | |
| 210 | + | let forged = format!("{none_header}.{payload}."); | |
| 211 | + | assert!(matches!( | |
| 212 | + | verify_jwt("s", &forged, 1_500), | |
| 213 | + | Err(AuthError::InvalidToken) | |
| 214 | + | )); | |
| 215 | + | } | |
| 216 | + | } | |
docs/decisions.md +36 −0
| @@ -170,3 +170,39 @@ into `OUT_DIR`. | |||
| 170 | 170 | break every derivation that compiles `store`. Widening the filter is the minimal, | |
| 171 | 171 | explicit fix and leaves room to add further asset globs (themes, vendored htmx) | |
| 172 | 172 | the same way in later phases. | |
| 173 | + | ||
| 174 | + | ## 2026-07-24 — Hand-rolled HS256 JWTs instead of a JWT crate | |
| 175 | + | ||
| 176 | + | **Decision:** `crates/auth` implements the HS256 JSON Web Token construction | |
| 177 | + | directly (base64url of a fixed JOSE header and a serde JSON payload, an | |
| 178 | + | `hmac`/`sha2` HMAC-SHA256, constant-time verification via `Mac::verify_slice`) | |
| 179 | + | rather than depending on `jsonwebtoken` or similar. | |
| 180 | + | ||
| 181 | + | **Alternatives:** Use the `jsonwebtoken` crate. | |
| 182 | + | ||
| 183 | + | **Rationale:** The mainstream JWT crates depend on `ring`, whose license set is | |
| 184 | + | not in `deny.toml`'s allow-list (it carries OpenSSL-derived terms), so pulling | |
| 185 | + | one in would fail `cargo-deny` — the same reason sqlx is built without a TLS | |
| 186 | + | backend. The HS256 surface we need is tiny and well-specified; hand-rolling it is | |
| 187 | + | a few dozen lines over crates already in the tree (`hmac`, `sha2`, `base64`, | |
| 188 | + | `serde_json`) and lets us reject algorithm-confusion tokens explicitly. Tokens | |
| 189 | + | are minted and verified only by fabrica, so interop with other JWT producers is a | |
| 190 | + | non-goal. | |
| 191 | + | ||
| 192 | + | ## 2026-07-24 — `access()` takes the collaborator row and anonymous flag explicitly | |
| 193 | + | ||
| 194 | + | **Decision:** The permission function is | |
| 195 | + | `access(viewer: Option<&Viewer>, repo: &Repo, collaborator: Option<Permission>, | |
| 196 | + | allow_anonymous: bool) -> Access`, widening the two-argument shape sketched in | |
| 197 | + | the spec. | |
| 198 | + | ||
| 199 | + | **Alternatives:** Keep the literal `access(viewer, repo)` signature and thread the | |
| 200 | + | collaborator lookup and `instance.allow_anonymous` in via `Viewer` or a context | |
| 201 | + | struct. | |
| 202 | + | ||
| 203 | + | **Rationale:** Rule 3 needs the viewer's `repo_collaborators` row and rule 4 needs | |
| 204 | + | `instance.allow_anonymous`; neither lives on `Viewer` or `Repo`. Passing them as | |
| 205 | + | explicit parameters keeps `access` a pure function of its inputs — which is what | |
| 206 | + | makes the exhaustive matrix test possible — and keeps the collaborator lookup at | |
| 207 | + | the call site (the store), where it belongs. The five ordered rules are unchanged | |
| 208 | + | from the spec. | |