OXYGENHUB

A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) a22e4aec2e28

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 88ebf83a57bc
When
· 2026-11-01 16:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
a22e4aec2e28e7e116863dacacb175a7b7e495581073e1b757840b5ae33ec0b5
Depends on
0024ccb1b050 041a945f55f8 119fd17d213f 19bb31ff1f5c 1c52d940b4a0 25ab8c73514a 36e5a9c15a84 3b1bc27fe805 47932fa8d6f3 4dfa4599b3f7 5d818ec7d5fc 83940d53664f 8597fb5ca16c 8a48dd830178 8ad8e656a3a6 8f1d76f4169a 93be9ce4653e 97b18869acf5 b5668108487f bff9106edcba d23464c0ac60 d366306dae7d df71c7892d27
Depended on by
8297a33b9a4c
In bookmarks
main
In tags
v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
52 atoms, 7 tree operations, 22 files

Browse the tree at this change

added crates/hub/migrations-pg/0104_second_factor.sql +31 −0
1 +
-- Phones as a second factor (oxygenhub#356): a phone's Keystore key, kept
2 +
-- with the sign-in it made (gone when that token is revoked); the
3 +
-- approvals it is asked for, each a number to match; and when each
4 +
-- browser session last proved a second factor, for the sudo step.
5 +
CREATE TABLE authenticators (
6 +
    token_id     BIGINT PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE,
7 +
    account_id   BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
8 +
    name         TEXT NOT NULL,
9 +
    public_key   TEXT NOT NULL,
10 +
    created_ms   BIGINT NOT NULL,
11 +
    last_used_ms BIGINT NOT NULL DEFAULT 0
12 +
);
13 +
CREATE INDEX authenticators_by_account ON authenticators (account_id);
14 +
15 +
CREATE TABLE approvals (
16 +
    id          TEXT PRIMARY KEY,
17 +
    account_id  BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
18 +
    kind        TEXT NOT NULL,
19 +
    what        TEXT NOT NULL,
20 +
    number      BIGINT NOT NULL,
21 +
    choices     TEXT NOT NULL,
22 +
    session_id  BIGINT,
23 +
    created_ms  BIGINT NOT NULL,
24 +
    expires_ms  BIGINT NOT NULL,
25 +
    state       TEXT NOT NULL DEFAULT 'pending',
26 +
    decided_ms  BIGINT NOT NULL DEFAULT 0,
27 +
    decided_by  BIGINT
28 +
);
29 +
CREATE INDEX approvals_by_account ON approvals (account_id, state);
30 +
31 +
ALTER TABLE sessions ADD COLUMN second_factor_ms BIGINT NOT NULL DEFAULT 0;
added crates/hub/migrations/0104_second_factor.sql +31 −0
1 +
-- Phones as a second factor (oxygenhub#356): a phone's Keystore key, kept
2 +
-- with the sign-in it made (gone when that token is revoked); the
3 +
-- approvals it is asked for, each a number to match; and when each
4 +
-- browser session last proved a second factor, for the sudo step.
5 +
CREATE TABLE authenticators (
6 +
    token_id     INTEGER PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE,
7 +
    account_id   INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
8 +
    name         TEXT NOT NULL,
9 +
    public_key   TEXT NOT NULL,
10 +
    created_ms   INTEGER NOT NULL,
11 +
    last_used_ms INTEGER NOT NULL DEFAULT 0
12 +
);
13 +
CREATE INDEX authenticators_by_account ON authenticators (account_id);
14 +
15 +
CREATE TABLE approvals (
16 +
    id          TEXT PRIMARY KEY,
17 +
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
18 +
    kind        TEXT NOT NULL,
19 +
    what        TEXT NOT NULL,
20 +
    number      INTEGER NOT NULL,
21 +
    choices     TEXT NOT NULL,
22 +
    session_id  INTEGER,
23 +
    created_ms  INTEGER NOT NULL,
24 +
    expires_ms  INTEGER NOT NULL,
25 +
    state       TEXT NOT NULL DEFAULT 'pending',
26 +
    decided_ms  INTEGER NOT NULL DEFAULT 0,
27 +
    decided_by  INTEGER
28 +
);
29 +
CREATE INDEX approvals_by_account ON approvals (account_id, state);
30 +
31 +
ALTER TABLE sessions ADD COLUMN second_factor_ms INTEGER NOT NULL DEFAULT 0;
added crates/hub/src/api/approvals.rs +156 −0
1 +
//! A phone as a second factor over the API (oxygenhub#356): register this
2 +
//! phone's Keystore key, list and remove phones, and answer the approvals
3 +
//! a browser asks for. Agents' tokens can do none of it.
4 +
5 +
use super::{caller, ApiError, Caller};
6 +
use crate::auth::{phone, totp};
7 +
use crate::db::{self, accounts, approvals};
8 +
use crate::Shared;
9 +
use axum::extract::{Path, State};
10 +
use axum::http::{HeaderMap, StatusCode};
11 +
use axum::Json;
12 +
use hub_api::{ApprovalAnswer, Authenticator, NewAuthenticator, PendingApproval};
13 +
14 +
/// The caller as a person's phone: its account and the token it signed in with.
15 +
fn phone_of(c: &Caller) -> Result<(&accounts::Account, i64), ApiError> {
16 +
    let account = c.require("user:read")?;
17 +
    if c.agent {
18 +
        return Err(ApiError(StatusCode::FORBIDDEN, "an agent cannot be a second factor".into()));
19 +
    }
20 +
    let Some(id) = c.token_id else {
21 +
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone uses the token it signed in with".into()));
22 +
    };
23 +
    Ok((account, id))
24 +
}
25 +
26 +
/// `POST /api/v1/user/authenticators`: this phone as a second factor. It
27 +
/// needs the account's current authenticator-app code, so a token alone
28 +
/// cannot add one, and the app code stays the way in when the phone is lost.
29 +
pub async fn add(
30 +
    State(app): State<Shared>,
31 +
    headers: HeaderMap,
32 +
    Json(r): Json<NewAuthenticator>,
33 +
) -> Result<(StatusCode, Json<Authenticator>), ApiError> {
34 +
    let c = caller(&app, &headers).await?;
35 +
    let (account, id) = phone_of(&c)?;
36 +
    let Some(secret) = account.totp_secret.as_deref() else {
37 +
        return Err(ApiError(
38 +
            StatusCode::CONFLICT,
39 +
            "set up an authenticator app first (Settings › Security on the hub): it stays the way in if this phone is lost".into(),
40 +
        ));
41 +
    };
42 +
    if !totp::verify(secret, r.code.trim(), (db::now_ms() / 1000) as u64) {
43 +
        accounts::audit(&app.db, Some(account), "2fa.phone_add_failed", &c.token, "wrong code").await.ok();
44 +
        return Err(ApiError(StatusCode::FORBIDDEN, "that code is not right".into()));
45 +
    }
46 +
    let Some(point) = phone::public_point(&r.public_key) else {
47 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is a P-256 key: hex of its SubjectPublicKeyInfo".into()));
48 +
    };
49 +
    approvals::add_authenticator(&app.db, id, account.id, &c.token, &point).await?;
50 +
    accounts::audit(&app.db, Some(account), "2fa.phone_added", &c.token, "").await.ok();
51 +
    let a = approvals::authenticator(&app.db, id).await?.expect("just added");
52 +
    Ok((StatusCode::CREATED, Json(Authenticator { id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, this: true })))
53 +
}
54 +
55 +
/// `GET /api/v1/user/authenticators`: the account's phones.
56 +
pub async fn list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Authenticator>>, ApiError> {
57 +
    let c = caller(&app, &headers).await?;
58 +
    let account = c.require("user:read")?;
59 +
    let this = c.token_id;
60 +
    Ok(Json(
61 +
        approvals::authenticators(&app.db, account.id)
62 +
            .await?
63 +
            .into_iter()
64 +
            .map(|a| Authenticator {
65 +
                this: Some(a.token_id) == this,
66 +
                id: a.token_id,
67 +
                name: a.name,
68 +
                created_ms: a.created_ms,
69 +
                last_used_ms: a.last_used_ms,
70 +
            })
71 +
            .collect(),
72 +
    ))
73 +
}
74 +
75 +
/// `DELETE /api/v1/user/authenticators`: this phone stops being a second factor.
76 +
pub async fn remove(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
77 +
    let c = caller(&app, &headers).await?;
78 +
    let (account, id) = phone_of(&c)?;
79 +
    if approvals::remove_authenticator(&app.db, account.id, id).await? {
80 +
        accounts::audit(&app.db, Some(account), "2fa.phone_removed", &c.token, "").await.ok();
81 +
    }
82 +
    Ok(StatusCode::NO_CONTENT)
83 +
}
84 +
85 +
/// `GET /api/v1/user/approvals`: what waits for this phone's answer.
86 +
pub async fn pending(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<PendingApproval>>, ApiError> {
87 +
    let c = caller(&app, &headers).await?;
88 +
    let (account, id) = phone_of(&c)?;
89 +
    if approvals::authenticator(&app.db, id).await?.is_none() {
90 +
        return Ok(Json(Vec::new()));
91 +
    }
92 +
    Ok(Json(
93 +
        approvals::pending(&app.db, account.id, db::now_ms())
94 +
            .await?
95 +
            .into_iter()
96 +
            .map(|a| PendingApproval {
97 +
                choices: a.choices.split(',').filter_map(|n| n.parse().ok()).collect(),
98 +
                id: a.id,
99 +
                kind: a.kind,
100 +
                what: a.what,
101 +
                created_ms: a.created_ms,
102 +
                expires_ms: a.expires_ms,
103 +
            })
104 +
            .collect(),
105 +
    ))
106 +
}
107 +
108 +
/// `POST /api/v1/user/approvals/{id}`: this phone's answer. Approving takes
109 +
/// the number the browser shows and the phone key's signature; a wrong
110 +
/// number denies it, as a guess should.
111 +
pub async fn answer(
112 +
    State(app): State<Shared>,
113 +
    headers: HeaderMap,
114 +
    Path(approval): Path<String>,
115 +
    Json(r): Json<ApprovalAnswer>,
116 +
) -> Result<Json<serde_json::Value>, ApiError> {
117 +
    let c = caller(&app, &headers).await?;
118 +
    let (account, id) = phone_of(&c)?;
119 +
    let Some(key) = approvals::authenticator(&app.db, id).await?.filter(|a| a.account_id == account.id) else {
120 +
        return Err(ApiError(StatusCode::FORBIDDEN, "this phone is not a second factor of the account".into()));
121 +
    };
122 +
    let now = db::now_ms();
123 +
    let Some(a) = approvals::get(&app.db, &approval).await?.filter(|a| a.account_id == account.id) else {
124 +
        return Err(ApiError(StatusCode::NOT_FOUND, "no such approval".into()));
125 +
    };
126 +
    if a.state != "pending" || a.expires_ms <= now {
127 +
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
128 +
    }
129 +
    let state = if !r.approve {
130 +
        "denied"
131 +
    } else {
132 +
        let Ok(sig) = hex::decode(r.signature.trim()) else {
133 +
            return Err(ApiError(StatusCode::BAD_REQUEST, "signature is hex".into()));
134 +
        };
135 +
        if !phone::verify(&key.public_key, &phone::message(&a.id, r.number, true), &sig) {
136 +
            accounts::audit(&app.db, Some(account), "2fa.bad_signature", &c.token, &a.kind).await.ok();
137 +
            return Err(ApiError(StatusCode::FORBIDDEN, "the signature is not this phone's".into()));
138 +
        }
139 +
        if r.number == a.number {
140 +
            "approved"
141 +
        } else {
142 +
            "denied"
143 +
        }
144 +
    };
145 +
    if !approvals::decide(&app.db, &a.id, state, id, now).await? {
146 +
        return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into()));
147 +
    }
148 +
    if state == "approved" {
149 +
        if let Some(session) = a.session_id.filter(|_| a.kind == "stepup") {
150 +
            db::sessions::mark_second_factor(&app.db, session, now).await?;
151 +
        }
152 +
    }
153 +
    let detail = if r.approve && state == "denied" { format!("{}: wrong number", a.kind) } else { a.kind.clone() };
154 +
    accounts::audit(&app.db, Some(account), &format!("2fa.{state}"), &c.token, &detail).await.ok();
155 +
    Ok(Json(serde_json::json!({ "state": state })))
156 +
}
added crates/hub/src/auth/phone.rs +127 −0
1 +
//! The phone as a second factor (oxygenhub#356): a P-256 key made in the
2 +
//! phone's Keystore, usable only after a fingerprint, face or the device
3 +
//! PIN, signs each approval; the browser shows one number and the phone
4 +
//! offers three, so a tap without reading approves nothing.
5 +
6 +
use crate::db::{self, accounts::Account, approvals};
7 +
use crate::Shared;
8 +
use rand::{seq::SliceRandom, Rng, RngCore};
9 +
use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1};
10 +
11 +
/// How long a phone has to answer.
12 +
pub const APPROVAL_MS: i64 = 2 * 60_000;
13 +
/// How long a proved second factor lets a session past the sudo step.
14 +
pub const FRESH_MS: i64 = 15 * 60_000;
15 +
16 +
/// The DER header of a P-256 SubjectPublicKeyInfo (what Android's
17 +
/// `PublicKey.getEncoded()` gives), before the 65-byte point.
18 +
const P256_SPKI: [u8; 26] = [
19 +
    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01,
20 +
    0x07, 0x03, 0x42, 0x00,
21 +
];
22 +
23 +
/// A phone's public key, given as hex of its SubjectPublicKeyInfo or of
24 +
/// the bare uncompressed point: the point, as hex. None when it is
25 +
/// neither, or not on the curve.
26 +
pub fn public_point(hex_key: &str) -> Option<String> {
27 +
    let bytes = hex::decode(hex_key.trim()).ok()?;
28 +
    let point = match bytes.len() {
29 +
        91 if bytes[..26] == P256_SPKI => &bytes[26..],
30 +
        65 => &bytes[..],
31 +
        _ => return None,
32 +
    };
33 +
    if point[0] != 0x04 {
34 +
        return None;
35 +
    }
36 +
    // ring checks that the point is on the curve when it verifies, so a
37 +
    // bad key only ever fails to verify.
38 +
    Some(hex::encode(point))
39 +
}
40 +
41 +
/// What the phone signs to answer approval `id`.
42 +
pub fn message(id: &str, number: i64, approve: bool) -> Vec<u8> {
43 +
    format!("oxygenhub approval v1\n{id}\n{number}\n{}", if approve { "approve" } else { "deny" }).into_bytes()
44 +
}
45 +
46 +
/// Whether `signature` (DER, as ECDSA gives it) is the phone's over `msg`.
47 +
pub fn verify(point_hex: &str, msg: &[u8], signature: &[u8]) -> bool {
48 +
    let Ok(point) = hex::decode(point_hex) else { return false };
49 +
    UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point).verify(msg, signature).is_ok()
