Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +87 −2
| @@ -69,6 +69,18 @@ name = "api" | |||
| 69 | version = "0.1.0" | 69 | version = "0.1.0" |
| 70 | 70 | ||
| 71 | [[package]] | 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 | name = "atoi" | 84 | name = "atoi" |
| 73 | version = "2.0.0" | 85 | version = "2.0.0" |
| 74 | source = "registry+https://github.com/rust-lang/crates.io-index" | 86 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -89,6 +101,17 @@ dependencies = [ | |||
| 89 | [[package]] | 101 | [[package]] |
| 90 | name = "auth" | 102 | name = "auth" |
| 91 | version = "0.1.0" | 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 | [[package]] | 116 | [[package]] |
| 94 | name = "autocfg" | 117 | name = "autocfg" |
| @@ -103,6 +126,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 103 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | 126 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 104 | 127 | ||
| 105 | [[package]] | 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 | name = "bitflags" | 135 | name = "bitflags" |
| 107 | version = "2.13.1" | 136 | version = "2.13.1" |
| 108 | source = "registry+https://github.com/rust-lang/crates.io-index" | 137 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -112,6 +141,15 @@ dependencies = [ | |||
| 112 | ] | 141 | ] |
| 113 | 142 | ||
| 114 | [[package]] | 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 | name = "block-buffer" | 153 | name = "block-buffer" |
| 116 | version = "0.10.4" | 154 | version = "0.10.4" |
| 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | 155 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -346,6 +384,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |||
| 346 | dependencies = [ | 384 | dependencies = [ |
| 347 | "block-buffer 0.10.4", | 385 | "block-buffer 0.10.4", |
| 348 | "crypto-common 0.1.6", | 386 | "crypto-common 0.1.6", |
| 387 | "subtle", | ||
| 349 | ] | 388 | ] |
| 350 | 389 | ||
| 351 | [[package]] | 390 | [[package]] |
| @@ -566,6 +605,17 @@ dependencies = [ | |||
| 566 | 605 | ||
| 567 | [[package]] | 606 | [[package]] |
| 568 | name = "getrandom" | 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 | version = "0.3.4" | 619 | version = "0.3.4" |
| 570 | source = "registry+https://github.com/rust-lang/crates.io-index" | 620 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 571 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | 621 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" |
| @@ -640,7 +690,16 @@ version = "0.13.0" | |||
| 640 | source = "registry+https://github.com/rust-lang/crates.io-index" | 690 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 641 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | 691 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" |
| 642 | dependencies = [ | 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 | [[package]] | 705 | [[package]] |
| @@ -935,6 +994,17 @@ dependencies = [ | |||
| 935 | ] | 994 | ] |
| 936 | 995 | ||
| 937 | [[package]] | 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 | name = "pear" | 1008 | name = "pear" |
| 939 | version = "0.2.9" | 1009 | version = "0.2.9" |
| 940 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1010 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1069,6 +1139,15 @@ dependencies = [ | |||
| 1069 | 1139 | ||
| 1070 | [[package]] | 1140 | [[package]] |
| 1071 | name = "rand_core" | 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 | version = "0.9.5" | 1151 | version = "0.9.5" |
| 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1074 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | 1153 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" |
| @@ -1370,7 +1449,7 @@ dependencies = [ | |||
| 1370 | "futures-util", | 1449 | "futures-util", |
| 1371 | "hex", | 1450 | "hex", |
| 1372 | "hkdf", | 1451 | "hkdf", |
| 1373 | "hmac", | 1452 | "hmac 0.13.0", |
| 1374 | "itoa", | 1453 | "itoa", |
| 1375 | "log", | 1454 | "log", |
| 1376 | "md-5", | 1455 | "md-5", |
| @@ -1451,6 +1530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1451 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | 1530 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" |
| 1452 | 1531 | ||
| 1453 | [[package]] | 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 | name = "syn" | 1539 | name = "syn" |
| 1455 | version = "2.0.119" | 1540 | version = "2.0.119" |
| 1456 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | ulid = "1" | 72 | ulid = "1" |
| 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } | 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } |
| 74 | tempfile = "3" | 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 | [workspace.lints.rust] | 87 | [workspace.lints.rust] |
| 77 | unsafe_code = "forbid" | 88 | unsafe_code = "forbid" |
crates/auth/Cargo.toml +9 −0
| @@ -10,6 +10,15 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 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 | [lints] | 23 | [lints] |
| 15 | workspace = true | 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 | // License, v. 2.0. If a copy of the MPL was not distributed with this | 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/. | 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 | break every derivation that compiles `store`. Widening the filter is the minimal, | 170 | break every derivation that compiles `store`. Widening the filter is the minimal, |
| 171 | explicit fix and leaves room to add further asset globs (themes, vendored htmx) | 171 | explicit fix and leaves room to add further asset globs (themes, vendored htmx) |
| 172 | the same way in later phases. | 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. | ||