fabrica

hanna/fabrica

feat(auth): argon2id, sessions, HS256 JWTs, and the access model

1f61684 · hanna committed on 2026-07-25

Implement phase 4. `crates/auth` is I/O-free and splits into four concerns:

- password: Argon2id PHC hashing; verification runs full hashing work on the
  missing/malformed-hash paths so account existence never leaks via timing.
- session: 32-byte OsRng tokens, base64url in the cookie, only their SHA-256
  kept server-side as the sessions.id.
- token: HS256 JWTs minted/verified over hmac+sha2 (no `ring`, keeping the
  license set clean); fixed header rejects alg-confusion; revocation stays a
  store concern.
- scope + access: the authorization vocabulary and the single `access()`
  function, with the five ordered rules and an exhaustive viewer × visibility ×
  collaborator × anonymous matrix test.

docs/decisions.md records the hand-rolled JWT and the widened access() signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +1097 −3UnifiedSplit
Cargo.lock +87 −2
@@ -69,6 +69,18 @@ name = "api"
6969 version = "0.1.0"
7070
7171 [[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]]
7284 name = "atoi"
7385 version = "2.0.0"
7486 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -89,6 +101,17 @@ dependencies = [
89101 [[package]]
90102 name = "auth"
91103 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+]
92115
93116 [[package]]
94117 name = "autocfg"
@@ -103,6 +126,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
103126 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
104127
105128 [[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]]
106135 name = "bitflags"
107136 version = "2.13.1"
108137 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -112,6 +141,15 @@ dependencies = [
112141 ]
113142
114143 [[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]]
115153 name = "block-buffer"
116154 version = "0.10.4"
117155 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -346,6 +384,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
346384 dependencies = [
347385 "block-buffer 0.10.4",
348386 "crypto-common 0.1.6",
387+ "subtle",
349388 ]
350389
351390 [[package]]
@@ -566,6 +605,17 @@ dependencies = [
566605
567606 [[package]]
568607 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"
569619 version = "0.3.4"
570620 source = "registry+https://github.com/rust-lang/crates.io-index"
571621 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
@@ -640,7 +690,16 @@ version = "0.13.0"
640690 source = "registry+https://github.com/rust-lang/crates.io-index"
641691 checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
642692 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",
644703 ]
645704
646705 [[package]]
@@ -935,6 +994,17 @@ dependencies = [
935994 ]
936995
937996 [[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]]
9381008 name = "pear"
9391009 version = "0.2.9"
9401010 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1069,6 +1139,15 @@ dependencies = [
10691139
10701140 [[package]]
10711141 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"
10721151 version = "0.9.5"
10731152 source = "registry+https://github.com/rust-lang/crates.io-index"
10741153 checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
@@ -1370,7 +1449,7 @@ dependencies = [
13701449 "futures-util",
13711450 "hex",
13721451 "hkdf",
1373- "hmac",
1452+ "hmac 0.13.0",
13741453 "itoa",
13751454 "log",
13761455 "md-5",
@@ -1451,6 +1530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
14511530 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
14521531
14531532 [[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]]
14541539 name = "syn"
14551540 version = "2.0.119"
14561541 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 = [
7272 ulid = "1"
7373 tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
7474 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"
7586
7687 [workspace.lints.rust]
7788 unsafe_code = "forbid"
crates/auth/Cargo.toml +9 −0
@@ -10,6 +10,15 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [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 }
1322
1423 [lints]
1524 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 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
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`.
170170 break every derivation that compiles `store`. Widening the filter is the minimal,
171171 explicit fix and leaves room to add further asset globs (themes, vendored htmx)
172172 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.