50 +
}
51 +
52 +
/// Three different two-digit numbers in a random order, and the one the
53 +
/// browser shows.
54 +
pub fn numbers() -> (i64, [i64; 3]) {
55 +
    let mut rng = rand::thread_rng();
56 +
    let mut all: Vec<i64> = (10..100).collect();
57 +
    all.shuffle(&mut rng);
58 +
    let choices = [all[0], all[1], all[2]];
59 +
    (choices[rng.gen_range(0..3)], choices)
60 +
}
61 +
62 +
fn new_id() -> String {
63 +
    let mut b = [0u8; 16];
64 +
    rand::rngs::OsRng.fill_bytes(&mut b);
65 +
    hex::encode(b)
66 +
}
67 +
68 +
/// Ask the account's phones to approve `what`: recorded, and sent as an
69 +
/// alert where alerts are on (the app also looks when it opens). For a
70 +
/// step-up, `session_id` is the browser session it makes fresh.
71 +
pub async fn ask(app: &Shared, account: &Account, kind: &str, what: &str, session_id: Option<i64>) -> sqlx::Result<approvals::Approval> {
72 +
    let id = new_id();
73 +
    let (number, choices) = numbers();
74 +
    let choices = choices.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
75 +
    let what: String = what.chars().take(200).collect();
76 +
    approvals::create(
77 +
        &app.db,
78 +
        approvals::NewApproval {
79 +
            id: &id,
80 +
            account_id: account.id,
81 +
            kind,
82 +
            what: &what,
83 +
            number,
84 +
            choices: &choices,
85 +
            session_id,
86 +
            expires_ms: db::now_ms() + APPROVAL_MS,
87 +
        },
88 +
    )
89 +
    .await?;
90 +
    crate::push::send(app, account.id, crate::push::Alert { kind: "approval".into(), title: what, url: format!("/approvals/{id}") });
91 +
    Ok(approvals::get(&app.db, &id).await?.expect("just made"))
92 +
}
93 +
94 +
#[cfg(test)]
95 +
mod tests {
96 +
    use super::*;
97 +
    use ring::rand::SystemRandom;
98 +
    use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING};
99 +
100 +
    #[test]
101 +
    fn a_phone_signature_verifies_and_nothing_else_does() {
102 +
        let rng = SystemRandom::new();
103 +
        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
104 +
        let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap();
105 +
        let point = hex::encode(pair.public_key().as_ref());
106 +
        let spki = format!("{}{point}", hex::encode(P256_SPKI));
107 +
        assert_eq!(public_point(&spki).as_deref(), Some(point.as_str()), "the Keystore's encoding");
108 +
        assert_eq!(public_point(&point).as_deref(), Some(point.as_str()), "a bare point");
109 +
        assert_eq!(public_point("00ff"), None);
110 +
        let m = message("ab", 47, true);
111 +
        let sig = pair.sign(&rng, &m).unwrap();
112 +
        assert!(verify(&point, &m, sig.as_ref()));
113 +
        assert!(!verify(&point, &message("ab", 48, true), sig.as_ref()), "another number");
114 +
        assert!(!verify(&point, &message("ab", 47, false), sig.as_ref()), "a denial is not an approval");
115 +
        assert!(!verify(&point, &message("ac", 47, true), sig.as_ref()), "another approval");
116 +
    }
117 +
118 +
    #[test]
119 +
    fn three_different_numbers_one_shown() {
120 +
        for _ in 0..200 {
121 +
            let (n, c) = numbers();
122 +
            assert!(c.contains(&n));
123 +
            assert!(c[0] != c[1] && c[1] != c[2] && c[0] != c[2]);
124 +
            assert!(c.iter().all(|x| (10..100).contains(x)));
125 +
        }
126 +
    }
