gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mremain/lib/sshkeys.hl

6.3 KB

  1. // lib/sshkeys.hl — SSH KEYS for git over SSH (ticket gitoria#7; docs/git-backend.md "Credentials"). A logged-in user pastes a
  2. // PUBLIC key; the sshd container (docker/sshd) asks this app which user a key belongs to (`/__git/keys`, lib/api.hl gitKeys → keyLine), so a new
  3. // key works at once and no authorized_keys file is edited. Only the public key is stored — nothing secret.
  4. // sshKeysTable pk @id index !key, user { user (users @id), name, type, key (base64), fingerprint, created } storage/mpackdb/sshkeys.db
  5. // The key is checked by `ssh-keygen -l -f` (the real parser): one line, an allowed type, a real key, RSA at least 2048 bits.
  6. import { MPackDB } from 'hl:mpackdb'
  7. import { run } from 'hl:proc'
  8. import { now } from 'hl:time'
  9. import { writeFile, remove, exists } from 'hl:fs'
  10. import { randomBytes } from 'hl:crypto'
  11. import { NL, storageDir, countOfList, firstOf, plainError, envOr, newestFirst, localStamp } from './util.hl'
  12. import { userRecord } from './users.hl'
  13. import { tmpDir } from './git.hl'
  14. static sshKeysTable = new MPackDB(file = storageDir + '/sshkeys.db', primaryKey = '@id', indexes = ['!key', 'user'])
  15. static maxKeys = 20
  16. // SSH is offered only when the sshd container is set up: the shared secret it uses to ask this app is in the environment
  17. static sshSecret = envOr('GITORIA_SSH_SECRET', '')
  18. static sshPort = envOr('GITORIA_SSH_PORT', '2222')
  19. // the host name in SSH clone addresses: repo addresses go through Cloudflare's proxy, which does not pass SSH, so
  20. // production sets a DNS-only name (GITORIA_SSH_HOST=ssh.gitoria.worldapi.org); empty = the page's own host (dev, gates)
  21. static sshHost = envOr('GITORIA_SSH_HOST', '')
  22. static sshEnabled = sshSecret != ''
  23. static allowedTypes = ['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-rsa', '[email protected]', '[email protected]']
  24. // the address to clone from over SSH: `git@host:slug.git` on port 22, else the ssh:// form with the port
  25. static sshUrl = (host, slug) => {
  26. h = sshHost != '' ? sshHost : host
  27. if (sshPort == '22') { return 'git@' + h + ':' + slug + '.git' }
  28. return 'ssh://git@' + h + ':' + sshPort + '/' + slug + '.git'
  29. }
  30. static isKeyBase64 = (s) => {
  31. if (s.length < 20 || s.length > 1200) { return false }
  32. let i = 0
  33. while (i < s.length) {
  34. let c = s.charCodeAt(i)
  35. if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 43 || c == 47 || c == 61)) { return false }
  36. i = i + 1
  37. }
  38. return true
  39. }
  40. static rowOfKey = (k) => { return { id = k.id name = k.name fingerprint = k.fingerprint created = localStamp(k.created) createdMs = k.created } }
  41. // a user's keys, newest first
  42. static keyRows = (userId) => {
  43. out = []
  44. all = sshKeysTable.find('user', userId)
  45. if (countOfList(all) == 0) { return out }
  46. for (k of all) { out.push(rowOfKey(k)) }
  47. return newestFirst(out)
  48. }
  49. // `ssh-keygen -l` on the key: → { bits, fingerprint } or null when it is not a key
  50. static inspectKey = (type, key) => {
  51. file = tmpDir() + '/' + randomBytes(12, 'hex') + '.pub'
  52. writeFile(file, type + ' ' + key + NL, 384)
  53. r = run(['ssh-keygen', '-l', '-f', file], { timeout = 10 })
  54. if (exists(file)) { remove(file) }
  55. if (r == null || r.exit != 0 || r.lines.length == 0) { return null }
  56. parts = r.lines[0].split(' ')
  57. if (parts.length < 2 || !parts[1].startsWith('SHA256:')) { return null }
  58. return { bits = toNumber(parts[0]) fingerprint = parts[1] }
  59. }
  60. // a new key: { row } or { error }. `text` is the line from the .pub file: `<type> <base64> [comment]`
  61. static addKey = (userId, name, text) => {
  62. u = userRecord(userId)
  63. if (u == null) { return { error = 'log in with ident (top right) first' } }
  64. n = name == null ? '' : ('' + name).trim()
  65. bad = plainError(n, 60, 'the key name')
  66. if (bad != null) { return { error = bad } }
  67. if (n == '') { return { error = 'give the key a name (for example the computer it is for)' } }
  68. if (text == null || hlTypeName(text) != 'String' || text.length > 2000) { return { error = 'paste the public key (the .pub file)' } }
  69. t = text.trim()
  70. if (t.startsWith('-----')) { return { error = 'that is a PRIVATE key — never paste it. Paste the .pub file: ssh-keygen -y -f ~/.ssh/id_ed25519' } }
  71. if (t.includes(NL)) { return { error = 'paste one public key, on one line' } }
  72. parts = t.split(' ')
  73. let type = parts[0]
  74. if (parts.length < 2 || !allowedTypes.includes(type)) { return { error = 'not a public key of a supported type (ssh-ed25519, ssh-rsa, ecdsa, sk-…)' } }
  75. let key = parts[1]
  76. if (!isKeyBase64(key)) { return { error = 'the key text is not valid' } }
  77. info = inspectKey(type, key)
  78. if (info == null) { return { error = 'the key text is not valid' } }
  79. if (type == 'ssh-rsa' && info.bits < 2048) { return { error = 'RSA keys need at least 2048 bits' } }
  80. if (countOfList(sshKeysTable.find('key', key)) > 0) { return { error = 'that key is already added' } }
  81. if (countOfList(sshKeysTable.find('user', u.id)) >= maxKeys) { return { error = 'you have ' + maxKeys + ' keys already — remove one first' } }
  82. id = sshKeysTable.put({ user = u.id name = n type = type key = key fingerprint = info.fingerprint created = now() })
  83. if (id == null) { return { error = 'could not store the key: ' + sshKeysTable.lastError() } }
  84. return { row = rowOfKey(sshKeysTable.fetch(id)) }
  85. }
  86. // remove one of the user's own keys (works at once: the sshd container asks again on every login)
  87. static revokeKey = (userId, keyId) => {
  88. if (userId == null || keyId == null || hlTypeName(keyId) != 'String') { return { error = 'no such key' } }
  89. k = sshKeysTable.fetch(keyId)
  90. if (k == null || k.user != userId) { return { error = 'no such key' } }
  91. sshKeysTable.delete(keyId)
  92. return { ok = true }
  93. }
  94. // the user a key belongs to (users @id) or null
  95. static userOfKey = (type, key) => {
  96. if (type == null || key == null || hlTypeName(type) != 'String' || hlTypeName(key) != 'String' || !isKeyBase64(key)) { return null }
  97. k = firstOf(sshKeysTable.find('key', key))
  98. if (k == null || k.type != type) { return null }
  99. return k.user
  100. }
  101. // what the sshd container's AuthorizedKeysCommand gets for a key (lib/api.hl gitKeys): the authorized_keys line of a known
  102. // user's key — forced command `gitoria-shell <user id>`, nothing else allowed — or '' for an unknown one
  103. static keyLine = (type, key) => {
  104. userId = userOfKey(type, key)
  105. if (userId == null || userRecord(userId) == null) { return '' }
  106. return 'restrict,command="/usr/local/bin/gitoria-shell ' + userId + '" ' + type + ' ' + key + NL
  107. }

Branches

Latest commits

  • 4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mre
  • 86605446mission 002 (code order) 4/4: README file map + import order + 'Same output' test + gate run with a tickets HEAD copy, STATUS (entry, lessons), LOG, report; tests/realdata-baseline.mjs + realdata-compare.py (a cleanup answers the same on live data: pages, modules, API, git over HTTPS and SSH, faces), tests/letcount.pymre
  • cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
  • 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
  • 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
  • fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
  • 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
  • 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
  • e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
  • 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
  • 3dc43108antcolony#40: mission references point to the moved missionsmre
  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre