Persist OAuth sessions and the Jetstream cursor
A SQLite wrapper over the library the storage spike proved, plus the three tables a restart must not lose -- oauth_auth_requests, oauth_sessions, jetstream_cursor -- matching the Go backend's schema. Rows hold JSON, as they do there, so adding a session field is not a migration. Cursor writes are batched: the cursor advances on every event, often hundreds a second, and writing synchronously would make the database the bottleneck for a firehose. The stored cursor therefore trails the live one, which is the gap the supervisor's rewind already covers. The DPoP key is the check worth having. A missing row is loud; a key that round-trips almost correctly is not, and would fail every request after a restart with nothing pointing at storage. ECDSA is randomised, so the reloaded key is proved by signing with it and verifying against the original public point, with a second key as a negative control. The public half is stored rather than re-derived, since deriving it needs a curve operation and a key that cannot be read back without one is a restart that drops every session. These rows are key material: a session holds the DPoP private key and the refresh token, which together are the account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1971b41 parent: 5eef251 modified
spike/nim/.gitignore +4 -0 | @@ -6,3 +6,7 @@ oauth_probe | ||
| 6 | 6 | xrpc_probe |
| 7 | 7 | jetstream_probe |
| 8 | 8 | reconnect_probe |
| 9 | +store_probe | |
| 10 | +*.db | |
| 11 | +*.db-wal | |
| 12 | +*.db-shm | |
| @@ -6,3 +6,7 @@ oauth_probe | |||
| 6 | xrpc_probe | 6 | xrpc_probe |
| 7 | jetstream_probe | 7 | jetstream_probe |
| 8 | reconnect_probe | 8 | reconnect_probe |
| 9 | +store_probe | ||
| 10 | +*.db | ||
| 11 | +*.db-wal | ||
| 12 | +*.db-shm | ||
added
spike/nim/atproto/cursor_store.nim +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +## A Jetstream cursor store backed by SQLite, with batched writes. | |
| 2 | +## | |
| 3 | +## Bridges `store` and `jetstream_run`. Separate from both so the supervisor | |
| 4 | +## does not drag a database dependency behind it, and so an application can | |
| 5 | +## substitute its own persistence. | |
| 6 | +## | |
| 7 | +## Writes are batched because the cursor advances on *every* event -- often | |
| 8 | +## hundreds a second -- and a synchronous write per event would make the | |
| 9 | +## database the bottleneck for a firehose. The cost is that the stored cursor | |
| 10 | +## trails the live one, which is exactly the gap the supervisor's rewind | |
| 11 | +## exists to cover. | |
| 12 | + | |
| 13 | +import std/times | |
| 14 | +import ./jetstream_run | |
| 15 | +import ./sqlite | |
| 16 | +import ./store | |
| 17 | + | |
| 18 | +type DbCursorStore* = ref object of CursorStore | |
| 19 | + db*: Db | |
| 20 | + flushEvery*: int64 ## seconds | |
| 21 | + lastFlush: int64 | |
| 22 | + pending: int64 | |
| 23 | + dirty: bool | |
| 24 | + | |
| 25 | +proc newDbCursorStore*(db: Db, flushEvery = 5'i64): DbCursorStore = | |
| 26 | + result = DbCursorStore(db: db, flushEvery: flushEvery, | |
| 27 | + lastFlush: getTime().toUnix) | |
| 28 | + result.cursor = loadCursor(db) | |
| 29 | + result.pending = result.cursor | |
| 30 | + | |
| 31 | +method load*(s: DbCursorStore): int64 {.gcsafe, raises: [].} = | |
| 32 | + s.cursor | |
| 33 | + | |
| 34 | +proc flushNow*(s: DbCursorStore) = | |
| 35 | + ## Write the newest cursor out. Safe to call at any time; a failure is | |
| 36 | + ## swallowed, because losing a cursor update costs a replay and raising | |
| 37 | + ## here would cost the subscription. | |
| 38 | + if not s.dirty: return | |
| 39 | + try: | |
| 40 | + saveCursor(s.db, s.pending) | |
| 41 | + s.cursor = s.pending | |
| 42 | + s.dirty = false | |
| 43 | + s.lastFlush = getTime().toUnix | |
| 44 | + except CatchableError: | |
| 45 | + discard | |
| 46 | + | |
| 47 | +method save*(s: DbCursorStore, cursor: int64) {.gcsafe, raises: [].} = | |
| 48 | + ## Called per event. Records in memory and writes at most once per | |
| 49 | + ## flushEvery seconds. | |
| 50 | + if cursor <= s.pending: return | |
| 51 | + s.pending = cursor | |
| 52 | + s.dirty = true | |
| 53 | + let now = try: getTime().toUnix except CatchableError: return | |
| 54 | + if now - s.lastFlush >= s.flushEvery: | |
| 55 | + s.flushNow() | |
| 56 | + | |
| 57 | +proc close*(s: DbCursorStore) = | |
| 58 | + ## Final flush, so a clean shutdown does not throw away the last batch. | |
| 59 | + s.flushNow() | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +## A Jetstream cursor store backed by SQLite, with batched writes. | ||
| 2 | +## | ||
| 3 | +## Bridges `store` and `jetstream_run`. Separate from both so the supervisor | ||
| 4 | +## does not drag a database dependency behind it, and so an application can | ||
| 5 | +## substitute its own persistence. | ||
| 6 | +## | ||
| 7 | +## Writes are batched because the cursor advances on *every* event -- often | ||
| 8 | +## hundreds a second -- and a synchronous write per event would make the | ||
| 9 | +## database the bottleneck for a firehose. The cost is that the stored cursor | ||
| 10 | +## trails the live one, which is exactly the gap the supervisor's rewind | ||
| 11 | +## exists to cover. | ||
| 12 | + | ||
| 13 | +import std/times | ||
| 14 | +import ./jetstream_run | ||
| 15 | +import ./sqlite | ||
| 16 | +import ./store | ||
| 17 | + | ||
| 18 | +type DbCursorStore* = ref object of CursorStore | ||
| 19 | + db*: Db | ||
| 20 | + flushEvery*: int64 ## seconds | ||
| 21 | + lastFlush: int64 | ||
| 22 | + pending: int64 | ||
| 23 | + dirty: bool | ||
| 24 | + | ||
| 25 | +proc newDbCursorStore*(db: Db, flushEvery = 5'i64): DbCursorStore = | ||
| 26 | + result = DbCursorStore(db: db, flushEvery: flushEvery, | ||
| 27 | + lastFlush: getTime().toUnix) | ||
| 28 | + result.cursor = loadCursor(db) | ||
| 29 | + result.pending = result.cursor | ||
| 30 | + | ||
| 31 | +method load*(s: DbCursorStore): int64 {.gcsafe, raises: [].} = | ||
| 32 | + s.cursor | ||
| 33 | + | ||
| 34 | +proc flushNow*(s: DbCursorStore) = | ||
| 35 | + ## Write the newest cursor out. Safe to call at any time; a failure is | ||
| 36 | + ## swallowed, because losing a cursor update costs a replay and raising | ||
| 37 | + ## here would cost the subscription. | ||
| 38 | + if not s.dirty: return | ||
| 39 | + try: | ||
| 40 | + saveCursor(s.db, s.pending) | ||
| 41 | + s.cursor = s.pending | ||
| 42 | + s.dirty = false | ||
| 43 | + s.lastFlush = getTime().toUnix | ||
| 44 | + except CatchableError: | ||
| 45 | + discard | ||
| 46 | + | ||
| 47 | +method save*(s: DbCursorStore, cursor: int64) {.gcsafe, raises: [].} = | ||
| 48 | + ## Called per event. Records in memory and writes at most once per | ||
| 49 | + ## flushEvery seconds. | ||
| 50 | + if cursor <= s.pending: return | ||
| 51 | + s.pending = cursor | ||
| 52 | + s.dirty = true | ||
| 53 | + let now = try: getTime().toUnix except CatchableError: return | ||
| 54 | + if now - s.lastFlush >= s.flushEvery: | ||
| 55 | + s.flushNow() | ||
| 56 | + | ||
| 57 | +proc close*(s: DbCursorStore) = | ||
| 58 | + ## Final flush, so a clean shutdown does not throw away the last batch. | ||
| 59 | + s.flushNow() | ||
modified
spike/nim/atproto/dpop.nim +25 -0 | @@ -99,6 +99,31 @@ proc accessTokenHash*(accessToken: string): string = | ||
| 99 | 99 | ## exists to prevent. |
| 100 | 100 | b64u(sha256.digest(accessToken).data) |
| 101 | 101 | |
| 102 | +proc verifyEs256*(k: P256Key, signingInput: string, | |
| 103 | + sig: openArray[byte]): bool = | |
| 104 | + ## Verify a raw R||S signature against this key's public half. | |
| 105 | + ## | |
| 106 | + ## Mostly useful for proving a key survived a round trip: ECDSA is | |
| 107 | + ## randomised, so a reloaded key cannot be checked by re-signing and | |
| 108 | + ## comparing bytes. Signing with the reloaded key and verifying against the | |
| 109 | + ## original public point does establish the private scalar came back intact. | |
| 110 | + if sig.len != 64: return false | |
| 111 | + var q: array[65, byte] | |
| 112 | + q[0] = 0x04 | |
| 113 | + for i in 0 ..< 32: | |
| 114 | + q[1 + i] = k.pubX[i] | |
| 115 | + q[33 + i] = k.pubY[i] | |
| 116 | + | |
| 117 | + var pk: EcPublicKey | |
| 118 | + pk.curve = EC_secp256r1.cint | |
| 119 | + pk.q = cast[ptr byte](addr q[0]) | |
| 120 | + pk.qlen = 65 | |
| 121 | + | |
| 122 | + let digest = sha256.digest(signingInput).data | |
| 123 | + ecdsaVrfyRawGetDefault()( | |
| 124 | + addr ecPrimeI31, unsafeAddr digest[0], digest.len.uint, | |
| 125 | + addr pk, unsafeAddr sig[0], sig.len.uint) == 1 | |
| 126 | + | |
| 102 | 127 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, |
| 103 | 128 | nonce = "", accessToken = "", issuer = ""): string = |
| 104 | 129 | ## A DPoP proof JWT (RFC 9449). |
| @@ -99,6 +99,31 @@ proc accessTokenHash*(accessToken: string): string = | |||
| 99 | ## exists to prevent. | 99 | ## exists to prevent. |
| 100 | b64u(sha256.digest(accessToken).data) | 100 | b64u(sha256.digest(accessToken).data) |
| 101 | 101 | ||
| 102 | +proc verifyEs256*(k: P256Key, signingInput: string, | ||
| 103 | + sig: openArray[byte]): bool = | ||
| 104 | + ## Verify a raw R||S signature against this key's public half. | ||
| 105 | + ## | ||
| 106 | + ## Mostly useful for proving a key survived a round trip: ECDSA is | ||
| 107 | + ## randomised, so a reloaded key cannot be checked by re-signing and | ||
| 108 | + ## comparing bytes. Signing with the reloaded key and verifying against the | ||
| 109 | + ## original public point does establish the private scalar came back intact. | ||
| 110 | + if sig.len != 64: return false | ||
| 111 | + var q: array[65, byte] | ||
| 112 | + q[0] = 0x04 | ||
| 113 | + for i in 0 ..< 32: | ||
| 114 | + q[1 + i] = k.pubX[i] | ||
| 115 | + q[33 + i] = k.pubY[i] | ||
| 116 | + | ||
| 117 | + var pk: EcPublicKey | ||
| 118 | + pk.curve = EC_secp256r1.cint | ||
| 119 | + pk.q = cast[ptr byte](addr q[0]) | ||
| 120 | + pk.qlen = 65 | ||
| 121 | + | ||
| 122 | + let digest = sha256.digest(signingInput).data | ||
| 123 | + ecdsaVrfyRawGetDefault()( | ||
| 124 | + addr ecPrimeI31, unsafeAddr digest[0], digest.len.uint, | ||
| 125 | + addr pk, unsafeAddr sig[0], sig.len.uint) == 1 | ||
| 126 | + | ||
| 102 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, | 127 | proc dpopProof*(k: P256Key, rng: var HmacDrbgContext, htm, htu: string, |
| 103 | nonce = "", accessToken = "", issuer = ""): string = | 128 | nonce = "", accessToken = "", issuer = ""): string = |
| 104 | ## A DPoP proof JWT (RFC 9449). | 129 | ## A DPoP proof JWT (RFC 9449). |
added
spike/nim/atproto/sqlite.nim +165 -0 | new file mode 100644 | ||
| @@ -0,0 +1,165 @@ | ||
| 1 | +## A small SQLite wrapper, with sqlite-vec compiled in. | |
| 2 | +## | |
| 3 | +## Deliberately thin: enough to prepare, bind and step, not an ORM. The | |
| 4 | +## sqlite-vec extension is linked rather than loaded at runtime -- see | |
| 5 | +## nim.cfg, where the SQLITE_CORE define lives, and note that getting that | |
| 6 | +## wrong segfaults rather than erroring. | |
| 7 | + | |
| 8 | +{.compile: "../vendor/sqlite-vec.c".} | |
| 9 | + | |
| 10 | +import std/options | |
| 11 | + | |
| 12 | +type | |
| 13 | + SqliteError* = object of CatchableError | |
| 14 | + | |
| 15 | + Db* = object | |
| 16 | + handle*: pointer | |
| 17 | + | |
| 18 | + Stmt* = object | |
| 19 | + handle*: pointer | |
| 20 | + db*: pointer | |
| 21 | + | |
| 22 | +const | |
| 23 | + SQLITE_OK* = 0 | |
| 24 | + SQLITE_ROW* = 100 | |
| 25 | + SQLITE_DONE* = 101 | |
| 26 | + | |
| 27 | +{.push importc, cdecl.} | |
| 28 | +proc sqlite3_open(filename: cstring, db: var pointer): cint | |
| 29 | +proc sqlite3_close(db: pointer): cint | |
| 30 | +proc sqlite3_errmsg(db: pointer): cstring | |
| 31 | +proc sqlite3_exec(db: pointer, sql: cstring, cb, arg: pointer, | |
| 32 | + errmsg: ptr cstring): cint | |
| 33 | +proc sqlite3_prepare_v2(db: pointer, sql: cstring, nByte: cint, | |
| 34 | + stmt: var pointer, tail: ptr cstring): cint | |
| 35 | +proc sqlite3_step(s: pointer): cint | |
| 36 | +proc sqlite3_finalize(s: pointer): cint | |
| 37 | +proc sqlite3_reset(s: pointer): cint | |
| 38 | +proc sqlite3_column_text(s: pointer, col: cint): cstring | |
| 39 | +proc sqlite3_column_int64(s: pointer, col: cint): int64 | |
| 40 | +proc sqlite3_column_double(s: pointer, col: cint): cdouble | |
| 41 | +proc sqlite3_column_count(s: pointer): cint | |
| 42 | +proc sqlite3_bind_text(s: pointer, col: cint, data: cstring, n: cint, | |
| 43 | + destructor: pointer): cint | |
| 44 | +proc sqlite3_bind_int64(s: pointer, col: cint, v: int64): cint | |
| 45 | +proc sqlite3_bind_blob(s: pointer, col: cint, data: pointer, n: cint, | |
| 46 | + destructor: pointer): cint | |
| 47 | +proc sqlite3_libversion(): cstring | |
| 48 | +{.pop.} | |
| 49 | + | |
| 50 | +proc sqlite3_vec_init(db: pointer, errMsg: ptr cstring, | |
| 51 | + api: pointer): cint {.importc, cdecl.} | |
| 52 | + | |
| 53 | +const SQLITE_TRANSIENT = cast[pointer](-1) | |
| 54 | + | |
| 55 | +proc libVersion*(): string = $sqlite3_libversion() | |
| 56 | + | |
| 57 | +proc check(db: pointer, rc: cint, what: string) = | |
| 58 | + if rc notin [SQLITE_OK.cint, SQLITE_ROW.cint, SQLITE_DONE.cint]: | |
| 59 | + raise newException(SqliteError, what & ": " & $sqlite3_errmsg(db)) | |
| 60 | + | |
| 61 | +proc open*(path: string, withVec = true): Db = | |
| 62 | + ## Open (or create) a database. | |
| 63 | + ## | |
| 64 | + ## WAL is set here rather than left to callers: it is what makes a reader | |
| 65 | + ## and the writer coexist, and a store that forgets it fails only under | |
| 66 | + ## concurrency, which is the worst time to find out. | |
| 67 | + var handle: pointer | |
| 68 | + if sqlite3_open(path.cstring, handle) != SQLITE_OK: | |
| 69 | + let msg = $sqlite3_errmsg(handle) | |
| 70 | + discard sqlite3_close(handle) | |
| 71 | + raise newException(SqliteError, "opening " & path & ": " & msg) | |
| 72 | + result = Db(handle: handle) | |
| 73 | + | |
| 74 | + if withVec: | |
| 75 | + let rc = sqlite3_vec_init(handle, nil, nil) | |
| 76 | + if rc != SQLITE_OK: | |
| 77 | + raise newException(SqliteError, "registering sqlite-vec failed") | |
| 78 | + | |
| 79 | + discard sqlite3_exec(handle, "PRAGMA journal_mode=WAL", nil, nil, nil) | |
| 80 | + discard sqlite3_exec(handle, "PRAGMA synchronous=NORMAL", nil, nil, nil) | |
| 81 | + discard sqlite3_exec(handle, "PRAGMA foreign_keys=ON", nil, nil, nil) | |
| 82 | + discard sqlite3_exec(handle, "PRAGMA busy_timeout=5000", nil, nil, nil) | |
| 83 | + | |
| 84 | +proc close*(db: var Db) = | |
| 85 | + if db.handle != nil: | |
| 86 | + discard sqlite3_close(db.handle) | |
| 87 | + db.handle = nil | |
| 88 | + | |
| 89 | +proc exec*(db: Db, sql: string) = | |
| 90 | + var err: cstring | |
| 91 | + let rc = sqlite3_exec(db.handle, sql.cstring, nil, nil, addr err) | |
| 92 | + if rc != SQLITE_OK: | |
| 93 | + raise newException(SqliteError, "exec failed: " & $sqlite3_errmsg(db.handle)) | |
| 94 | + | |
| 95 | +type Param* = object | |
| 96 | + case isNull*: bool | |
| 97 | + of true: discard | |
| 98 | + of false: | |
| 99 | + case kind*: range[0 .. 2] | |
| 100 | + of 0: s*: string | |
| 101 | + of 1: i*: int64 | |
| 102 | + of 2: b*: seq[byte] | |
| 103 | + | |
| 104 | +proc p*(s: string): Param = Param(isNull: false, kind: 0, s: s) | |
| 105 | +proc p*(i: int64): Param = Param(isNull: false, kind: 1, i: i) | |
| 106 | +proc p*(b: seq[byte]): Param = Param(isNull: false, kind: 2, b: b) | |
| 107 | + | |
| 108 | +proc prepare(db: Db, sql: string, params: openArray[Param]): pointer = | |
| 109 | + var stmt: pointer | |
| 110 | + check(db.handle, sqlite3_prepare_v2(db.handle, sql.cstring, -1, stmt, nil), | |
| 111 | + "prepare") | |
| 112 | + for i, prm in params: | |
| 113 | + let col = (i + 1).cint | |
| 114 | + let rc = | |
| 115 | + if prm.isNull: SQLITE_OK.cint | |
| 116 | + else: | |
| 117 | + case prm.kind | |
| 118 | + of 0: sqlite3_bind_text(stmt, col, prm.s.cstring, prm.s.len.cint, | |
| 119 | + SQLITE_TRANSIENT) | |
| 120 | + of 1: sqlite3_bind_int64(stmt, col, prm.i) | |
| 121 | + of 2: | |
| 122 | + if prm.b.len == 0: | |
| 123 | + sqlite3_bind_blob(stmt, col, nil, 0, SQLITE_TRANSIENT) | |
| 124 | + else: | |
| 125 | + sqlite3_bind_blob(stmt, col, prm.b[0].unsafeAddr, prm.b.len.cint, | |
| 126 | + SQLITE_TRANSIENT) | |
| 127 | + if rc != SQLITE_OK: | |
| 128 | + discard sqlite3_finalize(stmt) | |
| 129 | + raise newException(SqliteError, "binding parameter " & $col) | |
| 130 | + stmt | |
| 131 | + | |
| 132 | +proc run*(db: Db, sql: string, params: varargs[Param]) = | |
| 133 | + ## Execute a statement that returns no rows. | |
| 134 | + let stmt = prepare(db, sql, params) | |
| 135 | + defer: discard sqlite3_finalize(stmt) | |
| 136 | + check(db.handle, sqlite3_step(stmt), "step") | |
| 137 | + | |
| 138 | +proc queryText*(db: Db, sql: string, params: varargs[Param]): Option[string] = | |
| 139 | + ## First column of the first row, as text. | |
| 140 | + let stmt = prepare(db, sql, params) | |
| 141 | + defer: discard sqlite3_finalize(stmt) | |
| 142 | + if sqlite3_step(stmt) != SQLITE_ROW: | |
| 143 | + return none(string) | |
| 144 | + let raw = sqlite3_column_text(stmt, 0) | |
| 145 | + if raw == nil: none(string) else: some($raw) | |
| 146 | + | |
| 147 | +proc queryInt*(db: Db, sql: string, params: varargs[Param]): Option[int64] = | |
| 148 | + let stmt = prepare(db, sql, params) | |
| 149 | + defer: discard sqlite3_finalize(stmt) | |
| 150 | + if sqlite3_step(stmt) != SQLITE_ROW: | |
| 151 | + return none(int64) | |
| 152 | + some(sqlite3_column_int64(stmt, 0)) | |
| 153 | + | |
| 154 | +iterator rows*(db: Db, sql: string, params: varargs[Param]): seq[string] = | |
| 155 | + ## Every row, all columns as text. Fine for the small result sets a session | |
| 156 | + ## store deals in; not for scanning a table of articles. | |
| 157 | + let stmt = prepare(db, sql, @params) | |
| 158 | + defer: discard sqlite3_finalize(stmt) | |
| 159 | + let n = sqlite3_column_count(stmt) | |
| 160 | + while sqlite3_step(stmt) == SQLITE_ROW: | |
| 161 | + var row = newSeq[string](n) | |
| 162 | + for i in 0 ..< n: | |
| 163 | + let raw = sqlite3_column_text(stmt, i.cint) | |
| 164 | + row[i] = if raw == nil: "" else: $raw | |
| 165 | + yield row | |
| new file mode 100644 | |||
| @@ -0,0 +1,165 @@ | |||
| 1 | +## A small SQLite wrapper, with sqlite-vec compiled in. | ||
| 2 | +## | ||
| 3 | +## Deliberately thin: enough to prepare, bind and step, not an ORM. The | ||
| 4 | +## sqlite-vec extension is linked rather than loaded at runtime -- see | ||
| 5 | +## nim.cfg, where the SQLITE_CORE define lives, and note that getting that | ||
| 6 | +## wrong segfaults rather than erroring. | ||
| 7 | + | ||
| 8 | +{.compile: "../vendor/sqlite-vec.c".} | ||
| 9 | + | ||
| 10 | +import std/options | ||
| 11 | + | ||
| 12 | +type | ||
| 13 | + SqliteError* = object of CatchableError | ||
| 14 | + | ||
| 15 | + Db* = object | ||
| 16 | + handle*: pointer | ||
| 17 | + | ||
| 18 | + Stmt* = object | ||
| 19 | + handle*: pointer | ||
| 20 | + db*: pointer | ||
| 21 | + | ||
| 22 | +const | ||
| 23 | + SQLITE_OK* = 0 | ||
| 24 | + SQLITE_ROW* = 100 | ||
| 25 | + SQLITE_DONE* = 101 | ||
| 26 | + | ||
| 27 | +{.push importc, cdecl.} | ||
| 28 | +proc sqlite3_open(filename: cstring, db: var pointer): cint | ||
| 29 | +proc sqlite3_close(db: pointer): cint | ||
| 30 | +proc sqlite3_errmsg(db: pointer): cstring | ||
| 31 | +proc sqlite3_exec(db: pointer, sql: cstring, cb, arg: pointer, | ||
| 32 | + errmsg: ptr cstring): cint | ||
| 33 | +proc sqlite3_prepare_v2(db: pointer, sql: cstring, nByte: cint, | ||
| 34 | + stmt: var pointer, tail: ptr cstring): cint | ||
| 35 | +proc sqlite3_step(s: pointer): cint | ||
| 36 | +proc sqlite3_finalize(s: pointer): cint | ||
| 37 | +proc sqlite3_reset(s: pointer): cint | ||
| 38 | +proc sqlite3_column_text(s: pointer, col: cint): cstring | ||
| 39 | +proc sqlite3_column_int64(s: pointer, col: cint): int64 | ||
| 40 | +proc sqlite3_column_double(s: pointer, col: cint): cdouble | ||
| 41 | +proc sqlite3_column_count(s: pointer): cint | ||
| 42 | +proc sqlite3_bind_text(s: pointer, col: cint, data: cstring, n: cint, | ||
| 43 | + destructor: pointer): cint | ||
| 44 | +proc sqlite3_bind_int64(s: pointer, col: cint, v: int64): cint | ||
| 45 | +proc sqlite3_bind_blob(s: pointer, col: cint, data: pointer, n: cint, | ||
| 46 | + destructor: pointer): cint | ||
| 47 | +proc sqlite3_libversion(): cstring | ||
| 48 | +{.pop.} | ||
| 49 | + | ||
| 50 | +proc sqlite3_vec_init(db: pointer, errMsg: ptr cstring, | ||
| 51 | + api: pointer): cint {.importc, cdecl.} | ||
| 52 | + | ||
| 53 | +const SQLITE_TRANSIENT = cast[pointer](-1) | ||
| 54 | + | ||
| 55 | +proc libVersion*(): string = $sqlite3_libversion() | ||
| 56 | + | ||
| 57 | +proc check(db: pointer, rc: cint, what: string) = | ||
| 58 | + if rc notin [SQLITE_OK.cint, SQLITE_ROW.cint, SQLITE_DONE.cint]: | ||
| 59 | + raise newException(SqliteError, what & ": " & $sqlite3_errmsg(db)) | ||
| 60 | + | ||
| 61 | +proc open*(path: string, withVec = true): Db = | ||
| 62 | + ## Open (or create) a database. | ||
| 63 | + ## | ||
| 64 | + ## WAL is set here rather than left to callers: it is what makes a reader | ||
| 65 | + ## and the writer coexist, and a store that forgets it fails only under | ||
| 66 | + ## concurrency, which is the worst time to find out. | ||
| 67 | + var handle: pointer | ||
| 68 | + if sqlite3_open(path.cstring, handle) != SQLITE_OK: | ||
| 69 | + let msg = $sqlite3_errmsg(handle) | ||
| 70 | + discard sqlite3_close(handle) | ||
| 71 | + raise newException(SqliteError, "opening " & path & ": " & msg) | ||
| 72 | + result = Db(handle: handle) | ||
| 73 | + | ||
| 74 | + if withVec: | ||
| 75 | + let rc = sqlite3_vec_init(handle, nil, nil) | ||
| 76 | + if rc != SQLITE_OK: | ||
| 77 | + raise newException(SqliteError, "registering sqlite-vec failed") | ||
| 78 | + | ||
| 79 | + discard sqlite3_exec(handle, "PRAGMA journal_mode=WAL", nil, nil, nil) | ||
| 80 | + discard sqlite3_exec(handle, "PRAGMA synchronous=NORMAL", nil, nil, nil) | ||
| 81 | + discard sqlite3_exec(handle, "PRAGMA foreign_keys=ON", nil, nil, nil) | ||
| 82 | + discard sqlite3_exec(handle, "PRAGMA busy_timeout=5000", nil, nil, nil) | ||
| 83 | + | ||
| 84 | +proc close*(db: var Db) = | ||
| 85 | + if db.handle != nil: | ||
| 86 | + discard sqlite3_close(db.handle) | ||
| 87 | + db.handle = nil | ||
| 88 | + | ||
| 89 | +proc exec*(db: Db, sql: string) = | ||
| 90 | + var err: cstring | ||
| 91 | + let rc = sqlite3_exec(db.handle, sql.cstring, nil, nil, addr err) | ||
| 92 | + if rc != SQLITE_OK: | ||
| 93 | + raise newException(SqliteError, "exec failed: " & $sqlite3_errmsg(db.handle)) | ||
| 94 | + | ||
| 95 | +type Param* = object | ||
| 96 | + case isNull*: bool | ||
| 97 | + of true: discard | ||
| 98 | + of false: | ||
| 99 | + case kind*: range[0 .. 2] | ||
| 100 | + of 0: s*: string | ||
| 101 | + of 1: i*: int64 | ||
| 102 | + of 2: b*: seq[byte] | ||
| 103 | + | ||
| 104 | +proc p*(s: string): Param = Param(isNull: false, kind: 0, s: s) | ||
| 105 | +proc p*(i: int64): Param = Param(isNull: false, kind: 1, i: i) | ||
| 106 | +proc p*(b: seq[byte]): Param = Param(isNull: false, kind: 2, b: b) | ||
| 107 | + | ||
| 108 | +proc prepare(db: Db, sql: string, params: openArray[Param]): pointer = | ||
| 109 | + var stmt: pointer | ||
| 110 | + check(db.handle, sqlite3_prepare_v2(db.handle, sql.cstring, -1, stmt, nil), | ||
| 111 | + "prepare") | ||
| 112 | + for i, prm in params: | ||
| 113 | + let col = (i + 1).cint | ||
| 114 | + let rc = | ||
| 115 | + if prm.isNull: SQLITE_OK.cint | ||
| 116 | + else: | ||
| 117 | + case prm.kind | ||
| 118 | + of 0: sqlite3_bind_text(stmt, col, prm.s.cstring, prm.s.len.cint, | ||
| 119 | + SQLITE_TRANSIENT) | ||
| 120 | + of 1: sqlite3_bind_int64(stmt, col, prm.i) | ||
| 121 | + of 2: | ||
| 122 | + if prm.b.len == 0: | ||
| 123 | + sqlite3_bind_blob(stmt, col, nil, 0, SQLITE_TRANSIENT) | ||
| 124 | + else: | ||
| 125 | + sqlite3_bind_blob(stmt, col, prm.b[0].unsafeAddr, prm.b.len.cint, | ||
| 126 | + SQLITE_TRANSIENT) | ||
| 127 | + if rc != SQLITE_OK: | ||
| 128 | + discard sqlite3_finalize(stmt) | ||
| 129 | + raise newException(SqliteError, "binding parameter " & $col) | ||
| 130 | + stmt | ||
| 131 | + | ||
| 132 | +proc run*(db: Db, sql: string, params: varargs[Param]) = | ||
| 133 | + ## Execute a statement that returns no rows. | ||
| 134 | + let stmt = prepare(db, sql, params) | ||
| 135 | + defer: discard sqlite3_finalize(stmt) | ||
| 136 | + check(db.handle, sqlite3_step(stmt), "step") | ||
| 137 | + | ||
| 138 | +proc queryText*(db: Db, sql: string, params: varargs[Param]): Option[string] = | ||
| 139 | + ## First column of the first row, as text. | ||
| 140 | + let stmt = prepare(db, sql, params) | ||
| 141 | + defer: discard sqlite3_finalize(stmt) | ||
| 142 | + if sqlite3_step(stmt) != SQLITE_ROW: | ||
| 143 | + return none(string) | ||
| 144 | + let raw = sqlite3_column_text(stmt, 0) | ||
| 145 | + if raw == nil: none(string) else: some($raw) | ||
| 146 | + | ||
| 147 | +proc queryInt*(db: Db, sql: string, params: varargs[Param]): Option[int64] = | ||
| 148 | + let stmt = prepare(db, sql, params) | ||
| 149 | + defer: discard sqlite3_finalize(stmt) | ||
| 150 | + if sqlite3_step(stmt) != SQLITE_ROW: | ||
| 151 | + return none(int64) | ||
| 152 | + some(sqlite3_column_int64(stmt, 0)) | ||
| 153 | + | ||
| 154 | +iterator rows*(db: Db, sql: string, params: varargs[Param]): seq[string] = | ||
| 155 | + ## Every row, all columns as text. Fine for the small result sets a session | ||
| 156 | + ## store deals in; not for scanning a table of articles. | ||
| 157 | + let stmt = prepare(db, sql, @params) | ||
| 158 | + defer: discard sqlite3_finalize(stmt) | ||
| 159 | + let n = sqlite3_column_count(stmt) | ||
| 160 | + while sqlite3_step(stmt) == SQLITE_ROW: | ||
| 161 | + var row = newSeq[string](n) | ||
| 162 | + for i in 0 ..< n: | ||
| 163 | + let raw = sqlite3_column_text(stmt, i.cint) | ||
| 164 | + row[i] = if raw == nil: "" else: $raw | ||
| 165 | + yield row | ||
added
spike/nim/atproto/store.nim +217 -0 | new file mode 100644 | ||
| @@ -0,0 +1,217 @@ | ||
| 1 | +## Persisting what an OAuth session and a firehose subscription need to | |
| 2 | +## survive a restart. | |
| 3 | +## | |
| 4 | +## Three things are stored, matching the tables Glean's Go backend uses: | |
| 5 | +## | |
| 6 | +## oauth_auth_requests a flow in progress, keyed by `state` | |
| 7 | +## oauth_sessions a live session, keyed by (account_did, session_id) | |
| 8 | +## jetstream_cursor a single row: where the firehose got to | |
| 9 | +## | |
| 10 | +## The rows hold JSON, as the Go side does, so the schema does not have to | |
| 11 | +## change every time a field is added to a session. | |
| 12 | +## | |
| 13 | +## **This stores key material.** A session row contains the DPoP private key | |
| 14 | +## and the refresh token; together they are the account. The file wants the | |
| 15 | +## same protection as a password store: not world-readable, and not in a | |
| 16 | +## backup that travels somewhere less careful. | |
| 17 | + | |
| 18 | +import std/[json, options, sequtils, times] | |
| 19 | +import ./dpop | |
| 20 | +import ./oauth | |
| 21 | +import ./sqlite | |
| 22 | + | |
| 23 | +type | |
| 24 | + StoreError* = object of CatchableError | |
| 25 | + | |
| 26 | + SessionRecord* = object ## A usable OAuth session. | |
| 27 | + accountDid*: string | |
| 28 | + sessionId*: string | |
| 29 | + pdsUrl*: string | |
| 30 | + authServerUrl*: string | |
| 31 | + tokenEndpoint*: string | |
| 32 | + accessToken*: string | |
| 33 | + refreshToken*: string | |
| 34 | + dpopKey*: P256Key | |
| 35 | + hostNonce*: string | |
| 36 | + authNonce*: string | |
| 37 | + scopes*: seq[string] | |
| 38 | + createdAt*: int64 | |
| 39 | + | |
| 40 | +const Schema = [ | |
| 41 | + """CREATE TABLE IF NOT EXISTS oauth_auth_requests ( | |
| 42 | + state TEXT PRIMARY KEY, | |
| 43 | + data TEXT NOT NULL, | |
| 44 | + created_at INTEGER NOT NULL | |
| 45 | + )""", | |
| 46 | + """CREATE TABLE IF NOT EXISTS oauth_sessions ( | |
| 47 | + account_did TEXT NOT NULL, | |
| 48 | + session_id TEXT NOT NULL, | |
| 49 | + data TEXT NOT NULL, | |
| 50 | + PRIMARY KEY (account_did, session_id) | |
| 51 | + )""", | |
| 52 | + """CREATE TABLE IF NOT EXISTS jetstream_cursor ( | |
| 53 | + id INTEGER PRIMARY KEY CHECK(id = 1), | |
| 54 | + cursor_us INTEGER NOT NULL | |
| 55 | + )""", | |
| 56 | +] | |
| 57 | + | |
| 58 | +proc migrate*(db: Db) = | |
| 59 | + for stmt in Schema: | |
| 60 | + db.exec stmt | |
| 61 | + | |
| 62 | +# --- key serialisation ----------------------------------------------------- | |
| 63 | + | |
| 64 | +proc toJson*(k: P256Key): JsonNode = | |
| 65 | + ## The private scalar plus the public point. | |
| 66 | + ## | |
| 67 | + ## The public half is stored rather than re-derived on load: deriving it | |
| 68 | + ## needs a curve operation, and a stored key that cannot be read back | |
| 69 | + ## without one is a restart that silently drops every session. | |
| 70 | + %*{"d": b64u(k.priv), "x": b64u(k.pubX), "y": b64u(k.pubY)} | |
| 71 | + | |
| 72 | +proc keyFromJson*(j: JsonNode): P256Key = | |
| 73 | + proc part(name: string): seq[byte] = | |
| 74 | + let raw = b64uDecode(j{name}.getStr) | |
| 75 | + if raw.len != 32: | |
| 76 | + raise newException(StoreError, "stored key field " & name & " is not 32 bytes") | |
| 77 | + result = newSeq[byte](32) | |
| 78 | + copyMem(result[0].addr, raw[0].unsafeAddr, 32) | |
| 79 | + | |
| 80 | + let d = part("d") | |
| 81 | + let x = part("x") | |
| 82 | + let y = part("y") | |
| 83 | + for i in 0 ..< 32: | |
| 84 | + result.priv[i] = d[i] | |
| 85 | + result.pubX[i] = x[i] | |
| 86 | + result.pubY[i] = y[i] | |
| 87 | + | |
| 88 | +# --- auth requests --------------------------------------------------------- | |
| 89 | + | |
| 90 | +proc toJson(info: AuthRequestData): JsonNode = | |
| 91 | + %*{ | |
| 92 | + "state": info.state, | |
| 93 | + "auth_server_url": info.authServerUrl, | |
| 94 | + "scopes": info.scopes, | |
| 95 | + "pkce_verifier": info.pkceVerifier, | |
| 96 | + "request_uri": info.requestUri, | |
| 97 | + "token_endpoint": info.tokenEndpoint, | |
| 98 | + "revocation_endpoint": info.revocationEndpoint, | |
| 99 | + "dpop_nonce": info.dpopNonce, | |
| 100 | + "dpop_key": info.dpopKey.toJson, | |
| 101 | + "account_did": info.accountDid, | |
| 102 | + } | |
| 103 | + | |
| 104 | +proc authRequestFromJson(j: JsonNode): AuthRequestData = | |
| 105 | + AuthRequestData( | |
| 106 | + state: j{"state"}.getStr, | |
| 107 | + authServerUrl: j{"auth_server_url"}.getStr, | |
| 108 | + scopes: j{"scopes"}.getElems.mapIt(it.getStr), | |
| 109 | + pkceVerifier: j{"pkce_verifier"}.getStr, | |
| 110 | + requestUri: j{"request_uri"}.getStr, | |
| 111 | + tokenEndpoint: j{"token_endpoint"}.getStr, | |
| 112 | + revocationEndpoint: j{"revocation_endpoint"}.getStr, | |
| 113 | + dpopNonce: j{"dpop_nonce"}.getStr, | |
| 114 | + dpopKey: keyFromJson(j{"dpop_key"}), | |
| 115 | + accountDid: j{"account_did"}.getStr, | |
| 116 | + ) | |
| 117 | + | |
| 118 | +proc saveAuthRequest*(db: Db, info: AuthRequestData) = | |
| 119 | + db.run("""INSERT INTO oauth_auth_requests (state, data, created_at) | |
| 120 | + VALUES (?, ?, ?) | |
| 121 | + ON CONFLICT(state) DO UPDATE SET data = excluded.data""", | |
| 122 | + p(info.state), p($info.toJson), p(getTime().toUnix)) | |
| 123 | + | |
| 124 | +proc takeAuthRequest*(db: Db, state: string): Option[AuthRequestData] = | |
| 125 | + ## Read **and delete**: an authorization code is single-use, so leaving the | |
| 126 | + ## request behind invites a replay of the callback. | |
| 127 | + let row = db.queryText( | |
| 128 | + "SELECT data FROM oauth_auth_requests WHERE state = ?", p(state)) | |
| 129 | + if row.isNone: | |
| 130 | + return none(AuthRequestData) | |
| 131 | + db.run("DELETE FROM oauth_auth_requests WHERE state = ?", p(state)) | |
| 132 | + try: | |
| 133 | + some(authRequestFromJson(parseJson(row.get))) | |
| 134 | + except JsonParsingError: | |
| 135 | + raise newException(StoreError, "stored auth request is not valid JSON") | |
| 136 | + | |
| 137 | +proc purgeAuthRequests*(db: Db, olderThan: int64) = | |
| 138 | + ## Flows that were started and never finished. Without this the table grows | |
| 139 | + ## forever, one row per abandoned sign-in. | |
| 140 | + db.run("DELETE FROM oauth_auth_requests WHERE created_at < ?", | |
| 141 | + p(getTime().toUnix - olderThan)) | |
| 142 | + | |
| 143 | +# --- sessions -------------------------------------------------------------- | |
| 144 | + | |
| 145 | +proc toJson(s: SessionRecord): JsonNode = | |
| 146 | + %*{ | |
| 147 | + "account_did": s.accountDid, | |
| 148 | + "session_id": s.sessionId, | |
| 149 | + "pds_url": s.pdsUrl, | |
| 150 | + "auth_server_url": s.authServerUrl, | |
| 151 | + "token_endpoint": s.tokenEndpoint, | |
| 152 | + "access_token": s.accessToken, | |
| 153 | + "refresh_token": s.refreshToken, | |
| 154 | + "dpop_key": s.dpopKey.toJson, | |
| 155 | + "host_nonce": s.hostNonce, | |
| 156 | + "auth_nonce": s.authNonce, | |
| 157 | + "scopes": s.scopes, | |
| 158 | + "created_at": s.createdAt, | |
| 159 | + } | |
| 160 | + | |
| 161 | +proc sessionFromJson(j: JsonNode): SessionRecord = | |
| 162 | + SessionRecord( | |
| 163 | + accountDid: j{"account_did"}.getStr, | |
| 164 | + sessionId: j{"session_id"}.getStr, | |
| 165 | + pdsUrl: j{"pds_url"}.getStr, | |
| 166 | + authServerUrl: j{"auth_server_url"}.getStr, | |
| 167 | + tokenEndpoint: j{"token_endpoint"}.getStr, | |
| 168 | + accessToken: j{"access_token"}.getStr, | |
| 169 | + refreshToken: j{"refresh_token"}.getStr, | |
| 170 | + dpopKey: keyFromJson(j{"dpop_key"}), | |
| 171 | + hostNonce: j{"host_nonce"}.getStr, | |
| 172 | + authNonce: j{"auth_nonce"}.getStr, | |
| 173 | + scopes: j{"scopes"}.getElems.mapIt(it.getStr), | |
| 174 | + createdAt: j{"created_at"}.getBiggestInt.int64, | |
| 175 | + ) | |
| 176 | + | |
| 177 | +proc saveSession*(db: Db, s: SessionRecord) = | |
| 178 | + if s.accountDid.len == 0 or s.sessionId.len == 0: | |
| 179 | + raise newException(StoreError, "a session needs both a DID and a session id") | |
| 180 | + db.run("""INSERT INTO oauth_sessions (account_did, session_id, data) | |
| 181 | + VALUES (?, ?, ?) | |
| 182 | + ON CONFLICT(account_did, session_id) DO UPDATE SET data = excluded.data""", | |
| 183 | + p(s.accountDid), p(s.sessionId), p($s.toJson)) | |
| 184 | + | |
| 185 | +proc getSession*(db: Db, did, sessionId: string): Option[SessionRecord] = | |
| 186 | + let row = db.queryText( | |
| 187 | + "SELECT data FROM oauth_sessions WHERE account_did = ? AND session_id = ?", | |
| 188 | + p(did), p(sessionId)) | |
| 189 | + if row.isNone: | |
| 190 | + return none(SessionRecord) | |
| 191 | + try: | |
| 192 | + some(sessionFromJson(parseJson(row.get))) | |
| 193 | + except JsonParsingError: | |
| 194 | + raise newException(StoreError, "stored session is not valid JSON") | |
| 195 | + | |
| 196 | +proc deleteSession*(db: Db, did, sessionId: string) = | |
| 197 | + db.run("DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", | |
| 198 | + p(did), p(sessionId)) | |
| 199 | + | |
| 200 | +proc listSessions*(db: Db, did: string): seq[string] = | |
| 201 | + for row in db.rows( | |
| 202 | + "SELECT session_id FROM oauth_sessions WHERE account_did = ?", p(did)): | |
| 203 | + result.add row[0] | |
| 204 | + | |
| 205 | +proc listAccounts*(db: Db): seq[string] = | |
| 206 | + for row in db.rows("SELECT DISTINCT account_did FROM oauth_sessions"): | |
| 207 | + result.add row[0] | |
| 208 | + | |
| 209 | +# --- jetstream cursor ------------------------------------------------------ | |
| 210 | + | |
| 211 | +proc saveCursor*(db: Db, cursorUs: int64) = | |
| 212 | + db.run("""INSERT INTO jetstream_cursor (id, cursor_us) VALUES (1, ?) | |
| 213 | + ON CONFLICT(id) DO UPDATE SET cursor_us = excluded.cursor_us""", | |
| 214 | + p(cursorUs)) | |
| 215 | + | |
| 216 | +proc loadCursor*(db: Db): int64 = | |
| 217 | + db.queryInt("SELECT cursor_us FROM jetstream_cursor WHERE id = 1").get(0'i64) | |
| new file mode 100644 | |||
| @@ -0,0 +1,217 @@ | |||
| 1 | +## Persisting what an OAuth session and a firehose subscription need to | ||
| 2 | +## survive a restart. | ||
| 3 | +## | ||
| 4 | +## Three things are stored, matching the tables Glean's Go backend uses: | ||
| 5 | +## | ||
| 6 | +## oauth_auth_requests a flow in progress, keyed by `state` | ||
| 7 | +## oauth_sessions a live session, keyed by (account_did, session_id) | ||
| 8 | +## jetstream_cursor a single row: where the firehose got to | ||
| 9 | +## | ||
| 10 | +## The rows hold JSON, as the Go side does, so the schema does not have to | ||
| 11 | +## change every time a field is added to a session. | ||
| 12 | +## | ||
| 13 | +## **This stores key material.** A session row contains the DPoP private key | ||
| 14 | +## and the refresh token; together they are the account. The file wants the | ||
| 15 | +## same protection as a password store: not world-readable, and not in a | ||
| 16 | +## backup that travels somewhere less careful. | ||
| 17 | + | ||
| 18 | +import std/[json, options, sequtils, times] | ||
| 19 | +import ./dpop | ||
| 20 | +import ./oauth | ||
| 21 | +import ./sqlite | ||
| 22 | + | ||
| 23 | +type | ||
| 24 | + StoreError* = object of CatchableError | ||
| 25 | + | ||
| 26 | + SessionRecord* = object ## A usable OAuth session. | ||
| 27 | + accountDid*: string | ||
| 28 | + sessionId*: string | ||
| 29 | + pdsUrl*: string | ||
| 30 | + authServerUrl*: string | ||
| 31 | + tokenEndpoint*: string | ||
| 32 | + accessToken*: string | ||
| 33 | + refreshToken*: string | ||
| 34 | + dpopKey*: P256Key | ||
| 35 | + hostNonce*: string | ||
| 36 | + authNonce*: string | ||
| 37 | + scopes*: seq[string] | ||
| 38 | + createdAt*: int64 | ||
| 39 | + | ||
| 40 | +const Schema = [ | ||
| 41 | + """CREATE TABLE IF NOT EXISTS oauth_auth_requests ( | ||
| 42 | + state TEXT PRIMARY KEY, | ||
| 43 | + data TEXT NOT NULL, | ||
| 44 | + created_at INTEGER NOT NULL | ||
| 45 | + )""", | ||
| 46 | + """CREATE TABLE IF NOT EXISTS oauth_sessions ( | ||
| 47 | + account_did TEXT NOT NULL, | ||
| 48 | + session_id TEXT NOT NULL, | ||
| 49 | + data TEXT NOT NULL, | ||
| 50 | + PRIMARY KEY (account_did, session_id) | ||
| 51 | + )""", | ||
| 52 | + """CREATE TABLE IF NOT EXISTS jetstream_cursor ( | ||
| 53 | + id INTEGER PRIMARY KEY CHECK(id = 1), | ||
| 54 | + cursor_us INTEGER NOT NULL | ||
| 55 | + )""", | ||
| 56 | +] | ||
| 57 | + | ||
| 58 | +proc migrate*(db: Db) = | ||
| 59 | + for stmt in Schema: | ||
| 60 | + db.exec stmt | ||
| 61 | + | ||
| 62 | +# --- key serialisation ----------------------------------------------------- | ||
| 63 | + | ||
| 64 | +proc toJson*(k: P256Key): JsonNode = | ||
| 65 | + ## The private scalar plus the public point. | ||
| 66 | + ## | ||
| 67 | + ## The public half is stored rather than re-derived on load: deriving it | ||
| 68 | + ## needs a curve operation, and a stored key that cannot be read back | ||
| 69 | + ## without one is a restart that silently drops every session. | ||
| 70 | + %*{"d": b64u(k.priv), "x": b64u(k.pubX), "y": b64u(k.pubY)} | ||
| 71 | + | ||
| 72 | +proc keyFromJson*(j: JsonNode): P256Key = | ||
| 73 | + proc part(name: string): seq[byte] = | ||
| 74 | + let raw = b64uDecode(j{name}.getStr) | ||
| 75 | + if raw.len != 32: | ||
| 76 | + raise newException(StoreError, "stored key field " & name & " is not 32 bytes") | ||
| 77 | + result = newSeq[byte](32) | ||
| 78 | + copyMem(result[0].addr, raw[0].unsafeAddr, 32) | ||
| 79 | + | ||
| 80 | + let d = part("d") | ||
| 81 | + let x = part("x") | ||
| 82 | + let y = part("y") | ||
| 83 | + for i in 0 ..< 32: | ||
| 84 | + result.priv[i] = d[i] | ||
| 85 | + result.pubX[i] = x[i] | ||
| 86 | + result.pubY[i] = y[i] | ||
| 87 | + | ||
| 88 | +# --- auth requests --------------------------------------------------------- | ||
| 89 | + | ||
| 90 | +proc toJson(info: AuthRequestData): JsonNode = | ||
| 91 | + %*{ | ||
| 92 | + "state": info.state, | ||
| 93 | + "auth_server_url": info.authServerUrl, | ||
| 94 | + "scopes": info.scopes, | ||
| 95 | + "pkce_verifier": info.pkceVerifier, | ||
| 96 | + "request_uri": info.requestUri, | ||
| 97 | + "token_endpoint": info.tokenEndpoint, | ||
| 98 | + "revocation_endpoint": info.revocationEndpoint, | ||
| 99 | + "dpop_nonce": info.dpopNonce, | ||
| 100 | + "dpop_key": info.dpopKey.toJson, | ||
| 101 | + "account_did": info.accountDid, | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | +proc authRequestFromJson(j: JsonNode): AuthRequestData = | ||
| 105 | + AuthRequestData( | ||
| 106 | + state: j{"state"}.getStr, | ||
| 107 | + authServerUrl: j{"auth_server_url"}.getStr, | ||
| 108 | + scopes: j{"scopes"}.getElems.mapIt(it.getStr), | ||
| 109 | + pkceVerifier: j{"pkce_verifier"}.getStr, | ||
| 110 | + requestUri: j{"request_uri"}.getStr, | ||
| 111 | + tokenEndpoint: j{"token_endpoint"}.getStr, | ||
| 112 | + revocationEndpoint: j{"revocation_endpoint"}.getStr, | ||
| 113 | + dpopNonce: j{"dpop_nonce"}.getStr, | ||
| 114 | + dpopKey: keyFromJson(j{"dpop_key"}), | ||
| 115 | + accountDid: j{"account_did"}.getStr, | ||
| 116 | + ) | ||
| 117 | + | ||
| 118 | +proc saveAuthRequest*(db: Db, info: AuthRequestData) = | ||
| 119 | + db.run("""INSERT INTO oauth_auth_requests (state, data, created_at) | ||
| 120 | + VALUES (?, ?, ?) | ||
| 121 | + ON CONFLICT(state) DO UPDATE SET data = excluded.data""", | ||
| 122 | + p(info.state), p($info.toJson), p(getTime().toUnix)) | ||
| 123 | + | ||
| 124 | +proc takeAuthRequest*(db: Db, state: string): Option[AuthRequestData] = | ||
| 125 | + ## Read **and delete**: an authorization code is single-use, so leaving the | ||
| 126 | + ## request behind invites a replay of the callback. | ||
| 127 | + let row = db.queryText( | ||
| 128 | + "SELECT data FROM oauth_auth_requests WHERE state = ?", p(state)) | ||
| 129 | + if row.isNone: | ||
| 130 | + return none(AuthRequestData) | ||
| 131 | + db.run("DELETE FROM oauth_auth_requests WHERE state = ?", p(state)) | ||
| 132 | + try: | ||
| 133 | + some(authRequestFromJson(parseJson(row.get))) | ||
| 134 | + except JsonParsingError: | ||
| 135 | + raise newException(StoreError, "stored auth request is not valid JSON") | ||
| 136 | + | ||
| 137 | +proc purgeAuthRequests*(db: Db, olderThan: int64) = | ||
| 138 | + ## Flows that were started and never finished. Without this the table grows | ||
| 139 | + ## forever, one row per abandoned sign-in. | ||
| 140 | + db.run("DELETE FROM oauth_auth_requests WHERE created_at < ?", | ||
| 141 | + p(getTime().toUnix - olderThan)) | ||
| 142 | + | ||
| 143 | +# --- sessions -------------------------------------------------------------- | ||
| 144 | + | ||
| 145 | +proc toJson(s: SessionRecord): JsonNode = | ||
| 146 | + %*{ | ||
| 147 | + "account_did": s.accountDid, | ||
| 148 | + "session_id": s.sessionId, | ||
| 149 | + "pds_url": s.pdsUrl, | ||
| 150 | + "auth_server_url": s.authServerUrl, | ||
| 151 | + "token_endpoint": s.tokenEndpoint, | ||
| 152 | + "access_token": s.accessToken, | ||
| 153 | + "refresh_token": s.refreshToken, | ||
| 154 | + "dpop_key": s.dpopKey.toJson, | ||
| 155 | + "host_nonce": s.hostNonce, | ||
| 156 | + "auth_nonce": s.authNonce, | ||
| 157 | + "scopes": s.scopes, | ||
| 158 | + "created_at": s.createdAt, | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | +proc sessionFromJson(j: JsonNode): SessionRecord = | ||
| 162 | + SessionRecord( | ||
| 163 | + accountDid: j{"account_did"}.getStr, | ||
| 164 | + sessionId: j{"session_id"}.getStr, | ||
| 165 | + pdsUrl: j{"pds_url"}.getStr, | ||
| 166 | + authServerUrl: j{"auth_server_url"}.getStr, | ||
| 167 | + tokenEndpoint: j{"token_endpoint"}.getStr, | ||
| 168 | + accessToken: j{"access_token"}.getStr, | ||
| 169 | + refreshToken: j{"refresh_token"}.getStr, | ||
| 170 | + dpopKey: keyFromJson(j{"dpop_key"}), | ||
| 171 | + hostNonce: j{"host_nonce"}.getStr, | ||
| 172 | + authNonce: j{"auth_nonce"}.getStr, | ||
| 173 | + scopes: j{"scopes"}.getElems.mapIt(it.getStr), | ||
| 174 | + createdAt: j{"created_at"}.getBiggestInt.int64, | ||
| 175 | + ) | ||
| 176 | + | ||
| 177 | +proc saveSession*(db: Db, s: SessionRecord) = | ||
| 178 | + if s.accountDid.len == 0 or s.sessionId.len == 0: | ||
| 179 | + raise newException(StoreError, "a session needs both a DID and a session id") | ||
| 180 | + db.run("""INSERT INTO oauth_sessions (account_did, session_id, data) | ||
| 181 | + VALUES (?, ?, ?) | ||
| 182 | + ON CONFLICT(account_did, session_id) DO UPDATE SET data = excluded.data""", | ||
| 183 | + p(s.accountDid), p(s.sessionId), p($s.toJson)) | ||
| 184 | + | ||
| 185 | +proc getSession*(db: Db, did, sessionId: string): Option[SessionRecord] = | ||
| 186 | + let row = db.queryText( | ||
| 187 | + "SELECT data FROM oauth_sessions WHERE account_did = ? AND session_id = ?", | ||
| 188 | + p(did), p(sessionId)) | ||
| 189 | + if row.isNone: | ||
| 190 | + return none(SessionRecord) | ||
| 191 | + try: | ||
| 192 | + some(sessionFromJson(parseJson(row.get))) | ||
| 193 | + except JsonParsingError: | ||
| 194 | + raise newException(StoreError, "stored session is not valid JSON") | ||
| 195 | + | ||
| 196 | +proc deleteSession*(db: Db, did, sessionId: string) = | ||
| 197 | + db.run("DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ?", | ||
| 198 | + p(did), p(sessionId)) | ||
| 199 | + | ||
| 200 | +proc listSessions*(db: Db, did: string): seq[string] = | ||
| 201 | + for row in db.rows( | ||
| 202 | + "SELECT session_id FROM oauth_sessions WHERE account_did = ?", p(did)): | ||
| 203 | + result.add row[0] | ||
| 204 | + | ||
| 205 | +proc listAccounts*(db: Db): seq[string] = | ||
| 206 | + for row in db.rows("SELECT DISTINCT account_did FROM oauth_sessions"): | ||
| 207 | + result.add row[0] | ||
| 208 | + | ||
| 209 | +# --- jetstream cursor ------------------------------------------------------ | ||
| 210 | + | ||
| 211 | +proc saveCursor*(db: Db, cursorUs: int64) = | ||
| 212 | + db.run("""INSERT INTO jetstream_cursor (id, cursor_us) VALUES (1, ?) | ||
| 213 | + ON CONFLICT(id) DO UPDATE SET cursor_us = excluded.cursor_us""", | ||
| 214 | + p(cursorUs)) | ||
| 215 | + | ||
| 216 | +proc loadCursor*(db: Db): int64 = | ||
| 217 | + db.queryInt("SELECT cursor_us FROM jetstream_cursor WHERE id = 1").get(0'i64) | ||
modified
spike/nim/readme.md +38 -1 | @@ -15,6 +15,7 @@ nim c -r -d:ssl oauth_probe.nim [handle] # the flow | ||
| 15 | 15 | nim c -r -d:ssl xrpc_probe.nim # signing |
| 16 | 16 | nim c -r -d:ssl jetstream_probe.nim # firehose |
| 17 | 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor |
| 18 | +nim c -r -d:ssl store_probe.nim # persistence | |
| 18 | 19 | ``` |
| 19 | 20 | |
| 20 | 21 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -206,6 +207,43 @@ to the initial delay, skipping jitter on the first retry. That is precisely | ||
| 206 | 207 | the moment jitter exists for: one server restart drops every consumer at |
| 207 | 208 | once, and un-jittered first retries bring them all back simultaneously. |
| 208 | 209 | |
| 210 | +## 7. Session persistence — works | |
| 211 | + | |
| 212 | +`atproto/sqlite.nim` is a thin wrapper over the library the storage spike | |
| 213 | +already proved (sqlite-vec linked in, WAL on). `atproto/store.nim` keeps the | |
| 214 | +three things a restart must not lose, in the same tables the Go backend uses: | |
| 215 | +`oauth_auth_requests`, `oauth_sessions`, `jetstream_cursor`. Rows hold JSON, | |
| 216 | +as on the Go side, so adding a session field does not mean a migration. | |
| 217 | + | |
| 218 | +`atproto/cursor_store.nim` bridges the store and the supervisor, batching | |
| 219 | +writes: the cursor advances on every event, often hundreds a second, and a | |
| 220 | +synchronous write per event would make the database the bottleneck for a | |
| 221 | +firehose. The stored cursor therefore trails the live one — which is exactly | |
| 222 | +the gap the supervisor's rewind exists to cover. | |
| 223 | + | |
| 224 | +The check that earns its keep is the DPoP key. A missing row is loud; a key | |
| 225 | +that round-trips *almost* correctly is not — every request after a restart | |
| 226 | +would fail authentication with nothing pointing at storage. So the reloaded | |
| 227 | +key is checked by **signing with it and verifying against the original public | |
| 228 | +point**, with a second key as a negative control. Comparing fields, or | |
| 229 | +re-signing and comparing bytes, would prove neither thing: ECDSA is | |
| 230 | +randomised. | |
| 231 | + | |
| 232 | +Two other properties worth pinning: | |
| 233 | + | |
| 234 | +- Taking a pending auth request **consumes** it. An authorization code is | |
| 235 | + single-use, and leaving the row behind invites a replayed callback. | |
| 236 | +- Cursors never move backwards. A late event that rewound the stored cursor | |
| 237 | + would rewind the subscription on the next reconnect. | |
| 238 | + | |
| 239 | +The public half of the key is stored rather than re-derived on load. Deriving | |
| 240 | +it needs a curve operation, and a stored key that cannot be read back without | |
| 241 | +one is a restart that silently drops every session. | |
| 242 | + | |
| 243 | +**These rows are key material.** A session holds the DPoP private key and the | |
| 244 | +refresh token; together they are the account. The database file wants the | |
| 245 | +protection of a password store, not of a cache. | |
| 246 | + | |
| 209 | 247 | ## What this does and does not prove |
| 210 | 248 | |
| 211 | 249 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
| @@ -217,7 +255,6 @@ Not proven, and still ahead: | ||
| 217 | 255 | exercise. This is the one place a real end-to-end test still has to happen, |
| 218 | 256 | and it also covers the `invalid_token` refresh-and-retry branch of |
| 219 | 257 | `xrpc.request`, which is currently unexercised. |
| 220 | -- **Session persistence** across restarts. | |
| 221 | 258 | |
| 222 | 259 | ### Not needed: DAG-CBOR, CAR and the MST |
| 223 | 260 | |
| @@ -15,6 +15,7 @@ nim c -r -d:ssl oauth_probe.nim [handle] # the flow | |||
| 15 | nim c -r -d:ssl xrpc_probe.nim # signing | 15 | nim c -r -d:ssl xrpc_probe.nim # signing |
| 16 | nim c -r -d:ssl jetstream_probe.nim # firehose | 16 | nim c -r -d:ssl jetstream_probe.nim # firehose |
| 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor | 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor |
| 18 | +nim c -r -d:ssl store_probe.nim # persistence | ||
| 18 | ``` | 19 | ``` |
| 19 | 20 | ||
| 20 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. | 21 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -206,6 +207,43 @@ to the initial delay, skipping jitter on the first retry. That is precisely | |||
| 206 | the moment jitter exists for: one server restart drops every consumer at | 207 | the moment jitter exists for: one server restart drops every consumer at |
| 207 | once, and un-jittered first retries bring them all back simultaneously. | 208 | once, and un-jittered first retries bring them all back simultaneously. |
| 208 | 209 | ||
| 210 | +## 7. Session persistence — works | ||
| 211 | + | ||
| 212 | +`atproto/sqlite.nim` is a thin wrapper over the library the storage spike | ||
| 213 | +already proved (sqlite-vec linked in, WAL on). `atproto/store.nim` keeps the | ||
| 214 | +three things a restart must not lose, in the same tables the Go backend uses: | ||
| 215 | +`oauth_auth_requests`, `oauth_sessions`, `jetstream_cursor`. Rows hold JSON, | ||
| 216 | +as on the Go side, so adding a session field does not mean a migration. | ||
| 217 | + | ||
| 218 | +`atproto/cursor_store.nim` bridges the store and the supervisor, batching | ||
| 219 | +writes: the cursor advances on every event, often hundreds a second, and a | ||
| 220 | +synchronous write per event would make the database the bottleneck for a | ||
| 221 | +firehose. The stored cursor therefore trails the live one — which is exactly | ||
| 222 | +the gap the supervisor's rewind exists to cover. | ||
| 223 | + | ||
| 224 | +The check that earns its keep is the DPoP key. A missing row is loud; a key | ||
| 225 | +that round-trips *almost* correctly is not — every request after a restart | ||
| 226 | +would fail authentication with nothing pointing at storage. So the reloaded | ||
| 227 | +key is checked by **signing with it and verifying against the original public | ||
| 228 | +point**, with a second key as a negative control. Comparing fields, or | ||
| 229 | +re-signing and comparing bytes, would prove neither thing: ECDSA is | ||
| 230 | +randomised. | ||
| 231 | + | ||
| 232 | +Two other properties worth pinning: | ||
| 233 | + | ||
| 234 | +- Taking a pending auth request **consumes** it. An authorization code is | ||
| 235 | + single-use, and leaving the row behind invites a replayed callback. | ||
| 236 | +- Cursors never move backwards. A late event that rewound the stored cursor | ||
| 237 | + would rewind the subscription on the next reconnect. | ||
| 238 | + | ||
| 239 | +The public half of the key is stored rather than re-derived on load. Deriving | ||
| 240 | +it needs a curve operation, and a stored key that cannot be read back without | ||
| 241 | +one is a restart that silently drops every session. | ||
| 242 | + | ||
| 243 | +**These rows are key material.** A session holds the DPoP private key and the | ||
| 244 | +refresh token; together they are the account. The database file wants the | ||
| 245 | +protection of a password store, not of a cache. | ||
| 246 | + | ||
| 209 | ## What this does and does not prove | 247 | ## What this does and does not prove |
| 210 | 248 | ||
| 211 | Proven: the cryptography, the storage layer, and the OAuth flow up to user | 249 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
| @@ -217,7 +255,6 @@ Not proven, and still ahead: | |||
| 217 | exercise. This is the one place a real end-to-end test still has to happen, | 255 | exercise. This is the one place a real end-to-end test still has to happen, |
| 218 | and it also covers the `invalid_token` refresh-and-retry branch of | 256 | and it also covers the `invalid_token` refresh-and-retry branch of |
| 219 | `xrpc.request`, which is currently unexercised. | 257 | `xrpc.request`, which is currently unexercised. |
| 220 | -- **Session persistence** across restarts. | ||
| 221 | 258 | ||
| 222 | ### Not needed: DAG-CBOR, CAR and the MST | 259 | ### Not needed: DAG-CBOR, CAR and the MST |
| 223 | 260 | ||
added
spike/nim/store_probe.nim +185 -0 | new file mode 100644 | ||
| @@ -0,0 +1,185 @@ | ||
| 1 | +## Checks that a session survives a restart intact. | |
| 2 | +## | |
| 3 | +## nim c -r -d:ssl store_probe.nim | |
| 4 | +## | |
| 5 | +## The interesting failure is not "the row is missing" -- that is loud. It is | |
| 6 | +## a DPoP key that round-trips *almost* correctly: every request after a | |
| 7 | +## restart then fails authentication with no clue that storage is at fault. | |
| 8 | +## So the key is checked by signing with the reloaded copy and verifying | |
| 9 | +## against the original public point, not by comparing fields. | |
| 10 | + | |
| 11 | +import std/[json, options, os, strformat, strutils] | |
| 12 | +import pkg/chronos | |
| 13 | +import bearssl/rand | |
| 14 | +import atproto/[cursor_store, dpop, jetstream_run, oauth, sqlite, store] | |
| 15 | + | |
| 16 | +var failures = 0 | |
| 17 | + | |
| 18 | +proc report(name: string, ok: bool, detail = "") = | |
| 19 | + if not ok: inc failures | |
| 20 | + let label = if ok: "PASS" else: "FAIL" | |
| 21 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | |
| 22 | + else: echo &" {label} {name}" | |
| 23 | + | |
| 24 | +proc main() = | |
| 25 | + echo "Session persistence probe" | |
| 26 | + echo "" | |
| 27 | + | |
| 28 | + let rngRef = HmacDrbgContext.new() | |
| 29 | + doAssert rngRef != nil | |
| 30 | + var rng = rngRef[] | |
| 31 | + | |
| 32 | + let path = getTempDir() / "glean_store_probe.db" | |
| 33 | + removeFile(path) | |
| 34 | + removeFile(path & "-wal") | |
| 35 | + removeFile(path & "-shm") | |
| 36 | + defer: | |
| 37 | + removeFile(path) | |
| 38 | + removeFile(path & "-wal") | |
| 39 | + removeFile(path & "-shm") | |
| 40 | + | |
| 41 | + let key = generateKey(rng) | |
| 42 | + let originalThumb = key.thumbprint | |
| 43 | + | |
| 44 | + # --- write, then close the database entirely --------------------------- | |
| 45 | + block: | |
| 46 | + var db = sqlite.open(path) | |
| 47 | + migrate(db) | |
| 48 | + report("schema applies", true, "sqlite " & libVersion()) | |
| 49 | + | |
| 50 | + saveSession(db, SessionRecord( | |
| 51 | + accountDid: "did:plc:probe", | |
| 52 | + sessionId: "sess-1", | |
| 53 | + pdsUrl: "https://pds.example", | |
| 54 | + authServerUrl: "https://bsky.social", | |
| 55 | + tokenEndpoint: "https://bsky.social/oauth/token", | |
| 56 | + accessToken: "access-abc", | |
| 57 | + refreshToken: "refresh-xyz", | |
| 58 | + dpopKey: key, | |
| 59 | + hostNonce: "host-n", | |
| 60 | + authNonce: "auth-n", | |
| 61 | + scopes: @["atproto", "transition:generic"], | |
| 62 | + createdAt: 1_700_000_000, | |
| 63 | + )) | |
| 64 | + saveCursor(db, 1_700_000_000_000_000'i64) | |
| 65 | + saveAuthRequest(db, AuthRequestData( | |
| 66 | + state: "state-1", | |
| 67 | + authServerUrl: "https://bsky.social", | |
| 68 | + scopes: @["atproto"], | |
| 69 | + pkceVerifier: "verifier-1", | |
| 70 | + requestUri: "urn:ietf:params:oauth:request_uri:req-1", | |
| 71 | + tokenEndpoint: "https://bsky.social/oauth/token", | |
| 72 | + dpopNonce: "n-1", | |
| 73 | + dpopKey: key, | |
| 74 | + accountDid: "did:plc:probe", | |
| 75 | + )) | |
| 76 | + db.close() | |
| 77 | + | |
| 78 | + # --- reopen, as a restart would ---------------------------------------- | |
| 79 | + var db = sqlite.open(path) | |
| 80 | + defer: db.close() | |
| 81 | + migrate(db) # idempotent, as it is on every boot | |
| 82 | + | |
| 83 | + block: | |
| 84 | + let got = getSession(db, "did:plc:probe", "sess-1") | |
| 85 | + report("session survives a restart", got.isSome) | |
| 86 | + if got.isSome: | |
| 87 | + let s = got.get | |
| 88 | + report("tokens round-trip", | |
| 89 | + s.accessToken == "access-abc" and s.refreshToken == "refresh-xyz") | |
| 90 | + report("nonces round-trip", | |
| 91 | + s.hostNonce == "host-n" and s.authNonce == "auth-n") | |
| 92 | + report("scopes round-trip", s.scopes == @["atproto", "transition:generic"]) | |
| 93 | + | |
| 94 | + # The check that matters. | |
| 95 | + report("reloaded key has the same JWK thumbprint", | |
| 96 | + s.dpopKey.thumbprint == originalThumb, s.dpopKey.thumbprint) | |
| 97 | + | |
| 98 | + const msg = "eyJhbGciOiJFUzI1NiJ9.eyJodG0iOiJHRVQifQ" | |
| 99 | + let sig = s.dpopKey.signEs256(rng, msg) | |
| 100 | + report("reloaded key still signs verifiably", | |
| 101 | + key.verifyEs256(msg, sig), | |
| 102 | + "signed with the reloaded key, verified against the original") | |
| 103 | + | |
| 104 | + # Negative control: a verifier that accepts anything proves nothing. | |
| 105 | + let other = generateKey(rng) | |
| 106 | + report("a different key does not verify", | |
| 107 | + not other.verifyEs256(msg, sig)) | |
| 108 | + | |
| 109 | + block: | |
| 110 | + report("unknown session reads as none", | |
| 111 | + getSession(db, "did:plc:probe", "nope").isNone) | |
| 112 | + report("sessions are listed per account", | |
| 113 | + listSessions(db, "did:plc:probe") == @["sess-1"]) | |
| 114 | + report("accounts are listed", listAccounts(db) == @["did:plc:probe"]) | |
| 115 | + | |
| 116 | + block: | |
| 117 | + report("cursor survives a restart", | |
| 118 | + loadCursor(db) == 1_700_000_000_000_000'i64, $loadCursor(db)) | |
| 119 | + | |
| 120 | + # --- auth requests are single-use -------------------------------------- | |
| 121 | + block: | |
| 122 | + let first = takeAuthRequest(db, "state-1") | |
| 123 | + report("pending auth request survives a restart", first.isSome) | |
| 124 | + if first.isSome: | |
| 125 | + report("PKCE verifier round-trips", first.get.pkceVerifier == "verifier-1") | |
| 126 | + report("auth request key round-trips", | |
| 127 | + first.get.dpopKey.thumbprint == originalThumb) | |
| 128 | + # An authorization code is single-use; leaving the row behind invites a | |
| 129 | + # replayed callback. | |
| 130 | + report("taking an auth request consumes it", | |
| 131 | + takeAuthRequest(db, "state-1").isNone) | |
| 132 | + | |
| 133 | + # --- batched cursor store ---------------------------------------------- | |
| 134 | + block: | |
| 135 | + let cs = newDbCursorStore(db, flushEvery = 3600) # never auto-flush here | |
| 136 | + report("cursor store loads the persisted value", | |
| 137 | + cs.load() == 1_700_000_000_000_000'i64) | |
| 138 | + | |
| 139 | + cs.save(1_700_000_000_500_000'i64) | |
| 140 | + # Batching means the write is deferred, not lost. | |
| 141 | + report("a fresh cursor is not written immediately", | |
| 142 | + loadCursor(db) == 1_700_000_000_000_000'i64) | |
| 143 | + cs.close() | |
| 144 | + report("closing flushes the pending cursor", | |
| 145 | + loadCursor(db) == 1_700_000_000_500_000'i64, $loadCursor(db)) | |
| 146 | + | |
| 147 | + # Cursors must never move backwards, or a late event would rewind the | |
| 148 | + # subscription on the next reconnect. | |
| 149 | + let cs2 = newDbCursorStore(db, flushEvery = 0) | |
| 150 | + cs2.save(1'i64) | |
| 151 | + cs2.close() | |
| 152 | + report("an older cursor does not overwrite a newer one", | |
| 153 | + loadCursor(db) == 1_700_000_000_500_000'i64, $loadCursor(db)) | |
| 154 | + | |
| 155 | + # --- the seam with the supervisor -------------------------------------- | |
| 156 | + # jetstream_run takes a CursorStore base reference, so this only works if | |
| 157 | + # the overrides dispatch dynamically. A silent fall-through to the base | |
| 158 | + # method would read the in-memory field instead of the database and look | |
| 159 | + # entirely healthy while resuming from the wrong place. | |
| 160 | + block: | |
| 161 | + let cs = newDbCursorStore(db, flushEvery = 3600) | |
| 162 | + let asBase: CursorStore = cs | |
| 163 | + report("DbCursorStore dispatches through the CursorStore base", | |
| 164 | + asBase.load() == loadCursor(db), $asBase.load()) | |
| 165 | + | |
| 166 | + let rewound = rewoundCursor(asBase, 5.seconds) | |
| 167 | + report("the supervisor rewinds the persisted cursor", | |
| 168 | + rewound == loadCursor(db) - 5_000_000, $rewound) | |
| 169 | + | |
| 170 | + # --- deletion ---------------------------------------------------------- | |
| 171 | + block: | |
| 172 | + deleteSession(db, "did:plc:probe", "sess-1") | |
| 173 | + report("sessions can be deleted", | |
| 174 | + getSession(db, "did:plc:probe", "sess-1").isNone and | |
| 175 | + listAccounts(db).len == 0) | |
| 176 | + | |
| 177 | + echo "" | |
| 178 | + if failures == 0: | |
| 179 | + echo "RESULT: sessions, keys and cursors survive a restart." | |
| 180 | + else: | |
| 181 | + echo &"RESULT: {failures} check(s) failed." | |
| 182 | + quit 1 | |
| 183 | + | |
| 184 | +when isMainModule: | |
| 185 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,185 @@ | |||
| 1 | +## Checks that a session survives a restart intact. | ||
| 2 | +## | ||
| 3 | +## nim c -r -d:ssl store_probe.nim | ||
| 4 | +## | ||
| 5 | +## The interesting failure is not "the row is missing" -- that is loud. It is | ||
| 6 | +## a DPoP key that round-trips *almost* correctly: every request after a | ||
| 7 | +## restart then fails authentication with no clue that storage is at fault. | ||
| 8 | +## So the key is checked by signing with the reloaded copy and verifying | ||
| 9 | +## against the original public point, not by comparing fields. | ||
| 10 | + | ||
| 11 | +import std/[json, options, os, strformat, strutils] | ||
| 12 | +import pkg/chronos | ||
| 13 | +import bearssl/rand | ||
| 14 | +import atproto/[cursor_store, dpop, jetstream_run, oauth, sqlite, store] | ||
| 15 | + | ||
| 16 | +var failures = 0 | ||
| 17 | + | ||
| 18 | +proc report(name: string, ok: bool, detail = "") = | ||
| 19 | + if not ok: inc failures | ||
| 20 | + let label = if ok: "PASS" else: "FAIL" | ||
| 21 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | ||
| 22 | + else: echo &" {label} {name}" | ||
| 23 | + | ||
| 24 | +proc main() = | ||
| 25 | + echo "Session persistence probe" | ||
| 26 | + echo "" | ||
| 27 | + | ||
| 28 | + let rngRef = HmacDrbgContext.new() | ||
| 29 | + doAssert rngRef != nil | ||
| 30 | + var rng = rngRef[] | ||
| 31 | + | ||
| 32 | + let path = getTempDir() / "glean_store_probe.db" | ||
| 33 | + removeFile(path) | ||
| 34 | + removeFile(path & "-wal") | ||
| 35 | + removeFile(path & "-shm") | ||
| 36 | + defer: | ||
| 37 | + removeFile(path) | ||
| 38 | + removeFile(path & "-wal") | ||
| 39 | + removeFile(path & "-shm") | ||
| 40 | + | ||
| 41 | + let key = generateKey(rng) | ||
| 42 | + let originalThumb = key.thumbprint | ||
| 43 | + | ||
| 44 | + # --- write, then close the database entirely --------------------------- | ||
| 45 | + block: | ||
| 46 | + var db = sqlite.open(path) | ||
| 47 | + migrate(db) | ||
| 48 | + report("schema applies", true, "sqlite " & libVersion()) | ||
| 49 | + | ||
| 50 | + saveSession(db, SessionRecord( | ||
| 51 | + accountDid: "did:plc:probe", | ||
| 52 | + sessionId: "sess-1", | ||
| 53 | + pdsUrl: "https://pds.example", | ||
| 54 | + authServerUrl: "https://bsky.social", | ||
| 55 | + tokenEndpoint: "https://bsky.social/oauth/token", | ||
| 56 | + accessToken: "access-abc", | ||
| 57 | + refreshToken: "refresh-xyz", | ||
| 58 | + dpopKey: key, | ||
| 59 | + hostNonce: "host-n", | ||
| 60 | + authNonce: "auth-n", | ||
| 61 | + scopes: @["atproto", "transition:generic"], | ||
| 62 | + createdAt: 1_700_000_000, | ||
| 63 | + )) | ||
| 64 | + saveCursor(db, 1_700_000_000_000_000'i64) | ||
| 65 | + saveAuthRequest(db, AuthRequestData( | ||
| 66 | + state: "state-1", | ||
| 67 | + authServerUrl: "https://bsky.social", | ||
| 68 | + scopes: @["atproto"], | ||
| 69 | + pkceVerifier: "verifier-1", | ||
| 70 | + requestUri: "urn:ietf:params:oauth:request_uri:req-1", | ||
| 71 | + tokenEndpoint: "https://bsky.social/oauth/token", | ||
| 72 | + dpopNonce: "n-1", | ||
| 73 | + dpopKey: key, | ||
| 74 | + accountDid: "did:plc:probe", | ||
| 75 | + )) | ||
| 76 | + db.close() | ||
| 77 | + | ||
| 78 | + # --- reopen, as a restart would ---------------------------------------- | ||
| 79 | + var db = sqlite.open(path) | ||
| 80 | + defer: db.close() | ||
| 81 | + migrate(db) # idempotent, as it is on every boot | ||
| 82 | + | ||
| 83 | + block: | ||
| 84 | + let got = getSession(db, "did:plc:probe", "sess-1") | ||
| 85 | + report("session survives a restart", got.isSome) | ||
| 86 | + if got.isSome: | ||
| 87 | + let s = got.get | ||
| 88 | + report("tokens round-trip", | ||
| 89 | + s.accessToken == "access-abc" and s.refreshToken == "refresh-xyz") | ||
| 90 | + report("nonces round-trip", | ||
| 91 | + s.hostNonce == "host-n" and s.authNonce == "auth-n") | ||
| 92 | + report("scopes round-trip", s.scopes == @["atproto", "transition:generic"]) | ||
| 93 | + | ||
| 94 | + # The check that matters. | ||
| 95 | + report("reloaded key has the same JWK thumbprint", | ||
| 96 | + s.dpopKey.thumbprint == originalThumb, s.dpopKey.thumbprint) | ||
| 97 | + | ||
| 98 | + const msg = "eyJhbGciOiJFUzI1NiJ9.eyJodG0iOiJHRVQifQ" | ||
| 99 | + let sig = s.dpopKey.signEs256(rng, msg) | ||
| 100 | + report("reloaded key still signs verifiably", | ||
| 101 | + key.verifyEs256(msg, sig), | ||
| 102 | + "signed with the reloaded key, verified against the original") | ||
| 103 | + | ||
| 104 | + # Negative control: a verifier that accepts anything proves nothing. | ||
| 105 | + let other = generateKey(rng) | ||
| 106 | + report("a different key does not verify", | ||
| 107 | + not other.verifyEs256(msg, sig)) | ||
| 108 | + | ||
| 109 | + block: | ||
| 110 | + report("unknown session reads as none", | ||
| 111 | + getSession(db, "did:plc:probe", "nope").isNone) | ||
| 112 | + report("sessions are listed per account", | ||
| 113 | + listSessions(db, "did:plc:probe") == @["sess-1"]) | ||
| 114 | + report("accounts are listed", listAccounts(db) == @["did:plc:probe"]) | ||
| 115 | + | ||
| 116 | + block: | ||
| 117 | + report("cursor survives a restart", | ||
| 118 | + loadCursor(db) == 1_700_000_000_000_000'i64, $loadCursor(db)) | ||
| 119 | + | ||
| 120 | + # --- auth requests are single-use -------------------------------------- | ||
| 121 | + block: | ||
| 122 | + let first = takeAuthRequest(db, "state-1") | ||
| 123 | + report("pending auth request survives a restart", first.isSome) | ||
| 124 | + if first.isSome: | ||
| 125 | + report("PKCE verifier round-trips", first.get.pkceVerifier == "verifier-1") | ||
| 126 | + report("auth request key round-trips", | ||
| 127 | + first.get.dpopKey.thumbprint == originalThumb) | ||
| 128 | + # An authorization code is single-use; leaving the row behind invites a | ||
| 129 | + # replayed callback. | ||
| 130 | + report("taking an auth request consumes it", | ||
| 131 | + takeAuthRequest(db, "state-1").isNone) | ||
| 132 | + | ||
| 133 | + # --- batched cursor store ---------------------------------------------- | ||
| 134 | + block: | ||
| 135 | + let cs = newDbCursorStore(db, flushEvery = 3600) # never auto-flush here | ||
| 136 | + report("cursor store loads the persisted value", | ||
| 137 | + cs.load() == 1_700_000_000_000_000'i64) | ||
| 138 | + | ||
| 139 | + cs.save(1_700_000_000_500_000'i64) | ||
| 140 | + # Batching means the write is deferred, not lost. | ||
| 141 | + report("a fresh cursor is not written immediately", | ||
| 142 | + loadCursor(db) == 1_700_000_000_000_000'i64) | ||
| 143 | + cs.close() | ||
| 144 | + report("closing flushes the pending cursor", | ||
| 145 | + loadCursor(db) == 1_700_000_000_500_000'i64, $loadCursor(db)) | ||
| 146 | + | ||
| 147 | + # Cursors must never move backwards, or a late event would rewind the | ||
| 148 | + # subscription on the next reconnect. | ||
| 149 | + let cs2 = newDbCursorStore(db, flushEvery = 0) | ||
| 150 | + cs2.save(1'i64) | ||
| 151 | + cs2.close() | ||
| 152 | + report("an older cursor does not overwrite a newer one", | ||
| 153 | + loadCursor(db) == 1_700_000_000_500_000'i64, $loadCursor(db)) | ||
| 154 | + | ||
| 155 | + # --- the seam with the supervisor -------------------------------------- | ||
| 156 | + # jetstream_run takes a CursorStore base reference, so this only works if | ||
| 157 | + # the overrides dispatch dynamically. A silent fall-through to the base | ||
| 158 | + # method would read the in-memory field instead of the database and look | ||
| 159 | + # entirely healthy while resuming from the wrong place. | ||
| 160 | + block: | ||
| 161 | + let cs = newDbCursorStore(db, flushEvery = 3600) | ||
| 162 | + let asBase: CursorStore = cs | ||
| 163 | + report("DbCursorStore dispatches through the CursorStore base", | ||
| 164 | + asBase.load() == loadCursor(db), $asBase.load()) | ||
| 165 | + | ||
| 166 | + let rewound = rewoundCursor(asBase, 5.seconds) | ||
| 167 | + report("the supervisor rewinds the persisted cursor", | ||
| 168 | + rewound == loadCursor(db) - 5_000_000, $rewound) | ||
| 169 | + | ||
| 170 | + # --- deletion ---------------------------------------------------------- | ||
| 171 | + block: | ||
| 172 | + deleteSession(db, "did:plc:probe", "sess-1") | ||
| 173 | + report("sessions can be deleted", | ||
| 174 | + getSession(db, "did:plc:probe", "sess-1").isNone and | ||
| 175 | + listAccounts(db).len == 0) | ||
| 176 | + | ||
| 177 | + echo "" | ||
| 178 | + if failures == 0: | ||
| 179 | + echo "RESULT: sessions, keys and cursors survive a restart." | ||
| 180 | + else: | ||
| 181 | + echo &"RESULT: {failures} check(s) failed." | ||
| 182 | + quit 1 | ||
| 183 | + | ||
| 184 | +when isMainModule: | ||
| 185 | + main() | ||