127 +
}
added crates/hub/src/db/approvals.rs +163 −0
1 +
//! Phones as a second factor (oxygenhub#356): their keys, and the
2 +
//! approvals they are asked for.
3 +
4 +
use super::{now_ms, Pool};
5 +
use sqlx::FromRow;
6 +
7 +
/// A phone that approves for its account: the Keystore key of the sign-in
8 +
/// (token) it made, as an uncompressed P-256 point in hex.
9 +
#[derive(Clone, Debug, FromRow)]
10 +
pub struct Authenticator {
11 +
    pub token_id: i64,
12 +
    pub account_id: i64,
13 +
    pub name: String,
14 +
    pub public_key: String,
15 +
    pub created_ms: i64,
16 +
    pub last_used_ms: i64,
17 +
}
18 +
19 +
const AUTH_COLUMNS: &str = "token_id, account_id, name, public_key, created_ms, last_used_ms";
20 +
21 +
/// Register (or replace) the key of the phone signed in with `token_id`.
22 +
pub async fn add_authenticator(pool: &Pool, token_id: i64, account_id: i64, name: &str, public_key: &str) -> sqlx::Result<()> {
23 +
    sqlx::query(
24 +
        "INSERT INTO authenticators (token_id, account_id, name, public_key, created_ms) VALUES ($1, $2, $3, $4, $5) \
25 +
         ON CONFLICT (token_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, created_ms = excluded.created_ms",
26 +
    )
27 +
    .bind(token_id)
28 +
    .bind(account_id)
29 +
    .bind(name)
30 +
    .bind(public_key)
31 +
    .bind(now_ms())
32 +
    .execute(pool)
33 +
    .await?;
34 +
    Ok(())
35 +
}
36 +
37 +
pub async fn authenticators(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Authenticator>> {
38 +
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE account_id = $1 ORDER BY created_ms"))
39 +
        .bind(account_id)
40 +
        .fetch_all(pool)
41 +
        .await
42 +
}
43 +
44 +
pub async fn authenticator(pool: &Pool, token_id: i64) -> sqlx::Result<Option<Authenticator>> {
45 +
    sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE token_id = $1")).bind(token_id).fetch_optional(pool).await
46 +
}
47 +
48 +
/// Remove one of the account's phones; false when it had none by that id.
49 +
pub async fn remove_authenticator(pool: &Pool, account_id: i64, token_id: i64) -> sqlx::Result<bool> {
50 +
    Ok(sqlx::query("DELETE FROM authenticators WHERE account_id = $1 AND token_id = $2")
51 +
        .bind(account_id)
52 +
        .bind(token_id)
53 +
        .execute(pool)
54 +
        .await?
55 +
        .rows_affected()
56 +
        > 0)
57 +
}
58 +
59 +
/// An approval asked of the account's phones: what for, the number the
60 +
/// page shows among the three the phone offers, and what became of it.
61 +
#[derive(Clone, Debug, FromRow)]
62 +
pub struct Approval {
63 +
    pub id: String,
64 +
    pub account_id: i64,
65 +
    /// `signin` or `stepup`.
66 +
    pub kind: String,
67 +
    pub what: String,
68 +
    pub number: i64,
69 +
    /// The three numbers the phone offers, comma-separated.
70 +
    pub choices: String,
71 +
    /// For a step-up: the browser session it makes fresh.
72 +
    pub session_id: Option<i64>,
73 +
    pub created_ms: i64,
74 +
    pub expires_ms: i64,
75 +
    /// `pending`, `approved`, `denied`, or `used` once a sign-in took it.
76 +
    pub state: String,
77 +
    pub decided_ms: i64,
78 +
    pub decided_by: Option<i64>,
79 +
}
80 +
81 +
const COLUMNS: &str = "id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms, state, decided_ms, decided_by";
82 +
83 +
pub struct NewApproval<'a> {
84 +
    pub id: &'a str,
85 +
    pub account_id: i64,
86 +
    pub kind: &'a str,
87 +
    pub what: &'a str,
88 +
    pub number: i64,
89 +
    pub choices: &'a str,
90 +
    pub session_id: Option<i64>,
91 +
    pub expires_ms: i64,
92 +
}
93 +
94 +
pub async fn create(pool: &Pool, a: NewApproval<'_>) -> sqlx::Result<()> {
95 +
    sqlx::query(
96 +
        "INSERT INTO approvals (id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms) \
97 +
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
98 +
    )
99 +
    .bind(a.id)
100 +
    .bind(a.account_id)
101 +
    .bind(a.kind)
102 +
    .bind(a.what)
103 +
    .bind(a.number)
104 +
    .bind(a.choices)
105 +
    .bind(a.session_id)
106 +
    .bind(now_ms())
107 +
    .bind(a.expires_ms)
108 +
    .execute(pool)
109 +
    .await?;
110 +
    Ok(())
111 +
}
112 +
113 +
pub async fn get(pool: &Pool, id: &str) -> sqlx::Result<Option<Approval>> {
114 +
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM approvals WHERE id = $1")).bind(id).fetch_optional(pool).await
115 +
}
116 +
117 +
/// The account's approvals still waiting at `now`, newest first.
118 +
pub async fn pending(pool: &Pool, account_id: i64, now: i64) -> sqlx::Result<Vec<Approval>> {
119 +
    sqlx::query_as(&format!(
120 +
        "SELECT {COLUMNS} FROM approvals WHERE account_id = $1 AND state = 'pending' AND expires_ms > $2 ORDER BY created_ms DESC"
121 +
    ))
122 +
    .bind(account_id)
123 +
    .bind(now)
124 +
    .fetch_all(pool)
125 +
    .await
126 +
}
127 +
128 +
/// Decide a pending approval, once: false when it was no longer pending
129 +
/// or had expired.
130 +
pub async fn decide(pool: &Pool, id: &str, state: &str, by: i64, now: i64) -> sqlx::Result<bool> {
131 +
    let done = sqlx::query(
132 +
        "UPDATE approvals SET state = $1, decided_ms = $2, decided_by = $3 WHERE id = $4 AND state = 'pending' AND expires_ms > $2",
133 +
    )
134 +
    .bind(state)
135 +
    .bind(now)
136 +
    .bind(by)
137 +
    .bind(id)
138 +
    .execute(pool)
139 +
    .await?
140 +
    .rows_affected()
141 +
        > 0;
142 +
    if done && state == "approved" {
143 +
        sqlx::query("UPDATE authenticators SET last_used_ms = $1 WHERE token_id = $2").bind(now).bind(by).execute(pool).await?;
144 +
    }
145 +
    Ok(done)
146 +
}
147 +
148 +
/// Take an approved sign-in approval for the sign-in it was made for,
149 +
/// once: false when it is not approved, or was taken already.
150 +
pub async fn take(pool: &Pool, id: &str, account_id: i64) -> sqlx::Result<bool> {
151 +
    Ok(sqlx::query("UPDATE approvals SET state = 'used' WHERE id = $1 AND account_id = $2 AND state = 'approved' AND kind = 'signin'")
152 +
        .bind(id)
153 +
        .bind(account_id)
154 +
        .execute(pool)
155 +
        .await?
156 +
        .rows_affected()
157 +
        > 0)
158 +
}
159 +
160 +
/// Approvals that ended a day ago or more are of no further use.
161 +
pub async fn sweep(pool: &Pool, now: i64) -> sqlx::Result<u64> {
162 +
    Ok(sqlx::query("DELETE FROM approvals WHERE expires_ms < $1").bind(now - 86_400_000).execute(pool).await?.rows_affected())
163 +
}
added crates/hub/src/web/sudo.rs +271 −0
1 +
//! The sudo step (oxygenhub#356): before a sensitive change (a password,
2 +
//! two-factor settings, keys and tokens, secrets, a repository's deletion
3 +
//! or transfer, the site's administration), a session that has not proved
4 +
//! a second factor for a while proves one again: a phone's approval, a code
5 +
//! from the authenticator app, or the password when the account has no
6 +
//! second factor. Signing in counts as proof.
7 +
8 +
use super::{Error, Page, Shell};
9 +
use crate::auth::{self, phone, totp, Viewer};
10 +
use crate::db::{self, accounts};
11 +
use crate::throttle::{self, Key};
12 +
use crate::Shared;
13 +
use askama::Template;
14 +
use axum::extract::{Form, Path, Query, State};
15 +
use axum::http::{header, Method, StatusCode};
16 +
use axum::response::{IntoResponse, Redirect, Response};
17 +
use serde::Deserialize;
18 +
19 +
/// Whether a request changes something the sudo step guards. Only
20 +
/// changes are guarded: reading a page never asks.
21 +
pub fn sensitive(method: &Method, path: &str) -> bool {
22 +
    if method == Method::GET || method == Method::HEAD {
23 +
        return false;
24 +
    }
25 +
    let p = path.trim_end_matches('/');
26 +
    if p.starts_with("/admin") {
27 +
        return true;
28 +
    }
29 +
    if matches!(
30 +
        p,
31 +
        "/settings/password"
32 +
            | "/settings/security/totp/disable"
33 +
            | "/settings/keys/upload"
34 +
            | "/settings/keys/unlink"
35 +
            | "/settings/tokens"
36 +
            | "/settings/backups"
37 +
    ) || p.starts_with("/settings/security/phones/")
38 +
        || p.starts_with("/settings/security/disconnect/")
39 +
        || (p.starts_with("/settings/tokens/") && p.ends_with("/agent"))
40 +
    {
41 +
        return true;
42 +
    }
43 +
    let parts: Vec<&str> = p.trim_start_matches('/').split('/').collect();
44 +
    match parts.as_slice() {
45 +
        ["orgs", _, "settings", what] => matches!(*what, "secrets" | "storage" | "hooks" | "backups"),
46 +
        [owner, _, "settings", what] if !matches!(*owner, "settings" | "admin" | "orgs") => {
47 +
            matches!(*what, "delete" | "transfer" | "secrets" | "hooks" | "push-protection" | "secret-bypass" | "backup" | "collaborators")
48 +
        }
49 +
        _ => false,
50 +
    }
51 +
}
52 +
53 +
/// The middleware: a guarded change from a session whose proof is older
54 +
/// than [`phone::FRESH_MS`] goes to the sudo page instead, which comes back
55 +
/// to the page it was sent from.
56 +
pub async fn gate(State(app): State<Shared>, req: axum::extract::Request, next: axum::middleware::Next) -> Response {
57 +
    if !sensitive(req.method(), req.uri().path()) {
58 +
        return next.run(req).await;
59 +
    }
60 +
    let Some(value) = auth::viewer::cookie(req.headers(), auth::SESSION_COOKIE) else { return next.run(req).await };
61 +
    let Some((_, session)) = auth::viewer::resolve(&app, &value).await else { return next.run(req).await };
62 +
    if db::now_ms() - session.second_factor_ms < phone::FRESH_MS {
63 +
        return next.run(req).await;
64 +
    }
65 +
    let base = app.config.base_url().trim_end_matches('/').to_string();
66 +
    let back = req
67 +
        .headers()
68 +
        .get(header::REFERER)
69 +
        .and_then(|v| v.to_str().ok())
70 +
        .and_then(|r| r.strip_prefix(&base))
71 +
        .filter(|p| p.starts_with('/') && !p.starts_with("//"))
72 +
        .unwrap_or("/settings/security")
73 +
        .to_string();
74 +
    Redirect::to(&format!("/sudo?next={}", super::settings::urlencode(&back))).into_response()
75 +
}
76 +
77 +
#[derive(Template)]
78 +
#[template(path = "sudo.html")]
79 +
pub struct SudoPage {
80 +
    shell: Shell,
81 +
    next: String,
82 +
    error: Option<String>,
83 +
    /// The account has a phone that approves.
84 +
    phones: bool,
85 +
    /// The account has an authenticator app.
86 +
    totp: bool,
87 +
    /// Neither: the password stands in.
88 +
    password: bool,
89 +
    /// Waiting for the phone: the approval and its number.
90 +
    approval: Option<(String, i64)>,
91 +
}
92 +
93 +
#[derive(Deserialize)]
94 +
pub struct NextQuery {
95 +
    #[serde(default)]
96 +
    next: String,
97 +
}
98 +
99 +
fn safe_next(next: &str) -> String {
100 +
    if next.starts_with('/') && !next.starts_with("//") {
101 +
        next.to_string()
102 +
    } else {
103 +
        "/".into()
104 +
    }
105 +
}
106 +
107 +
async fn page(
108 +
    app: &Shared,
109 +
    viewer: &Viewer,
110 +
    next: &str,
111 +
    error: Option<String>,
112 +
    approval: Option<(String, i64)>,
113 +
) -> Result<Response, Error> {
114 +
    let shell = Shell::new(app, "/sudo", viewer);
115 +
    let Some(me) = viewer.account() else {
116 +
        return Ok(Redirect::to(&format!("/login?next={}", super::settings::urlencode(next))).into_response());
117 +
    };
118 +
    let totp = me.totp_secret.is_some();
119 +
    let phones = totp && !db::approvals::authenticators(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty();
120 +
    let password = !totp && !me.password_hash.is_empty();
121 +
    Ok(Page(SudoPage { shell, next: safe_next(next), error, phones, totp, password, approval }).into_response())
122 +
}
123 +
124 +
/// `GET /sudo?next=`: prove it is you, then carry on.
125 +
pub async fn show(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Result<Response, Error> {
126 +
    page(&app, &viewer, &q.next, None, None).await
127 +
}
128 +
129 +
#[derive(Deserialize)]
130 +
pub struct SudoForm {
131 +
    #[serde(rename = "_csrf")]
132 +
    csrf: String,
133 +
    #[serde(default)]
134 +
    next: String,
135 +
    #[serde(default)]
136 +
    code: String,
137 +
    #[serde(default)]
138 +
    password: String,
139 +
}
140 +
141 +
/// `POST /sudo`: a code from the authenticator app (or a recovery code),
142 +
/// or the password for an account without one.
143 +
pub async fn confirm(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SudoForm>) -> Result<Response, Error> {
144 +
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
145 +
    let shell = Shell::new(&app, "/sudo", &viewer);
146 +
    if !viewer.csrf_ok(&f.csrf) {
147 +
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
148 +
    }
149 +
    let now = db::now_ms();
150 +
    let keys = [Key::account("sudo", &me.account.username)];
151 +
    let wait = app.throttle.lock().unwrap().wait(&keys, now);
152 +
    if let Some(wait) = wait {
153 +
        return page(&app, &viewer, &f.next, Some(throttle::message(wait)), None).await;
154 +
    }
155 +
    let ok = match &me.account.totp_secret {
156 +
        Some(secret) => {
157 +
            totp::verify(secret, f.code.trim(), (now / 1000) as u64)
158 +
                || match totp::use_recovery_code(&me.account.recovery_codes, f.code.trim()) {
159 +
                    Some(rest) => {
160 +
                        accounts::set_totp(&app.db, me.account.id, Some(secret), &rest)
161 +
                            .await
162 +
                            .map_err(|e| Error::from_db(shell.clone(), e))?;
163 +
                        true
164 +
                    }
165 +
                    None => false,
166 +
                }
167 +
        }
168 +
        None => !me.account.password_hash.is_empty() && auth::verify_password(&me.account.password_hash, &f.password),
169 +
    };
170 +
    if !ok {
171 +
        app.throttle.lock().unwrap().failed(&keys, now);
172 +
        accounts::audit(&app.db, Some(&me.account), "sudo.failed", &me.account.username, "").await.ok();
173 +
        let what = if me.account.totp_secret.is_some() { "That code is not right." } else { "That password is not right." };
174 +
        return page(&app, &viewer, &f.next, Some(what.into()), None).await;
175 +
    }
176 +
    app.throttle.lock().unwrap().cleared(&keys[0]);
177 +
    db::sessions::mark_second_factor(&app.db, me.session_id, now).await.map_err(|e| Error::from_db(shell.clone(), e))?;
178 +
    accounts::audit(
179 +
        &app.db,
180 +
        Some(&me.account),
181 +
        "sudo",
182 +
        &me.account.username,
183 +
        if me.account.totp_secret.is_some() { "code" } else { "password" },
184 +
    )
185 +
    .await
186 +
    .ok();
187 +
    Ok(Redirect::to(&safe_next(&f.next)).into_response())
188 +
}
189 +
190 +
#[derive(Deserialize)]
191 +
pub struct PhoneForm {
192 +
    #[serde(rename = "_csrf")]
193 +
    csrf: String,
194 +
    #[serde(default)]
195 +
    next: String,
196 +
}
197 +
198 +
/// `POST /sudo/phone`: ask the account's phones; the page waits for one.
199 +
pub async fn ask_phone(
200 +
    State(app): State<Shared>,
201 +
    viewer: Viewer,
202 +
    headers: axum::http::HeaderMap,
203 +
    Form(f): Form<PhoneForm>,
204 +
) -> Result<Response, Error> {
205 +
    let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) };
206 +
    let shell = Shell::new(&app, "/sudo", &viewer);
207 +
    if !viewer.csrf_ok(&f.csrf) {
208 +
        return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await;
209 +
    }
210 +
    let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
211 +
    let what = format!("Confirm a change from {}", super::settings::browser_of(agent));
212 +
    let a = phone::ask(&app, &me.account, "stepup", &what, Some(me.session_id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
213 +
    page(&app, &viewer, &f.next, None, Some((a.id, a.number))).await
214 +
}
215 +
216 +
/// `GET /sudo/approval/{id}`: what became of this session's approval.
217 +
pub async fn approval_state(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Response {
218 +
    let found = match viewer.0.as_ref() {
219 +
        Some(me) => {
220 +
            db::approvals::get(&app.db, &id).await.ok().flatten().filter(|a| a.session_id == Some(me.session_id) && a.kind == "stepup")
221 +
        }
222 +
        None => None,
223 +
    };
224 +
    match found {
225 +
        Some(a) => {
226 +
            let state = if a.state == "pending" && a.expires_ms <= db::now_ms() { "expired".to_string() } else { a.state };
227 +
            axum::Json(serde_json::json!({ "state": state })).into_response()
228 +
        }
229 +
        None => (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(),
230 +
    }
231 +
}
232 +
233 +
#[cfg(test)]
234 +
mod tests {
235 +
    use super::*;
236 +
237 +
    #[test]
238 +
    fn only_changes_that_matter_are_guarded() {
239 +
        let post = Method::POST;
240 +
        for p in [
241 +
            "/settings/password",
242 +
            "/settings/security/totp/disable",
243 +
            "/settings/security/phones/3/remove",
244 +
            "/settings/tokens",
245 +
            "/settings/tokens/4/agent",
246 +
            "/settings/keys/upload",
247 +
            "/admin/mail",
248 +
            "/admin/runners/7/state",
249 +
            "/orgs/acme/settings/secrets",
250 +
            "/andy/demo/settings/delete",
251 +
            "/andy/demo/settings/transfer",
252 +
            "/andy/demo/settings/secrets",
253 +
            "/andy/demo/settings/collaborators",
254 +
        ] {
255 +
            assert!(sensitive(&post, p), "{p}");
256 +
        }
257 +
        for p in [
258 +
            "/settings",
259 +
            "/settings/appearance",
260 +
            "/settings/security/totp/begin",
261 +
            "/settings/tokens/4/delete",
262 +
            "/andy/demo/issues/new",
263 +
            "/andy/demo/settings/topics",
264 +
            "/login",
265 +
            "/sudo",
266 +
        ] {
267 +
            assert!(!sensitive(&post, p), "{p}");
268 +
        }
269 +
        assert!(!sensitive(&Method::GET, "/admin/mail"), "reading never asks");
270 +
    }
271 +
}
added templates/sudo.html +40 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Confirm it is you{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Confirm it is you</h1></div>
5 +
<p class="mute">This change needs a fresh proof that it is you. It lasts 15 minutes; then go back and make the change again.</p>
6 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
7 +
{% if let Some((id, number)) = approval %}
8 +
<section class="card-box">
9 +
  <div class="approve-wait pad" data-approval-poll="/sudo/approval/{{ id }}" data-approval-next="{{ next }}" aria-live="polite">
10 +
    <p>Open OxygenHub on your phone and pick</p>
11 +
    <p class="approve-number" aria-label="the number {{ number }}">{{ number }}</p>
12 +
    <p class="small mute" data-approval-status>Waiting for your phone…</p>
13 +
  </div>
14 +
</section>
15 +
{% else %}
16 +
{% if phones %}<section class="card-box">
17 +
  <form class="pad sec-form sec-split" method="post" action="/sudo/phone">
18 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
19 +
    <input type="hidden" name="next" value="{{ next }}">
20 +
    <p>Approve on your phone, with your fingerprint, face or PIN.</p>
21 +
    <button type="submit">APPROVE ON MY PHONE</button>
22 +
  </form>
23 +
</section>{% endif %}
24 +
{% endif %}
25 +
<section class="card-box">
26 +
  <form class="pad sec-form" method="post" action="/sudo">
27 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
28 +
    <input type="hidden" name="next" value="{{ next }}">
29 +
    {% if totp %}
30 +
    <div class="sec-row"><label for="code">{% if phones %}Or a code{% else %}A code{% endif %} from your authenticator app, or a recovery code</label><input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code"{% if !phones %} autofocus{% endif %} required><button type="submit">CONFIRM</button></div>
31 +
    {% else if password %}
32 +
    <div class="sec-row"><label for="password">Your password</label><input type="password" id="password" name="password" autocomplete="current-password" autofocus required><button type="submit">CONFIRM</button></div>
33 +
    <p class="small mute">With two-factor authentication on (Settings › Security), a code or your phone confirms instead.</p>
34 +
    {% else %}
35 +
    <p>Your account signs in through another service and has no password here: sign out and sign in again to confirm.</p>
36 +
    {% endif %}
37 +
  </form>
38 +
</section>
39 +
<p class="small"><a href="{{ next }}">Go back</a></p>
40 +
{% endblock %}
modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **Added: your phone as the second factor, and the sudo step (#356).** With two-factor sign-in on, the OxygenHub app can add the phone as a second factor (its Keystore key, opened by your fingerprint, face or PIN; adding it needs a current app code). Signing in then shows a two-digit number and waits: the phone offers three, you pick the one shown and confirm, and the browser carries on, with "Use a code instead" always there. A wrong number is a no. Before sensitive changes (password, two-factor settings, keys, tokens, secrets, webhooks, collaborators, deleting or transferring a repository, anything under Administration) a session that has not proved a second factor in 15 minutes is asked again at `/sudo`: the phone, a code, or the password for an account without two-factor. Settings › Security lists the phones; turning app codes off removes them. API: `/api/v1/user/authenticators`, `/api/v1/user/approvals`; migration 0104.
8 +
5 9
## 0.9.20 (2026-11-01)
6 10
7 11
- **Added: `GET /api/v1/user/issues` (#359).** The open issues assigned to you, in repositories you can see, newest-updated first, as the dashboard's "Assigned to you" lists them, each with its repository and address; for apps, first the Android widget's issue count (oxygenhub-android#31).
…
439 unchanged lines
modified assets/hub.js +43 −0
…
975 unchanged lines
976 976
    var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]');
977 977
    if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash);
978 978
  })();
979 +
  /* Waiting for a phone's approval (oxygenhub#356): at sign-in the page
980 +
     posts its proof of the password and signs in once the phone approves;
981 +
     at the sudo step it asks with the session and goes back where it came
982 +
     from. A no, or too long a wait, says so and stops. */
983 +
  (function () {
984 +
    var box = document.querySelector("[data-approval-poll]");
985 +
    if (!box) return;
986 +
    var status = box.querySelector("[data-approval-status]");
987 +
    var form = box.closest("form");
988 +
    var url = box.getAttribute("data-approval-poll");
989 +
    var said = {
990 +
      denied: "Your phone said no, or a different number was picked. Sign in again, or use a code.",
991 +
      expired: "That took too long. Start again, or use a code.",
992 +
      unknown: "That approval is gone. Start again."
993 +
    };
994 +
    var tries = 0;
995 +
    function ask() {
996 +
      var req;
997 +
      if (form) {
998 +
        var body = new URLSearchParams();
999 +
        ["username", "pending", "approval"].forEach(function (n) {
1000 +
          var el = form.querySelector('input[name="' + n + '"]');
1001 +
          if (el) body.set(n, el.value);
1002 +
        });
1003 +
        req = fetch(url, { method: "POST", credentials: "same-origin", body: body });
1004 +
      } else {
1005 +
        req = fetch(url, { credentials: "same-origin", headers: { accept: "application/json" } });
1006 +
      }
1007 +
      req.then(function (r) { return r.json(); }).then(function (j) {
1008 +
        var state = j && j.state;
1009 +
        box.setAttribute("data-state", state || "");
1010 +
        if (state === "approved") {
1011 +
          if (status) status.textContent = "Approved. Carrying on…";
1012 +
          if (form) form.submit(); else location.href = box.getAttribute("data-approval-next") || "/";
1013 +
        } else if (said[state] || state === "used") {
1014 +
          if (status) status.textContent = said[state] || said.unknown;
1015 +
        } else if (++tries < 75) {
1016 +
          setTimeout(ask, 2000);
1017 +
        }
1018 +
      }).catch(function () { if (++tries < 75) setTimeout(ask, 4000); });
1019 +
    }
1020 +
    setTimeout(ask, 1500);
1021 +
  })();
979 1022
})();
modified crates/hub-api/src/lib.rs +55 −0
…
893 unchanged lines
894 894
    pub url: String,
895 895
}
896 896
897 +
/// `POST /user/authenticators` (oxygenhub#356): this phone as a second
898 +
/// factor. `public_key` is hex of the Keystore key's SubjectPublicKeyInfo
899 +
/// (or its bare uncompressed P-256 point); `code` is the account's current
900 +
/// authenticator-app code, which adding a phone needs.
901 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
902 +
pub struct NewAuthenticator {
903 +
    pub public_key: String,
904 +
    pub code: String,
905 +
}
906 +
907 +
/// A phone that approves sign-ins and sensitive actions for its account.
908 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
909 +
pub struct Authenticator {
910 +
    /// The sign-in (token) it belongs to: signing that phone out removes it.
911 +
    pub id: i64,
912 +
    pub name: String,
913 +
    pub created_ms: i64,
914 +
    /// When it last approved something; 0 for never.
915 +
    pub last_used_ms: i64,
916 +
    /// The caller's own phone.
917 +
    #[serde(default)]
918 +
    pub this: bool,
919 +
}
920 +
921 +
/// An approval waiting for the caller's phone (`GET /user/approvals`).
922 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
923 +
pub struct PendingApproval {
924 +
    pub id: String,
925 +
    /// `signin` or `stepup`.
926 +
    pub kind: String,
927 +
    /// What it is for: "Sign in from Firefox on Linux, 203.0.113.9".
928 +
    pub what: String,
929 +
    /// The three numbers to offer; the browser shows one of them.
930 +
    pub choices: Vec<i64>,
931 +
    pub created_ms: i64,
932 +
    pub expires_ms: i64,
933 +
}
934 +
935 +
/// `POST /user/approvals/{id}`: the phone's answer. To approve, `number`
936 +
/// is the one picked and `signature` the hex DER ECDSA signature, by the
937 +
/// phone's key, over `oxygenhub approval v1\n{id}\n{number}\napprove`.
938 +
/// A denial needs neither.
939 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
940 +
pub struct ApprovalAnswer {
941 +
    pub approve: bool,
942 +
    #[serde(default)]
943 +
    pub number: i64,
944 +
    #[serde(default)]
945 +
    pub signature: String,
946 +
}
947 +
897 948
/// `GET /user/proposals` query.
898 949
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
899 950
pub struct UserProposalsQuery {
…
690 unchanged lines
1590 1641
        ("UserProposals", schemars::schema_for!(UserProposals)),
1591 1642
        ("ProposalBrief", schemars::schema_for!(ProposalBrief)),
1592 1643
        ("IssueBrief", schemars::schema_for!(IssueBrief)),
1644 +
        ("NewAuthenticator", schemars::schema_for!(NewAuthenticator)),
1645 +
        ("Authenticator", schemars::schema_for!(Authenticator)),
1646 +
        ("PendingApproval", schemars::schema_for!(PendingApproval)),
1647 +
        ("ApprovalAnswer", schemars::schema_for!(ApprovalAnswer)),
1593 1648
        ("StarterAssistants", schemars::schema_for!(StarterAssistants)),
1594 1649
        ("StarterStatus", schemars::schema_for!(StarterStatus)),
1595 1650
        ("StarterRequest", schemars::schema_for!(StarterRequest)),
…
300 unchanged lines
modified crates/hub/src/api/mod.rs +4 −0
…
14 unchanged lines
15 15
use hub_api::*;
16 16
use serde::Deserialize;
17 17
18 +
mod approvals;
18 19
mod attachments;
19 20
mod backups;
20 21
pub mod device;
…
90 unchanged lines
111 112
        .route("/user/orgs", get(user_orgs))
112 113
        .route("/user/proposals", get(user_proposals))
113 114
        .route("/user/issues", get(user_issues))
115 +
        .route("/user/authenticators", get(approvals::list).post(approvals::add).delete(approvals::remove))
116 +
        .route("/user/approvals", get(approvals::pending))
117 +
        .route("/user/approvals/{id}", post(approvals::answer))
114 118
        .route("/user/sky", get(user_sky))
115 119
        .route("/user/starter", get(user_starter))
116 120
        .route("/user/flow/runs", get(user_flow_runs))
…
3394 unchanged lines
modified crates/hub/src/auth/mod.rs +1 −0
…
1 unchanged lines
2 2
//! session cookies, CSRF tokens, TOTP, personal access tokens, roles, and
3 3
//! the viewer extractor pages use.
4 4
5 +
pub mod phone;
5 6
pub mod roles;
6 7
pub mod totp;
7 8
pub mod viewer;
…
143 unchanged lines
modified crates/hub/src/db/mod.rs +1 −0
…
3 unchanged lines
4 4
5 5
pub mod accounts;
6 6
pub mod activity;
7 +
pub mod approvals;
7 8
pub mod attachments;
8 9
#[cfg(feature = "postgres")]
9 10
pub mod copy;
…
166 unchanged lines
modified crates/hub/src/db/sessions.rs +37 −11
…
18 unchanged lines
19 19
    pub seen_ms: i64,
20 20
    pub address: String,
21 21
    pub agent: String,
22 +
    /// When it last proved a second factor, or signed in (oxygenhub#356):
23 +
    /// the sudo step asks again once this is older than its window.
24 +
    pub second_factor_ms: i64,
22 25
}
23 26
24 27
impl Session {
…
3 unchanged lines
28 31
    }
29 32
}
30 33
31 -
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent";
34 +
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent, second_factor_ms";
32 35
33 36
pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> {
34 37
    let now = now_ms();
35 -
    sqlx::query_scalar("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id")
36 -
        .bind(account_id)
37 -
        .bind(hash)
38 -
        .bind(salt)
39 -
        .bind(now)
40 -
        .bind(now)
41 -
        .fetch_one(pool)
42 -
        .await
38 +
    // Signing in is as fresh as proof gets (oxygenhub#356).
39 +
    sqlx::query_scalar(
40 +
        "INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms, second_factor_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
41 +
    )
42 +
    .bind(account_id)
43 +
    .bind(hash)
44 +
    .bind(salt)
45 +
    .bind(now)
46 +
    .bind(now)
47 +
    .bind(now)
48 +
    .fetch_one(pool)
49 +
    .await
50 +
}
51 +
52 +
/// The session proved a second factor (or the password, for an account
53 +
/// without one) at `now`: the sudo step lets it through for a while.
54 +
pub async fn mark_second_factor(pool: &Pool, session_id: i64, now: i64) -> sqlx::Result<()> {
55 +
    sqlx::query("UPDATE sessions SET second_factor_ms = $1 WHERE id = $2").bind(now).bind(session_id).execute(pool).await?;
56 +
    Ok(())
57 +
}
58 +
59 +
pub async fn by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Session>> {
60 +
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE id = $1")).bind(id).fetch_optional(pool).await
43 61
}
44 62
45 63
pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> {
…
65 unchanged lines
111 129
    #[test]
112 130
    fn a_session_ends_on_a_new_salt_when_idle_or_when_old() {
113 131
        let t0 = 1_000_000_000_000;
114 -
        let s =
115 -
            Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() };
132 +
        let s = Session {
133 +
            id: 1,
134 +
            account_id: 1,
135 +
            salt: "a".into(),
136 +
            created_ms: t0,
137 +
            seen_ms: t0,
138 +
            address: String::new(),
139 +
            agent: String::new(),
140 +
            second_factor_ms: t0,
141 +
        };
116 142
        assert!(s.live("a", t0 + 1000));
117 143
        assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere");
118 144
        assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days");
…
5 unchanged lines
modified crates/hub/src/web/mod.rs +7 −0
…
57 unchanged lines
58 58
pub(crate) mod settings;
59 59
mod signin;
60 60
mod statics;
61 +
mod sudo;
61 62
pub mod test_reports;
62 63
mod variables;
63 64
mod webhooks;
…
40 unchanged lines
104 105
        .route("/favicon.ico", get(statics::favicon))
105 106
        .route("/static/{*path}", get(statics::asset))
106 107
        .route("/login", get(session::login_form).post(session::login))
108 +
        .route("/login/approval", post(session::approval_state))
109 +
        .route("/sudo", get(sudo::show).post(sudo::confirm))
110 +
        .route("/sudo/phone", post(sudo::ask_phone))
111 +
        .route("/sudo/approval/{id}", get(sudo::approval_state))
107 112
        .route("/device", get(device::form).post(device::answer))
108 113
        .route(crate::headers::REPORT_PATH, post(crate::headers::csp_report).layer(DefaultBodyLimit::max(16 * 1024)))
109 114
        .route("/logout", post(session::logout))
…
7 unchanged lines
117 122
        .route("/settings/security/totp/disable", post(settings::totp_disable))
118 123
        .route("/settings/security/sessions", post(settings::sessions_reset))
119 124
        .route("/settings/security/sessions/{id}/end", post(settings::session_end))
125 +
        .route("/settings/security/phones/{id}/remove", post(settings::phone_remove))
120 126
        .route("/settings/keys", get(settings::keys))
121 127
        .route("/settings/keys/upload", post(settings::keys_upload))
122 128
        .route("/settings/keys/verify", post(settings::keys_verify))
…
269 unchanged lines
392 398
        .route("/{owner}/{name}/milestones", get(issues::milestones).post(issues::save_milestone))
393 399
        .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit)))
