๐Ÿฆ‹๐Ÿ”‘ PAM provider for ATProto

so far...

since someone else is working on something similar, though i'd slap this mess up finally to compare notes

ducky.ws d76e501a

+214
+8
deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run --watch src/main.ts" 4 + }, 5 + "imports": { 6 + "@std/assert": "jsr:@std/assert@1" 7 + } 8 + }
+36
native/src/nss_atpam.c
··· 1 + 2 + /* libnss_atpam โ€“ synthesise passwd entries for at(plc|web)-* names */ 3 + #define _GNU_SOURCE 4 + #include <errno.h> 5 + #include <nss.h> 6 + #include <pwd.h> 7 + #include <string.h> 8 + #include <stdlib.h> 9 + #include <stdio.h> 10 + #include <sys/types.h> 11 + 12 + static uid_t uid_for(const char *name) 13 + { 14 + return 2000 + (name[7]*31 + name[8]) % 30000; 15 + } 16 + 17 + enum nss_status _nss_atpam_getpwnam_r(const char *name, struct passwd *pw, 18 + char *buf, size_t buflen, int *err){ 19 + if (strncmp(name,"didplc-",6) && strncmp(name,"didweb-",6)) 20 + return NSS_STATUS_NOTFOUND; 21 + 22 + pw->pw_name = (char*)name; 23 + //pw->pw_uid = uid_for(name); 24 + pw->pw_uid = 60051; 25 + pw->pw_gid = 60050; /* must exist */ 26 + pw->pw_gecos= (char*)"atpam user"; 27 + pw->pw_dir = buf; 28 + pw->pw_shell= (char*)"/bin/bash"; 29 + int needed = snprintf(buf,buflen,"/home/%s",name); 30 + if ((size_t)needed>=buflen){ *err=ERANGE; return NSS_STATUS_TRYAGAIN;} 31 + return NSS_STATUS_SUCCESS; 32 + } 33 + enum nss_status _nss_atpam_getpwuid_r(uid_t uid, struct passwd *pw, 34 + char *buf, size_t buflen, int *err){ 35 + return NSS_STATUS_NOTFOUND; 36 + }
+68
native/src/pam_atpam.c
··· 1 + /* pam_atpam.c โ€“ talk to the atpam Unix-socket daemon (DEBUG build) */ 2 + #define PAM_SM_AUTH 3 + #include <security/pam_modules.h> 4 + #include <security/pam_ext.h> 5 + #include <stdio.h> 6 + #include <string.h> 7 + #include <unistd.h> 8 + #include <sys/socket.h> 9 + #include <sys/un.h> 10 + 11 + #define SOCK "/run/atpam.sock" 12 + 13 + PAM_EXTERN int 14 + pam_sm_authenticate(pam_handle_t *pamh, int flags, 15 + int argc, const char **argv) 16 + { 17 + const char *user = NULL, *pass = NULL; 18 + pam_get_user(pamh, &user, NULL); 19 + pam_get_authtok(pamh, PAM_AUTHTOK, &pass, NULL); 20 + 21 + fprintf(stderr, "DEBUG pam_atpam: user=[%s] pass-len=%zu\n", 22 + user ? user : "(null)", pass ? strlen(pass) : 0); 23 + 24 + const char *field, *value; 25 + if (strchr(user, ':')) { 26 + field = "did"; value = user; 27 + } else if (strncmp(user, "didplc-", 7) == 0 || 28 + strncmp(user, "didweb-", 7) == 0) { 29 + field = "unix"; value = user; 30 + } else { 31 + field = "handle"; value = user; 32 + } 33 + fprintf(stderr, "DEBUG pam_atpam: chosen field=[%s] value=[%s]\n", field, value); 34 + 35 + char json[512]; 36 + int n = snprintf(json, sizeof(json), 37 + "{\"%s\":\"%s\",\"password\":\"%s\"}", 38 + field, value, pass ? pass : ""); 39 + fprintf(stderr, "DEBUG pam_atpam: sending %.*s\n", n, json); 40 + 41 + int fd = socket(AF_UNIX, SOCK_STREAM, 0); 42 + if (fd < 0) { perror("socket"); return PAM_AUTH_ERR; } 43 + 44 + struct sockaddr_un addr = { .sun_family = AF_UNIX }; 45 + strncpy(addr.sun_path, SOCK, sizeof(addr.sun_path) - 1); 46 + 47 + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { 48 + perror("connect"); 49 + close(fd); 50 + return PAM_AUTH_ERR; 51 + } 52 + fprintf(stderr, "DEBUG pam_atpam: connected to %s\n", SOCK); 53 + 54 + if (write(fd, json, n) != n) { perror("write"); close(fd); return PAM_AUTH_ERR; } 55 + 56 + n = read(fd, json, sizeof(json) - 1); 57 + close(fd); 58 + if (n <= 0) { perror("read"); return PAM_AUTH_ERR; } 59 + json[n] = '\0'; 60 + 61 + fprintf(stderr, "DEBUG pam_atpam: daemon replied [%s]\n", json); 62 + return strstr(json, "\"ok\":true") ? PAM_SUCCESS : PAM_AUTH_ERR; 63 + } 64 + 65 + PAM_EXTERN int 66 + pam_sm_setcred(pam_handle_t *pamh, int flags, 67 + int argc, const char **argv) 68 + { return PAM_SUCCESS; }
+102
src/main.ts
··· 1 + import { AtpAgent } from "https://esm.sh/@atproto/api@0.14?target=deno"; 2 + 3 + const SOCK = "/run/atpam.sock"; 4 + const PID = "/run/atpam.pid"; 5 + const DIDMAP = "/etc/atpam/dids.json"; 6 + const AT_ENDPOINT = "https://zio.blue"; // NOTE: hardcoded because lazy rn 7 + 8 + function exists(path: string): boolean { 9 + try { Deno.statSync(path); return true; } 10 + catch (e: any) { 11 + if (e instanceof Deno.errors.NotFound) return false; 12 + throw e; 13 + } 14 + } 15 + const loadMap = () => exists(DIDMAP) ? JSON.parse(Deno.readTextFileSync(DIDMAP)) : {}; 16 + const saveMap = (m: Record<string,string>) => 17 + Deno.writeTextFileSync(DIDMAP, JSON.stringify(m, null, 2)); 18 + 19 + const didToUnix = (did: string) => 20 + did.replace(/^did:/, "").replace(/:/g, "-").replace(/\./g, "-"); 21 + 22 + async function resolveCurrentHandle(did: string): Promise<string> { 23 + const [meth, id] = did.replace(/^did:/, "").split(":"); 24 + if (meth === "plc") { 25 + const doc = await fetch(`https://plc.directory/${did}`).then(r => r.json()); 26 + const aka = doc.alsoKnownAs?.find((s: string) => s.startsWith("at://")); 27 + return aka ? aka.slice(5) : "handle.invalid"; 28 + } else if (meth === "web") { 29 + const doc = await fetch(`https://${id}/.well-known/did.json`).then(r => r.json()); 30 + const aka = doc.alsoKnownAs?.find((s: string) => s.startsWith("at://")); 31 + return aka ? aka.slice(5) : "handle.invalid"; 32 + } 33 + return "handle.invalid"; 34 + } 35 + 36 + async function authenticate( 37 + cred: {handle?:string; did?:string; unix?:string; password:string} 38 + ): Promise<{ok:boolean; unixUser?:string; error?:string}> { 39 + const map = loadMap(); 40 + let did: string; 41 + 42 + if (cred.did) { 43 + did = cred.did; 44 + } else if (cred.unix) { 45 + const expand = (u: string): string | null => { 46 + if (u.startsWith("didplc-")) return "did:plc:" + u.slice(7); 47 + if (u.startsWith("didweb-")) return "did:web:" + u.slice(7).replace(/-/g, "."); 48 + return null; 49 + }; 50 + const exp = expand(cred.unix); 51 + if (!exp) return {ok:false, error:"Bad unix name"}; 52 + did = exp; 53 + } else if (cred.handle) { 54 + let cached = map[cred.handle]; 55 + if (!cached) { 56 + const u = new URL(`${AT_ENDPOINT}/xrpc/com.atproto.identity.resolveHandle`); 57 + u.searchParams.set("handle", cred.handle); 58 + const {did: resolved} = await fetch(u).then(r => r.json()); 59 + if (!resolved) return {ok:false, error:"Handle not found"}; 60 + cached = resolved; 61 + map[cred.handle] = cached; 62 + saveMap(map); 63 + } 64 + did = cached; 65 + } else { 66 + return {ok:false, error:"No identity provided"}; 67 + } 68 + 69 + const current = await resolveCurrentHandle(did); 70 + if (current === "handle.invalid") 71 + return {ok:false, error:"DID has no valid handle"}; 72 + 73 + const agent = new AtpAgent({service: AT_ENDPOINT}); 74 + //try { 75 + await agent.login({identifier:current, password:cred.password}); 76 + //} catch { 77 + // return {ok:false, error:"Auth failed"}; 78 + //} 79 + return {ok:true, unixUser: didToUnix(did)}; 80 + } 81 + 82 + /* ---------- socket server ---------- */ 83 + try { await Deno.remove(SOCK); } catch {} 84 + const listener = Deno.listen({ path: SOCK, transport: "unix" }); 85 + Deno.writeTextFileSync(PID, String(Deno.pid)); 86 + console.log(`atpam listening on ${SOCK} (PID ${Deno.pid})`); 87 + 88 + for await (const conn of listener) { 89 + (async () => { 90 + const buf = new Uint8Array(4096); 91 + const n = await conn.read(buf); 92 + if (!n) return conn.close(); 93 + //try { 94 + const cred = JSON.parse(new TextDecoder().decode(buf.slice(0, n))); 95 + const res = await authenticate(cred); 96 + await conn.write(new TextEncoder().encode(JSON.stringify(res))); 97 + //} catch { 98 + // await conn.write(new TextEncoder().encode(JSON.stringify({ok:false}))); 99 + //} 100 + conn.close(); 101 + })(); 102 + }