gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9b27cb269b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre9b27cb26/sshkeys.hl

6.1 KB

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

Branches

Latest commits

  • 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