394 400
        .fallback(not_found)
401 +
        .layer(axum::middleware::from_fn_with_state(app.clone(), sudo::gate))
395 402
        .layer(axum::middleware::from_fn_with_state(app.clone(), rejections_as_pages))
396 403
        .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin))
397 404
        .layer(CompressionLayer::new())
…
570 unchanged lines
modified crates/hub/src/web/session.rs +84 −1
…
25 unchanged lines
26 26
    reset: bool,
27 27
    /// Providers to sign in with (#106): (id, name).
28 28
    providers: Vec<(&'static str, &'static str)>,
29 +
    /// Waiting for the phone (oxygenhub#356): the approval and the number
30 +
    /// to pick on it.
31 +
    approval: Option<(String, i64)>,
32 +
    /// The account has a phone that approves, so the code step offers it.
33 +
    phones: bool,
29 34
}
30 35
31 36
#[derive(Deserialize)]
…
25 unchanged lines
57 62
        pending: String::new(),
58 63
        reset,
59 64
        providers,
65 +
        approval: None,
66 +
        phones: false,
60 67
    })
61 68
    .into_response()
62 69
}
…
10 unchanged lines
73 80
    code: String,
74 81
    #[serde(default)]
75 82
    next: String,
83 +
    /// The approval the phone gave this sign-in (oxygenhub#356).
