Outline

What

  • SSH alike public key authentication
  • OAuth 2.1 (PKCE) alike signing flow
  • IdP that only frontend static site, easy to self-hosting
  • general-purpose public service, open source under AGPLv3 (releasing soon)

Why

  • identity shouldn't be exclusively tied to email or phone
  • pre site username / password flow is centralized and out of control
  • password managers ease up credential management, but not breaking the loop
  • Passkeys so far only leads to vendor look-in, (read: "Passkeys: A Shattered Dream")

How

user flow diagram

  1. starts login process
  2. navigate to signing site
    • client_id: UUID v5 (NS:URL) based on redirect_uri's origin
  3. username and password associated to the signing site only
    • seed: hard coded to the signing site, changeable for custom deploy
      • SHA256("quick brown fox jumps over the lazy dog")
  4. navigate back from signing site
    • pub: Ed25519 public key
    • state: received from step 2
    • timestamp: signing time in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ)
    • signature:
      • first compute sample from HMAC_SHA256({ key: timestamp, message: challenge })
      • then sign the sample by Ed25519 private key
  5. challenge retrieved by state or cookie
  6. logged in

Code Snippets

auth
import { sign } from './sign.js';
import { stretch } from './stretch.js';
import { SEED, mk_timestamp } from './common.js';





export const auth = pre_auth();

export function pre_auth ({

        seed = SEED,
        now = mk_timestamp,

} = {}) {

    return async function ({ client_id, challenge }: {

            client_id: string,
            challenge: string,

    }, { username, password }: {

            username: string,
            password: string,

    }) {

        const entropy = new Uint8Array(await stretch([
            password, client_id, username, seed
        ]));

        return sign(challenge, entropy, now());

    };

}

stretch
import { pre_PBKDF2 } from './pbkdf2.js';

import { str_to_buf } from './common.js';





const PBKDF2 = pre_PBKDF2({
    byte: 32,
    hash: 'SHA-512',
    iterations: 200_000,
});





export async function stretch ([

        head,         ...tail
]: [    head: string, ...tail: string[] ]) {

    let acc = str_to_buf(head).buffer;

    for (const next of tail) {

        acc = await PBKDF2({

                  salt: acc,
            passphrase: str_to_buf(next),

        });

    }

    return acc;

}

sign
import { getPublicKeyAsync, signAsync } from '@noble/ed25519';

import { HMAC_SHA256, str_to_buf, type Timestamp } from './common.js';





export const sign = pre_sign();

export function pre_sign ({

        HMAC = HMAC_SHA256,
        mk_signature = signAsync,
        mk_public_key = getPublicKeyAsync,

} = {}) {

    return async function (

            challenge: string,
            entropy: Uint8Array,
            timestamp: Timestamp,

    ) {

        const sample = new Uint8Array(await HMAC({

                key: str_to_buf(timestamp),
            message: str_to_buf(challenge),

        }));

        const signature = await mk_signature(sample, entropy);

        const pub = await mk_public_key(entropy);

        return { pub, signature, timestamp };

    };

}