84 +
    #[serde(default)]
85 +
    approval: String,
86 +
    /// Asked for the code rather than the phone.
87 +
    #[serde(default)]
88 +
    use_code: String,
76 89
}
77 90
78 91
pub async fn login(
…
16 unchanged lines
95 108
        .as_ref()
96 109
        .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS))
97 110
        .unwrap_or_default();
111 +
    let phones = match &account {
112 +
        Some(a) if a.totp_secret.is_some() => {
113 +
            !db::approvals::authenticators(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty()
114 +
        }
115 +
        _ => false,
116 +
    };
98 117
    let fail = |msg: &str, totp_for: Option<String>| {
99 118
        Page(LoginPage {
100 119
            shell: shell.clone(),
101 -
            error: Some(msg.into()),
120 +
            error: (!msg.is_empty()).then(|| msg.into()),
102 121
            next: next.clone(),
103 122
            totp_for,
104 123
            pending: pending.clone(),
105 124
            reset: false,
106 125
            providers: vec![],
126 +
            approval: None,
127 +
            phones,
107 128
        })
108 129
        .into_response()
109 130
    };
…
31 unchanged lines
141 162
    }
142 163
    if let Some(secret) = &account.totp_secret {
143 164
        if f.code.trim().is_empty() {
165 +
            // The phone, when the account has one (oxygenhub#356): an
166 +
            // approval it gave this sign-in, or a new one to ask for.
167 +
            if !f.use_code.is_empty() {
168 +
                return Ok(fail("", Some(username)));
169 +
            }
170 +
            if !f.approval.is_empty() {
171 +
                if !db::approvals::take(&app.db, &f.approval, account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
172 +
                    return Ok(fail("Your phone has not approved this sign-in. Try again, or use a code.", Some(username)));
173 +
                }
174 +
                app.throttle.lock().unwrap().cleared(&keys[0]);
175 +
                accounts::audit(&app.db, Some(&account), "login", &username, "approved on a phone").await.ok();
176 +
                return Ok(signed_in(&app, &account, &next).await);
177 +
            }
178 +
            if phones {
179 +
                let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
180 +
                let from = if addr.is_empty() { String::new() } else { format!(", {addr}") };
181 +
                let what = format!("Sign in from {}{from}", super::settings::browser_of(agent));
182 +
                let a =
183 +
                    crate::auth::phone::ask(&app, &account, "signin", &what, None).await.map_err(|e| Error::from_db(shell.clone(), e))?;
184 +
                return Ok(Page(LoginPage {
185 +
                    shell: shell.clone(),
186 +
                    error: None,
187 +
                    next: next.clone(),
188 +
                    totp_for: Some(username),
189 +
                    pending: pending.clone(),
190 +
                    reset: false,
191 +
                    providers: vec![],
192 +
                    approval: Some((a.id, a.number)),
193 +
                    phones,
194 +
                })
195 +
                .into_response());
196 +
            }
144 197
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
145 198
        }
146 199
        let now = (db::now_ms() / 1000) as u64;
…
13 unchanged lines
160 213
    app.throttle.lock().unwrap().cleared(&keys[0]);
161 214
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
162 215
    Ok(signed_in(&app, &account, &next).await)
216 +
}
217 +
218 +
#[derive(Deserialize)]
219 +
pub struct ApprovalPoll {
220 +
    username: String,
221 +
    pending: String,
222 +
    approval: String,
223 +
}
224 +
225 +
/// `POST /login/approval` (oxygenhub#356): what became of a sign-in's
226 +
/// approval, for the page waiting on the phone. Only the sign-in that
227 +
/// proved the password may ask.
228 +
pub async fn approval_state(State(app): State<Shared>, Form(f): Form<ApprovalPoll>) -> Response {
229 +
    let now = db::now_ms();
230 +
    let state = async {
231 +
        let account = accounts::account_by_name(&app.db, f.username.trim()).await.ok()??;
232 +
        if !auth::pending_login_ok(&app.cookie_key, &account.username, &account.session_salt, &f.pending, now) {
233 +
            return None;
234 +
        }
235 +
        let a = db::approvals::get(&app.db, &f.approval).await.ok()??;
236 +
        if a.account_id != account.id || a.kind != "signin" {
237 +
            return None;
238 +
        }
239 +
        Some(if a.state == "pending" && a.expires_ms <= now { "expired".to_string() } else { a.state })
240 +
    }
241 +
    .await;
242 +
    match state {
243 +
        Some(s) => axum::Json(serde_json::json!({ "state": s })).into_response(),
244 +
        None => (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(),
245 +
    }
163 246
}
164 247
165 248
/// How long the code step of a sign-in may take after the password.
…
163 unchanged lines
modified crates/hub/src/web/settings.rs +45 −2
…
304 unchanged lines
305 305
    connected: Vec<(&'static str, &'static str, Option<String>)>,
306 306
    /// Where the account is signed in (oxygenhub#223).
307 307
    sessions: Vec<SessionView>,
308 +
    /// Phones that approve as a second factor (oxygenhub#356).
309 +
    phones: Vec<crate::db::approvals::Authenticator>,
308 310
}
309 311
310 312
/// One signed-in browser, as Settings › Security lists it.
…
73 unchanged lines
384 386
    let me = require(&viewer, &shell)?;
385 387
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
386 388
    let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?;
387 -
    Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected, sessions }))
389 +
    let phones = crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
390 +
    Ok(Page(SecurityPage {
391 +
        shell,
392 +
        me: me.account,
393 +
        notice: q.notice,
394 +
        error: q.error,
395 +
        enrol: None,
396 +
        recovery: vec![],
397 +
        connected,
398 +
        sessions,
399 +
        phones,
400 +
    }))
388 401
}
389 402
390 403
/// Each sign-in provider this hub offers, with the login connected to the account.
…
35 unchanged lines
426 439
        recovery: vec![],
427 440
        connected: vec![],
428 441
        sessions: vec![],
442 +
        phones: vec![],
429 443
    }))
430 444
}
431 445
…
15 unchanged lines
447 461
            recovery: vec![],
448 462
            connected: vec![],
449 463
            sessions: vec![],
464 +
            phones: vec![],
450 465
        }));
451 466
    }
452 467
    let (codes, stored) = totp::new_recovery_codes();
…
9 unchanged lines
462 477
        recovery: codes,
463 478
        connected: vec![],
464 479
        sessions: vec![],
480 +
        phones: vec![],
465 481
    }))
466 482
}
467 483
…
11 unchanged lines
479 495
            recovery: vec![],
480 496
            connected: vec![],
481 497
            sessions: vec![],
498 +
            phones: vec![],
482 499
        }));
483 500
    }
484 501
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
485 502
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
503 +
    // A phone stands beside the app's codes, never alone (oxygenhub#356).
504 +
    for p in crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
505 +
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, p.token_id)
506 +
            .await
507 +
            .map_err(|e| Error::from_db(shell.clone(), e))?;
508 +
    }
486 509
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
487 510
    Ok(Page(SecurityPage {
488 511
        shell,
489 512
        me: fresh,
490 -
        notice: Some("Two-factor authentication is off.".into()),
513 +
        notice: Some("Two-factor authentication is off, and no phone approves for you now.".into()),
491 514
        error: None,
492 515
        enrol: None,
493 516
        recovery: vec![],
494 517
        connected: vec![],
495 518
        sessions: vec![],
519 +
        phones: vec![],
496 520
    }))
497 521
}
498 522
…
25 unchanged lines
524 548
    }
525 549
    let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." };
526 550
    Ok(Redirect::to(to).into_response())
551 +
}
552 +
553 +
/// `POST /settings/security/phones/{id}/remove` (oxygenhub#356): that
554 +
/// phone no longer approves for the account.
555 +
pub async fn phone_remove(
556 +
    State(app): State<Shared>,
557 +
    viewer: Viewer,
558 +
    Path(id): Path<i64>,
559 +
    Form(f): Form<super::session::CsrfForm>,
560 +
) -> Result<Response, Error> {
561 +
    let shell = Shell::new(&app, "/settings/security", &viewer);
562 +
    let me = require(&viewer, &shell)?;
563 +
    csrf(&viewer, &f.csrf, &shell)?;
564 +
    let removed =
565 +
        crate::db::approvals::remove_authenticator(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
566 +
    if removed {
567 +
        accounts::audit(&app.db, Some(&me.account), "2fa.phone_removed", &me.account.username, &id.to_string()).await.ok();
568 +
    }
569 +
    Ok(Redirect::to("/settings/security?notice=That+phone+no+longer+approves+for+you.#phones").into_response())
527 570
}
528 571
529 572
// ------------------------------------------------------------------- keys
…
291 unchanged lines
modified crates/hub/tests/accounts.rs +200 −0
…
1797 unchanged lines
1798 1798
    let anon = get(app, "/explore", None).await.body;
1799 1799
    assert!(!anon.contains("data-fragment=\"bell\""), "nothing to hear signed out");
1800 1800
}
1801 +
1802 +
/// A bearer call to the API: status and JSON.
1803 +
async fn bearer(app: &Shared, method: &str, path: &str, token: &str, json: Option<&str>) -> (StatusCode, serde_json::Value) {
1804 +
    let mut req = Request::builder().method(method).uri(path).header(header::AUTHORIZATION, format!("Bearer {token}"));
1805 +
    let body = match json {
1806 +
        Some(j) => {
1807 +
            req = req.header(header::CONTENT_TYPE, "application/json");
1808 +
            Body::from(j.to_string())
1809 +
        }
1810 +
        None => Body::empty(),
1811 +
    };
1812 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
1813 +
    let status = res.status();
1814 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
1815 +
    (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
1816 +
}
1817 +
1818 +
/// A value from a rendered form: `name="NAME" value="..."`.
1819 +
fn field(page: &str, name: &str) -> String {
1820 +
    page.split(&format!("name=\"{name}\" value=\""))
1821 +
        .nth(1)
1822 +
        .unwrap_or_else(|| panic!("no {name} in {page}"))
1823 +
        .split('"')
1824 +
        .next()
1825 +
        .unwrap()
1826 +
        .to_string()
1827 +
}
1828 +
1829 +
/// oxygenhub#356: a phone with a Keystore key approves sign-ins by the
1830 +
/// number the browser shows, a wrong number denies, the sudo step asks a
1831 +
/// stale session again before sensitive changes, and turning the app codes
1832 +
/// off takes the phone with them.
1833 +
#[tokio::test(flavor = "multi_thread")]
1834 +
async fn a_phone_approves_sign_ins_and_the_sudo_step() {
1835 +
    use ring::rand::SystemRandom;
1836 +
    use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING};
1837 +
    let h = hub().await;
1838 +
    let app = &h.app;
1839 +
    let andy = register(app, "andy", "").await;
1840 +
    let now_code = |secret: &str| oxygenhub::auth::totp::current(secret, (db::now_ms() / 1000) as u64).unwrap();
1841 +
1842 +
    // A token for the phone, as its sign-in would give it.
1843 +
    let page = get(app, "/settings/tokens", Some(&andy)).await;
1844 +
    let r = post(
1845 +
        app,
1846 +
        "/settings/tokens",
1847 +
        Some(&andy),
1848 +
        &format!("_csrf={}&name=Pixel&scopes=user%3Aread&scopes=repo%3Aread", csrf_of(&page.body)),
1849 +
    )
1850 +
    .await;
1851 +
    let token = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).expect(&r.body);
1852 +
1853 +
    // The phone's key, encoded as Android's Keystore gives it.
1854 +
    let rng = SystemRandom::new();
1855 +
    let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
1856 +
    let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap();
1857 +
    let spki = format!("3059301306072a8648ce3d020106082a8648ce3d030107034200{}", hex::encode(key.public_key().as_ref()));
1858 +
    let add = |code: &str| format!(r#"{{"public_key":"{spki}","code":"{code}"}}"#);
1859 +
1860 +
    // Without the app's codes there is nothing to fall back on: refused.
1861 +
    let (st, _) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add("123456"))).await;
1862 +
    assert_eq!(st, StatusCode::CONFLICT);
1863 +
    let sec = get(app, "/settings/security", Some(&andy)).await;
1864 +
    let csrf = csrf_of(&sec.body);
1865 +
    let r = post(app, "/settings/security/totp/begin", Some(&andy), &format!("_csrf={csrf}")).await;
1866 +
    let secret = field(&r.body, "secret");
1867 +
    let r =
1868 +
        post(app, "/settings/security/totp/enable", Some(&andy), &format!("_csrf={csrf}&secret={secret}&code={}", now_code(&secret))).await;
1869 +
    assert!(r.body.contains("Recovery codes"), "{}", r.body);
1870 +
    let (st, _) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add("000000"))).await;
1871 +
    assert_eq!(st, StatusCode::FORBIDDEN, "a token alone cannot add a phone");
1872 +
    let (st, v) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add(&now_code(&secret)))).await;
1873 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
1874 +
    assert_eq!((v["name"].as_str(), v["this"].as_bool()), (Some("Pixel"), Some(true)));
1875 +
    assert!(get(app, "/settings/security", Some(&andy)).await.body.contains("<strong>Pixel</strong>"), "listed under Phones");
1876 +
1877 +
    // Signing in asks the phone, showing one of the three numbers it offers.
1878 +
    let sign_in = || post(app, "/login", None, "username=andy&password=correct-horse-battery");
1879 +
    let r = sign_in().await;
1880 +
    assert!(r.body.contains("Open OxygenHub on your phone"), "{}", r.body);
1881 +
    let (id, pending) = (field(&r.body, "approval"), field(&r.body, "pending"));
1882 +
    let shown: i64 =
1883 +
        r.body.split("class=\"approve-number\"").nth(1).unwrap().split('>').nth(1).unwrap().split('<').next().unwrap().parse().unwrap();
1884 +
    let poll = |id: String, pending: String| async move {
1885 +
        let r = post(app, "/login/approval", None, &format!("username=andy&pending={pending}&approval={id}")).await;
1886 +
        serde_json::from_str::<serde_json::Value>(&r.body).unwrap()["state"].as_str().unwrap().to_string()
1887 +
    };
1888 +
    assert_eq!(poll(id.clone(), pending.clone()).await, "pending");
1889 +
    assert_eq!(poll(id.clone(), "1.forged".into()).await, "unknown", "only the sign-in that proved the password may ask");
1890 +
    let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await;
1891 +
    assert_eq!(v[0]["id"].as_str(), Some(id.as_str()), "{v}");
1892 +
    assert_eq!(v[0]["kind"], "signin");
1893 +
    assert!(v[0]["what"].as_str().unwrap().starts_with("Sign in from"), "{v}");
1894 +
    let choices: Vec<i64> = v[0]["choices"].as_array().unwrap().iter().map(|n| n.as_i64().unwrap()).collect();
1895 +
    assert!(choices.contains(&shown) && choices.len() == 3, "{choices:?} {shown}");
1896 +
    let sign = |id: &str, n: i64| hex::encode(key.sign(&rng, &oxygenhub::auth::phone::message(id, n, true)).unwrap().as_ref());
1897 +
    // Someone else's signature is not the phone's.
1898 +
    let other = EcdsaKeyPair::from_pkcs8(
1899 +
        &ECDSA_P256_SHA256_ASN1_SIGNING,
1900 +
        EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap().as_ref(),
1901 +
        &rng,
1902 +
    )
1903 +
    .unwrap();
1904 +
    let forged = hex::encode(other.sign(&rng, &oxygenhub::auth::phone::message(&id, shown, true)).unwrap().as_ref());
1905 +
    let answer = |n: i64, sig: &str| format!(r#"{{"approve":true,"number":{n},"signature":"{sig}"}}"#);
1906 +
    let (st, _) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown, &forged))).await;
1907 +
    assert_eq!(st, StatusCode::FORBIDDEN);
1908 +
    let (st, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown, &sign(&id, shown)))).await;
1909 +
    assert_eq!((st, v["state"].as_str()), (StatusCode::OK, Some("approved")), "{v}");
1910 +
    assert_eq!(poll(id.clone(), pending.clone()).await, "approved");
1911 +
    let finish = |id: String, pending: String| async move {
1912 +
        post(app, "/login", None, &format!("username=andy&pending={pending}&approval={id}&next=/")).await
1913 +
    };
1914 +
    let r = finish(id.clone(), pending.clone()).await;
1915 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
1916 +
    let phone_session = r.cookie.expect("signed in");
1917 +
    let r = finish(id.clone(), pending.clone()).await;
1918 +
    assert!(r.cookie.is_none() && r.body.contains("has not approved"), "an approval signs in once: {}", r.body);
1919 +
1920 +
    // A wrong number is a no, and a no signs nobody in.
1921 +
    let r = sign_in().await;
1922 +
    let (id, pending) = (field(&r.body, "approval"), field(&r.body, "pending"));
1923 +
    let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await;
1924 +
    let shown2 = r
1925 +
        .body
1926 +
        .split("class=\"approve-number\"")
1927 +
        .nth(1)
1928 +
        .unwrap()
1929 +
        .split('>')
1930 +
        .nth(1)
1931 +
        .unwrap()
1932 +
        .split('<')
1933 +
        .next()
1934 +
        .unwrap()
1935 +
        .parse::<i64>()
1936 +
        .unwrap();
1937 +
    let wrong = v[0]["choices"].as_array().unwrap().iter().map(|n| n.as_i64().unwrap()).find(|n| *n != shown2).unwrap();
1938 +
    let (_, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(wrong, &sign(&id, wrong)))).await;
1939 +
    assert_eq!(v["state"], "denied");
1940 +
    assert_eq!(poll(id.clone(), pending.clone()).await, "denied");
1941 +
    assert!(finish(id, pending).await.cookie.is_none());
1942 +
    // The code still works beside the phone.
1943 +
    let r = post(app, "/login", None, "username=andy&password=correct-horse-battery&use_code=1").await;
1944 +
    assert!(r.body.contains("six-digit code") && r.body.contains("Approve on my phone instead"), "{}", r.body);
1945 +
1946 +
    // The sudo step: a fresh sign-in passes, a stale session is asked again.
1947 +
    let tokens = |c: String| async move {
1948 +
        let page = get(app, "/settings/tokens", Some(&c)).await;
1949 +
        post(app, "/settings/tokens", Some(&c), &format!("_csrf={}&name=x&scopes=repo%3Aread", csrf_of(&page.body))).await
1950 +
    };
1951 +
    assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK, "just signed in");
1952 +
    sqlx::query("UPDATE sessions SET second_factor_ms = 0").execute(&app.db).await.unwrap();
1953 +
    let r = tokens(phone_session.clone()).await;
1954 +
    assert_eq!(r.status, StatusCode::SEE_OTHER);
1955 +
    assert!(r.location.as_deref().unwrap().starts_with("/sudo?next="), "{:?}", r.location);
1956 +
    let sudo = get(app, "/sudo?next=/settings/tokens", Some(&phone_session)).await;
1957 +
    assert!(sudo.body.contains("APPROVE ON MY PHONE"), "{}", sudo.body);
1958 +
    let r = post(app, "/sudo/phone", Some(&phone_session), &format!("_csrf={}&next=/settings/tokens", csrf_of(&sudo.body))).await;
1959 +
    let id = r.body.split("/sudo/approval/").nth(1).unwrap().split('"').next().unwrap().to_string();
1960 +
    let shown3: i64 =
1961 +
        r.body.split("class=\"approve-number\"").nth(1).unwrap().split('>').nth(1).unwrap().split('<').next().unwrap().parse().unwrap();
1962 +
    let state = |c: String, id: String| async move {
1963 +
        serde_json::from_str::<serde_json::Value>(&get(app, &format!("/sudo/approval/{id}"), Some(&c)).await.body).unwrap()["state"].clone()
1964 +
    };
1965 +
    assert_eq!(state(phone_session.clone(), id.clone()).await, "pending");
1966 +
    assert_eq!(state(andy.clone(), id.clone()).await, "unknown", "another session cannot watch it");
1967 +
    let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await;
1968 +
    assert_eq!(v[0]["kind"], "stepup");
1969 +
    let (_, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown3, &sign(&id, shown3)))).await;
1970 +
    assert_eq!(v["state"], "approved");
1971 +
    assert_eq!(state(phone_session.clone(), id).await, "approved");
1972 +
    assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK, "fresh again");
1973 +
    // Or a code from the app.
1974 +
    sqlx::query("UPDATE sessions SET second_factor_ms = 0").execute(&app.db).await.unwrap();
1975 +
    let sudo = get(app, "/sudo?next=/settings/tokens", Some(&phone_session)).await;
1976 +
    let r = post(app, "/sudo", Some(&phone_session), &format!("_csrf={}&next=/settings/tokens&code=000000", csrf_of(&sudo.body))).await;
1977 +
    assert!(r.body.contains("not right"), "{}", r.body);
1978 +
    let r = post(
1979 +
        app,
1980 +
        "/sudo",
1981 +
        Some(&phone_session),
1982 +
        &format!("_csrf={}&next=/settings/tokens&code={}", csrf_of(&sudo.body), now_code(&secret)),
1983 +
    )
1984 +
    .await;
1985 +
    assert_eq!((r.status, r.location.as_deref()), (StatusCode::SEE_OTHER, Some("/settings/tokens")));
1986 +
    assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK);
1987 +
1988 +
    // Turning the app's codes off takes the phone with them.
1989 +
    let sec = get(app, "/settings/security", Some(&phone_session)).await;
1990 +
    let r = post(
1991 +
        app,
1992 +
        "/settings/security/totp/disable",
1993 +
        Some(&phone_session),
1994 +
        &format!("_csrf={}&password=correct-horse-battery", csrf_of(&sec.body)),
1995 +
    )
1996 +
    .await;
1997 +
    assert!(r.body.contains("no phone approves"), "{}", r.body);
1998 +
    let (_, v) = bearer(app, "GET", "/api/v1/user/authenticators", &token, None).await;
1999 +
    assert_eq!(v.as_array().map(Vec::len), Some(0), "{v}");
2000 +
}
modified docs/spec.md +2 −0
…
192 unchanged lines
193 193
194 194
Sessions are server-side records (oxygenhub#223): the cookie `oxyhub=<id>` holds 32 random bytes in hex, and the hub keeps only the id's BLAKE3 hash, with the account, the account's session salt at sign-in, the times it was made and last used, and the address and browser it came from. A session is good while its salt is still the account's, it has been used in the last 14 days, and it is under 30 days old; the cookie is `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Every sign-in (after the code, with two-factor) makes a new session; signing out deletes its record. Password changes, resets and "sign out everywhere" rotate the salt, which ends every session at once; Settings › Security lists the signed-in browsers and signs any one out. Ended sessions are swept nightly. CSRF tokens are bound to the session id. Sign-in is also slowed against guessing (§15.6, oxygenhub#221). Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement.
195 195
196 +
**A phone as the second factor, and the sudo step (oxygenhub#356).** An account with an authenticator app may add phones: the OxygenHub app makes a P-256 key in the Android Keystore that signs only after a fingerprint, face or the device PIN, and registers its public half with `POST /api/v1/user/authenticators` (`{"public_key", "code"}`, the key's SubjectPublicKeyInfo in hex and the account's current app code, so a token alone cannot add a phone). A phone belongs to the sign-in (token) it made: signing that phone out, removing it under Settings › Security › Phones, or turning the app codes off removes it. Signing in with a phone, after the password the browser shows a two-digit number and the hub records an approval (two minutes) and sends it to the account's phones as an alert where alerts are on; the app also asks `GET /api/v1/user/approvals` when it opens. The phone offers three numbers; picking the one shown and confirming signs `oxygenhub approval v1\n{id}\n{number}\napprove` with its key (`POST /api/v1/user/approvals/{id}`), a wrong number denies, and the page, which asks `POST /login/approval` with its proof of the password every two seconds, signs in once, with the approval. "Use a code instead" stays. **The sudo step**: a change to the password, two-factor settings, phones, connected accounts, keys, tokens (making one, or an agent's), backups, an organisation's or repository's secrets, storage, webhooks, collaborators, push protection, deletion or transfer, and any change under `/admin`, from a session that has not proved a second factor for 15 minutes, goes first to `/sudo`, which asks the phone, an app or recovery code, or the password for an account with neither; signing in counts as proof. Audit records: `2fa.phone_added`, `2fa.approved`, `2fa.denied`, `2fa.phone_removed`, `sudo`, `sudo.failed`. Planned: the same step for API calls (landing into a protected bookmark, approving a guarded run) and organisations that require two-factor authentication.
197 +
196 198
#### 5.1.2 Sign in with GitHub or Google (done 2026-10-04)
197 199
198 200
An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, or it is by invitation and an unused invitation came through the round trip (`/login/{provider}?invite=CODE`, carried in the signed state as the purpose `invite:CODE`, used up when the account is made; the registration page shows the provider buttons for a usable invitation, oxygenhub#214), an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider.
…
698 unchanged lines
modified templates/login.html +13 −1
…
12 unchanged lines
13 13
  {% if let Some(u) = totp_for %}
14 14
  <input type="hidden" name="username" value="{{ u }}">
15 15
  <input type="hidden" name="pending" value="{{ pending }}">
16 +
  {% if let Some((id, number)) = approval %}
17 +
  {# Waiting on the phone (oxygenhub#356): the page asks what became of it and signs in once it is approved. #}
18 +
  <input type="hidden" name="approval" value="{{ id }}">
19 +
  <div class="approve-wait" data-approval-poll="/login/approval" data-approval-id="{{ id }}" aria-live="polite">
20 +
    <p>Password accepted for <strong>{{ u }}</strong>. Open OxygenHub on your phone and pick</p>
21 +
    <p class="approve-number" aria-label="the number {{ number }}">{{ number }}</p>
22 +
    <p class="small mute" data-approval-status>Waiting for your phone…</p>
23 +
  </div>
24 +
  <button type="submit" name="use_code" value="1" class="small" formnovalidate>Use a code instead</button>
25 +
  {% else %}
16 26
  <p>Password accepted for <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p>
17 27
  <label for="code">Code</label><input type="text" id="code" name="code" autocomplete="one-time-code" inputmode="numeric" autofocus>
28 +
  {% endif %}
18 29
  {% else %}
19 30
  <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" autofocus required>
20 31
  <label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required>
21 32
  {% endif %}
22 -
  <button type="submit">SIGN IN</button>
33 +
  {% if approval.is_none() %}<button type="submit">SIGN IN</button>{% endif %}
34 +
  {% if totp_for.is_some() && approval.is_none() && phones %}<button type="submit" name="phone" value="1" class="small" formnovalidate>Approve on my phone instead</button>{% endif %}
23 35
</form>
24 36
{% if !providers.is_empty() && totp_for.is_none() %}<div class="signin-or" aria-hidden="true"><span>or</span></div>
25 37
{% let verb = "Sign in" %}{% let suffix = "" %}{% include "signin_buttons.html" %}{% endif %}
…
3 unchanged lines
modified templates/settings_security.html +14 −0
…
33 unchanged lines
34 34
  </form>
35 35
  {% endif %}
36 36
</section>
37 +
{% if me.totp_enabled() %}<section class="card-box" id="phones">
38 +
  <div class="card-head"><h2>Phones</h2><span class="count-chip">{{ phones.len() }}</span><span class="small mute more">approve with a fingerprint instead of typing a code</span></div>
39 +
  {% if phones.is_empty() %}<p class="empty small">No phone approves for you yet. In the OxygenHub app, Account › <strong>Use this phone as a second factor</strong>.</p>{% else %}
40 +
  <ul class="rows token-rows">{% for p in phones %}
41 +
    <li>
42 +
      <div class="token-main">
43 +
        <div class="token-name"><strong>{{ p.name }}</strong></div>
44 +
        <div class="small mute">added <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time> · {% if p.last_used_ms > 0 %}last approved <time title="{{ p.last_used_ms|when_i }}">{{ p.last_used_ms|ago_i }}</time>{% else %}never used{% endif %}</div>
45 +
      </div>
46 +
      <div class="token-actions"><form class="inline" method="post" action="/settings/security/phones/{{ p.token_id }}/remove"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small">Remove</button></form></div>
47 +
    </li>{% endfor %}
48 +
  </ul>{% endif %}
49 +
  <p class="small mute pad sec-foot">Signing in, a phone shows three numbers; pick the one this page shows and confirm with your fingerprint, face or PIN. Your authenticator app's codes still work when the phone is not to hand.</p>
50 +
</section>{% endif %}
37 51
{% if !connected.is_empty() %}<section class="card-box" id="connected">
38 52
  <div class="card-head"><h2>Connected accounts</h2><span class="small mute more">sign in with these instead of a password</span></div>
39 53
  <ul class="rows token-rows">{% for (id, name, login) in connected %}
…
22 unchanged lines
modified templates/style.css +6 −0
…
1330 unchanged lines
1331 1331
.sec-row input { flex: 0 1 320px; min-width: 0; }
1332 1332
.sec-split { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1333 1333
.sec-split p { margin: 0; }
1334 +
/* Waiting for a phone's approval (oxygenhub#356): the number to pick, big enough to read across a desk. */
1335 +
.approve-wait { text-align: center; }
1336 +
.approve-wait p { margin: 6px 0; }
1337 +
.approve-number { font: 700 56px/1.1 var(--mono, ui-monospace, monospace); color: var(--acc); letter-spacing: 4px; margin: 8px 0 !important; }
1338 +
.approve-wait[data-state="approved"] .approve-number { color: var(--ok); }
1339 +
.approve-wait[data-state="denied"] .approve-number, .approve-wait[data-state="expired"] .approve-number { color: var(--bad); text-decoration: line-through; }
1334 1340
.card-box > .card-head form.more { margin-left: auto; }
1335 1341
.card-box > .sec-foot { margin: 0; border-top: 1px solid var(--line); }
1336 1342
.token-form { display: flex; flex-direction: column; gap: 6px; max-width: 720px; }
…
176 unchanged lines