@pgpm/totp 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/Makefile +6 -0
- package/README.md +160 -0
- package/__tests__/__snapshots__/algo.test.ts.snap +25 -0
- package/__tests__/algo.test.ts +142 -0
- package/__tests__/totp.test.ts +26 -0
- package/deploy/schemas/totp/procedures/generate_totp.sql +168 -0
- package/deploy/schemas/totp/procedures/random_base32.sql +38 -0
- package/deploy/schemas/totp/procedures/urlencode.sql +43 -0
- package/deploy/schemas/totp/schema.sql +8 -0
- package/jest.config.js +15 -0
- package/launchql-totp.control +8 -0
- package/launchql.plan +8 -0
- package/package.json +29 -0
- package/revert/schemas/totp/procedures/generate_totp.sql +14 -0
- package/revert/schemas/totp/procedures/random_base32.sql +8 -0
- package/revert/schemas/totp/procedures/urlencode.sql +7 -0
- package/revert/schemas/totp/schema.sql +7 -0
- package/sql/launchql-totp--0.4.6.sql +173 -0
- package/verify/schemas/totp/procedures/generate_totp.sql +7 -0
- package/verify/schemas/totp/procedures/random_base32.sql +7 -0
- package/verify/schemas/totp/procedures/urlencode.sql +7 -0
- package/verify/schemas/totp/schema.sql +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
|
|
4
|
+
Copyright (c) 2025 Interweb, Inc.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/Makefile
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# @pgpm/totp
|
|
2
|
+
|
|
3
|
+
TOTP implementation in pure PostgreSQL plpgsql
|
|
4
|
+
|
|
5
|
+
This extension provides the HMAC Time-Based One-Time Password Algorithm (TOTP) as specfied in RFC 4226 as pure plpgsql functions.
|
|
6
|
+
|
|
7
|
+
# Usage
|
|
8
|
+
|
|
9
|
+
## totp.generate
|
|
10
|
+
|
|
11
|
+
```sql
|
|
12
|
+
SELECT totp.generate('mysecret');
|
|
13
|
+
|
|
14
|
+
-- you can also specify totp_interval, and totp_length
|
|
15
|
+
SELECT totp.generate('mysecret', 30, 6);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
In this case, produces a TOTP code of length 6
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
013438
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## totp.verify
|
|
25
|
+
|
|
26
|
+
```sql
|
|
27
|
+
SELECT totp.verify('mysecret', '765430');
|
|
28
|
+
|
|
29
|
+
-- you can also specify totp_interval, and totp_length
|
|
30
|
+
SELECT totp.verify('mysecret', '765430', 30, 6);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Depending on input, returns `TRUE/FALSE`
|
|
34
|
+
|
|
35
|
+
## totp.url
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
-- totp.url ( email text, totp_secret text, totp_interval int, totp_issuer text )
|
|
39
|
+
SELECT totp.url(
|
|
40
|
+
'customer@email.com',
|
|
41
|
+
'mysecret',
|
|
42
|
+
30,
|
|
43
|
+
'Acme Inc'
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Will produce a URL-encoded string
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
otpauth://totp/customer@email.com?secret=mysecret&period=30&issuer=Acme%20Inc
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
# caveats
|
|
54
|
+
|
|
55
|
+
Currently only supports `sha1`, pull requests welcome!
|
|
56
|
+
|
|
57
|
+
# debugging
|
|
58
|
+
|
|
59
|
+
use the verbose option to show keys
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
$ oathtool --totp -v -d 7 -s 10s -b OH3NUPO3WOGOZZQ4
|
|
63
|
+
Hex secret: 71f6da3ddbb38cece61c
|
|
64
|
+
Base32 secret: OH3NUPO3WOGOZZQ4
|
|
65
|
+
Digits: 7
|
|
66
|
+
Window size: 0
|
|
67
|
+
TOTP mode: SHA1
|
|
68
|
+
Step size (seconds): 10
|
|
69
|
+
Start time: 1970-01-01 00:00:00 UTC (0)
|
|
70
|
+
Current time: 2020-11-18 12:35:08 UTC (1605702908)
|
|
71
|
+
Counter: 0x9921BB2 (160570290)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
using time for testing
|
|
75
|
+
|
|
76
|
+
oathtool --totp -v -d 6 -s 30s -b vmlhl2knm27eftq7 --now "2020-02-05 22:11:40 UTC"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# credits
|
|
80
|
+
|
|
81
|
+
Thanks to
|
|
82
|
+
|
|
83
|
+
https://tools.ietf.org/html/rfc6238
|
|
84
|
+
|
|
85
|
+
https://www.youtube.com/watch?v=VOYxF12K1vE
|
|
86
|
+
|
|
87
|
+
https://pgxn.org/dist/otp/
|
|
88
|
+
|
|
89
|
+
And major improvements from
|
|
90
|
+
|
|
91
|
+
https://gist.github.com/bwbroersma/676d0de32263ed554584ab132434ebd9
|
|
92
|
+
|
|
93
|
+
# Development
|
|
94
|
+
|
|
95
|
+
## start the postgres db process
|
|
96
|
+
|
|
97
|
+
First you'll want to start the postgres docker (you can also just use `docker-compose up -d`):
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
make up
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## install modules
|
|
104
|
+
|
|
105
|
+
Install modules
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
yarn install
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## install the Postgres extensions
|
|
112
|
+
|
|
113
|
+
Now that the postgres process is running, install the extensions:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
make install
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This basically `ssh`s into the postgres instance with the `packages/` folder mounted as a volume, and installs the bundled sql code as pgxn extensions.
|
|
120
|
+
|
|
121
|
+
## testing
|
|
122
|
+
|
|
123
|
+
Testing will load all your latest sql changes and create fresh, populated databases for each sqitch module in `packages/`.
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
yarn test:watch
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## building new modules
|
|
130
|
+
|
|
131
|
+
Create a new folder in `packages/`
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
lql init
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Then, run a generator:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
lql generate
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
You can also add arguments if you already know what you want to do:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
lql generate schema --schema myschema
|
|
147
|
+
lql generate table --schema myschema --table mytable
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## deploy code as extensions
|
|
151
|
+
|
|
152
|
+
`cd` into `packages/<module>`, and run `lql package`. This will make an sql file in `packages/<module>/sql/` used for `CREATE EXTENSION` calls to install your sqitch module as an extension.
|
|
153
|
+
|
|
154
|
+
## recursive deploy
|
|
155
|
+
|
|
156
|
+
You can also deploy all modules utilizing versioning as sqtich modules. Remove `--createdb` if you already created your db:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
lql deploy awesome-db --yes --recursive --createdb
|
|
160
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`issue case: 1 1`] = `"476240"`;
|
|
4
|
+
|
|
5
|
+
exports[`issue case: 2 1`] = `"788648"`;
|
|
6
|
+
|
|
7
|
+
exports[`issue case: 3 1`] = `"080176"`;
|
|
8
|
+
|
|
9
|
+
exports[`rfc6238 case: 1 1`] = `"94287082"`;
|
|
10
|
+
|
|
11
|
+
exports[`rfc6238 case: 2 1`] = `"07081804"`;
|
|
12
|
+
|
|
13
|
+
exports[`rfc6238 case: 3 1`] = `"14050471"`;
|
|
14
|
+
|
|
15
|
+
exports[`rfc6238 case: 4 1`] = `"89005924"`;
|
|
16
|
+
|
|
17
|
+
exports[`rfc6238 case: 5 1`] = `"69279037"`;
|
|
18
|
+
|
|
19
|
+
exports[`rfc6238 case: 6 1`] = `"65353130"`;
|
|
20
|
+
|
|
21
|
+
exports[`speakeasy test case: 1 1`] = `"287082"`;
|
|
22
|
+
|
|
23
|
+
exports[`speakeasy test case: 2 1`] = `"081804"`;
|
|
24
|
+
|
|
25
|
+
exports[`speakeasy test case: 3 1`] = `"360094"`;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { getConnections, PgTestClient } from 'pgsql-test';
|
|
2
|
+
import cases from 'jest-in-case';
|
|
3
|
+
|
|
4
|
+
let pg: PgTestClient;
|
|
5
|
+
let teardown: () => Promise<void>;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
({ pg, teardown } = await getConnections());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await teardown();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
cases(
|
|
17
|
+
'rfc6238',
|
|
18
|
+
async (opts: { date: string; len: number; algo: string; result: string }) => {
|
|
19
|
+
const { generate } = await pg.one(
|
|
20
|
+
`SELECT totp.generate(
|
|
21
|
+
secret := $1,
|
|
22
|
+
period := 30,
|
|
23
|
+
digits := $2,
|
|
24
|
+
time_from := $3,
|
|
25
|
+
hash := $4,
|
|
26
|
+
encoding := NULL
|
|
27
|
+
)`,
|
|
28
|
+
['12345678901234567890', opts.len, opts.date, opts.algo]
|
|
29
|
+
);
|
|
30
|
+
expect(generate).toEqual(opts.result);
|
|
31
|
+
expect(generate).toMatchSnapshot();
|
|
32
|
+
},
|
|
33
|
+
[
|
|
34
|
+
{ date: '1970-01-01 00:00:59', len: 8, algo: 'sha1', result: '94287082' },
|
|
35
|
+
{ date: '2005-03-18 01:58:29', len: 8, algo: 'sha1', result: '07081804' },
|
|
36
|
+
{ date: '2005-03-18 01:58:31', len: 8, algo: 'sha1', result: '14050471' },
|
|
37
|
+
{ date: '2009-02-13 23:31:30', len: 8, algo: 'sha1', result: '89005924' },
|
|
38
|
+
{ date: '2033-05-18 03:33:20', len: 8, algo: 'sha1', result: '69279037' },
|
|
39
|
+
{ date: '2603-10-11 11:33:20', len: 8, algo: 'sha1', result: '65353130' }
|
|
40
|
+
]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
cases(
|
|
44
|
+
'speakeasy test',
|
|
45
|
+
async (opts: { date: string; len: number; algo: string; step: number; result: string }) => {
|
|
46
|
+
const { generate } = await pg.one(
|
|
47
|
+
`SELECT totp.generate(
|
|
48
|
+
secret := $1,
|
|
49
|
+
period := $5,
|
|
50
|
+
digits := $2,
|
|
51
|
+
time_from := $3,
|
|
52
|
+
hash := $4,
|
|
53
|
+
encoding := NULL
|
|
54
|
+
)`,
|
|
55
|
+
['12345678901234567890', opts.len, opts.date, opts.algo, opts.step]
|
|
56
|
+
);
|
|
57
|
+
expect(generate).toEqual(opts.result);
|
|
58
|
+
expect(generate).toMatchSnapshot();
|
|
59
|
+
},
|
|
60
|
+
[
|
|
61
|
+
{ date: '1970-01-01 00:00:59', len: 6, step: 30, algo: 'sha1', result: '287082' },
|
|
62
|
+
{ date: '2005-03-18 01:58:29', len: 6, step: 30, algo: 'sha1', result: '081804' },
|
|
63
|
+
{ date: '2005-03-18 01:58:29', len: 6, step: 60, algo: 'sha1', result: '360094' }
|
|
64
|
+
]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
cases(
|
|
68
|
+
'verify',
|
|
69
|
+
async (opts: { date: string; len: number; algo?: string; step: number; result: string }) => {
|
|
70
|
+
const [{ verified }] = await pg.any(
|
|
71
|
+
`SELECT * FROM totp.verify(
|
|
72
|
+
secret := $1,
|
|
73
|
+
check_totp := $2,
|
|
74
|
+
period := $3,
|
|
75
|
+
digits := $4,
|
|
76
|
+
time_from := $5,
|
|
77
|
+
encoding := NULL
|
|
78
|
+
) as verified`,
|
|
79
|
+
['12345678901234567890', opts.result, opts.step, opts.len, opts.date]
|
|
80
|
+
);
|
|
81
|
+
expect(verified).toBe(true);
|
|
82
|
+
},
|
|
83
|
+
[
|
|
84
|
+
{ date: '1970-01-01 00:00:59', len: 6, step: 30, algo: 'sha1', result: '287082' },
|
|
85
|
+
{ date: '2005-03-18 01:58:29', len: 6, step: 30, algo: 'sha1', result: '081804' },
|
|
86
|
+
{ date: '2005-03-18 01:58:29', len: 6, step: 60, algo: 'sha1', result: '360094' },
|
|
87
|
+
{ date: '1970-01-01 00:00:59', len: 8, step: 30, algo: 'sha1', result: '94287082' },
|
|
88
|
+
{ date: '2005-03-18 01:58:29', len: 8, step: 30, algo: 'sha1', result: '07081804' },
|
|
89
|
+
{ date: '2005-03-18 01:58:31', len: 8, step: 30, algo: 'sha1', result: '14050471' },
|
|
90
|
+
{ date: '2009-02-13 23:31:30', len: 8, step: 30, algo: 'sha1', result: '89005924' },
|
|
91
|
+
{ date: '2033-05-18 03:33:20', len: 8, algo: 'sha1', step: 30, result: '69279037' },
|
|
92
|
+
{ date: '2603-10-11 11:33:20', len: 8, algo: 'sha1', step: 30, result: '65353130' }
|
|
93
|
+
]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
cases(
|
|
97
|
+
'issue',
|
|
98
|
+
async (opts: { encoding: string | null; secret: string; date: string; len: number; step: number; algo: string; result: string }) => {
|
|
99
|
+
const { generate } = await pg.one(
|
|
100
|
+
`SELECT totp.generate(
|
|
101
|
+
secret := $1,
|
|
102
|
+
period := $2,
|
|
103
|
+
digits := $3,
|
|
104
|
+
time_from := $4,
|
|
105
|
+
hash := $5,
|
|
106
|
+
encoding := $6
|
|
107
|
+
)`,
|
|
108
|
+
[opts.secret, opts.step, opts.len, opts.date, opts.algo, opts.encoding]
|
|
109
|
+
);
|
|
110
|
+
expect(generate).toEqual(opts.result);
|
|
111
|
+
expect(generate).toMatchSnapshot();
|
|
112
|
+
},
|
|
113
|
+
[
|
|
114
|
+
{
|
|
115
|
+
encoding: null,
|
|
116
|
+
secret: 'OH3NUPO3WOGOZZQ4',
|
|
117
|
+
date: '2020-11-14 07:46:37.212048+00',
|
|
118
|
+
len: 6,
|
|
119
|
+
step: 30,
|
|
120
|
+
algo: 'sha1',
|
|
121
|
+
result: '476240'
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
encoding: 'base32',
|
|
125
|
+
secret: 'OH3NUPO3WOGOZZQ4',
|
|
126
|
+
date: '2020-11-14 07:46:37.212048+00',
|
|
127
|
+
len: 6,
|
|
128
|
+
step: 30,
|
|
129
|
+
algo: 'sha1',
|
|
130
|
+
result: '788648'
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
encoding: 'base32',
|
|
134
|
+
secret: 'OH3NUPO',
|
|
135
|
+
date: '2020-11-14 07:46:37.212048+00',
|
|
136
|
+
len: 6,
|
|
137
|
+
step: 30,
|
|
138
|
+
algo: 'sha1',
|
|
139
|
+
result: '080176'
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getConnections, PgTestClient } from 'pgsql-test';
|
|
2
|
+
|
|
3
|
+
let pg: PgTestClient;
|
|
4
|
+
let teardown: () => Promise<void>;
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
({ pg, teardown } = await getConnections());
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await teardown();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
it('totp.generate + totp.verify basic', async () => {
|
|
16
|
+
const { generate } = await pg.one(
|
|
17
|
+
`SELECT totp.generate($1::text) AS generate`,
|
|
18
|
+
['secret']
|
|
19
|
+
);
|
|
20
|
+
const { verify } = await pg.one(
|
|
21
|
+
`SELECT totp.verify($1::text, $2::text) AS verify`,
|
|
22
|
+
['secret', generate]
|
|
23
|
+
);
|
|
24
|
+
expect(typeof generate).toBe('string');
|
|
25
|
+
expect(verify).toBe(true);
|
|
26
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
-- Deploy schemas/totp/procedures/generate_totp to pg
|
|
2
|
+
-- requires: schemas/totp/schema
|
|
3
|
+
-- requires: schemas/totp/procedures/urlencode
|
|
4
|
+
|
|
5
|
+
BEGIN;
|
|
6
|
+
|
|
7
|
+
-- https://www.youtube.com/watch?v=VOYxF12K1vE
|
|
8
|
+
-- https://tools.ietf.org/html/rfc6238
|
|
9
|
+
-- http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript/
|
|
10
|
+
-- https://gist.github.com/bwbroersma/676d0de32263ed554584ab132434ebd9
|
|
11
|
+
|
|
12
|
+
CREATE FUNCTION totp.pad_secret (
|
|
13
|
+
input bytea,
|
|
14
|
+
len int
|
|
15
|
+
) returns bytea as $$
|
|
16
|
+
DECLARE
|
|
17
|
+
output bytea;
|
|
18
|
+
orig_length int = octet_length(input);
|
|
19
|
+
BEGIN
|
|
20
|
+
IF (orig_length = len) THEN
|
|
21
|
+
RETURN input;
|
|
22
|
+
END IF;
|
|
23
|
+
|
|
24
|
+
-- create blank bytea size of new length
|
|
25
|
+
output = lpad('', len, 'x')::bytea;
|
|
26
|
+
|
|
27
|
+
FOR i IN 0 .. len-1 LOOP
|
|
28
|
+
output = set_byte(output, i, get_byte(input, i % orig_length));
|
|
29
|
+
END LOOP;
|
|
30
|
+
|
|
31
|
+
RETURN output;
|
|
32
|
+
END;
|
|
33
|
+
$$
|
|
34
|
+
LANGUAGE 'plpgsql' IMMUTABLE;
|
|
35
|
+
|
|
36
|
+
CREATE FUNCTION totp.base32_to_hex (
|
|
37
|
+
input text
|
|
38
|
+
) returns text as $$
|
|
39
|
+
DECLARE
|
|
40
|
+
output text[];
|
|
41
|
+
decoded text = base32.decode(input);
|
|
42
|
+
len int = character_length(decoded);
|
|
43
|
+
hx text;
|
|
44
|
+
BEGIN
|
|
45
|
+
|
|
46
|
+
FOR i IN 1 .. len LOOP
|
|
47
|
+
hx = to_hex(ascii(substring(decoded from i for 1)))::text;
|
|
48
|
+
IF (character_length(hx) = 1) THEN
|
|
49
|
+
-- if it is odd number of digits, pad a 0 so it can later
|
|
50
|
+
hx = '0' || hx;
|
|
51
|
+
END IF;
|
|
52
|
+
output = array_append(output, hx);
|
|
53
|
+
END LOOP;
|
|
54
|
+
|
|
55
|
+
RETURN array_to_string(output, '');
|
|
56
|
+
END;
|
|
57
|
+
$$
|
|
58
|
+
LANGUAGE 'plpgsql' IMMUTABLE;
|
|
59
|
+
|
|
60
|
+
CREATE FUNCTION totp.hotp(key BYTEA, c INT, digits INT DEFAULT 6, hash TEXT DEFAULT 'sha1') RETURNS TEXT AS $$
|
|
61
|
+
DECLARE
|
|
62
|
+
c BYTEA := '\x' || LPAD(TO_HEX(c), 16, '0');
|
|
63
|
+
mac BYTEA := HMAC(c, key, hash);
|
|
64
|
+
trunc_offset INT := GET_BYTE(mac, length(mac) - 1) % 16;
|
|
65
|
+
result TEXT := SUBSTRING(SET_BIT(SUBSTRING(mac FROM 1 + trunc_offset FOR 4), 7, 0)::TEXT, 2)::BIT(32)::INT % (10 ^ digits)::INT;
|
|
66
|
+
BEGIN
|
|
67
|
+
RETURN LPAD(result, digits, '0');
|
|
68
|
+
END;
|
|
69
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
70
|
+
|
|
71
|
+
CREATE FUNCTION totp.generate(
|
|
72
|
+
secret text,
|
|
73
|
+
period int DEFAULT 30,
|
|
74
|
+
digits int DEFAULT 6,
|
|
75
|
+
time_from timestamptz DEFAULT NOW(),
|
|
76
|
+
hash text DEFAULT 'sha1',
|
|
77
|
+
encoding text DEFAULT 'base32',
|
|
78
|
+
clock_offset int DEFAULT 0
|
|
79
|
+
) RETURNS text AS $$
|
|
80
|
+
DECLARE
|
|
81
|
+
c int := FLOOR(EXTRACT(EPOCH FROM time_from) / period)::int + clock_offset;
|
|
82
|
+
key bytea;
|
|
83
|
+
BEGIN
|
|
84
|
+
|
|
85
|
+
IF (encoding = 'base32') THEN
|
|
86
|
+
key = ( '\x' || totp.base32_to_hex(secret) )::bytea;
|
|
87
|
+
ELSE
|
|
88
|
+
key = secret::bytea;
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
RETURN totp.hotp(key, c, digits, hash);
|
|
92
|
+
END;
|
|
93
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
94
|
+
|
|
95
|
+
-- Mitigate timing attacks by using constant-time comparison.
|
|
96
|
+
-- Mitigates timing attacks by avoiding early-exit and content-dependent work; compares full byte sequences in a length-oblivious loop.
|
|
97
|
+
-- Context: HN discussion on TOTP '=' comparison timing leaks: https://news.ycombinator.com/item?id=26260667
|
|
98
|
+
|
|
99
|
+
-- Context: https://news.ycombinator.com/item?id=26260667
|
|
100
|
+
|
|
101
|
+
CREATE FUNCTION totp.timing_safe_equals(a bytea, b bytea)
|
|
102
|
+
RETURNS boolean
|
|
103
|
+
AS $$
|
|
104
|
+
DECLARE
|
|
105
|
+
la int := length(a);
|
|
106
|
+
lb int := length(b);
|
|
107
|
+
maxlen int := GREATEST(la, lb);
|
|
108
|
+
i int;
|
|
109
|
+
diff int := la # lb;
|
|
110
|
+
ca int;
|
|
111
|
+
cb int;
|
|
112
|
+
BEGIN
|
|
113
|
+
FOR i IN 0..(maxlen - 1) LOOP
|
|
114
|
+
ca := CASE WHEN i < la THEN get_byte(a, i) ELSE 0 END;
|
|
115
|
+
cb := CASE WHEN i < lb THEN get_byte(b, i) ELSE 0 END;
|
|
116
|
+
diff := diff | (ca # cb);
|
|
117
|
+
END LOOP;
|
|
118
|
+
RETURN diff = 0;
|
|
119
|
+
END;
|
|
120
|
+
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
|
|
121
|
+
|
|
122
|
+
CREATE FUNCTION totp.timing_safe_equals(a text, b text)
|
|
123
|
+
RETURNS boolean
|
|
124
|
+
AS $$
|
|
125
|
+
-- Verify uses timing-safe equality to avoid leaking mismatch position via timing; do not use direct '=' here.
|
|
126
|
+
-- See HN discussion for background: https://news.ycombinator.com/item?id=26260667
|
|
127
|
+
|
|
128
|
+
SELECT totp.timing_safe_equals(convert_to(a, 'UTF8'), convert_to(b, 'UTF8'));
|
|
129
|
+
$$ LANGUAGE sql IMMUTABLE STRICT;
|
|
130
|
+
|
|
131
|
+
CREATE FUNCTION totp.verify (
|
|
132
|
+
secret text,
|
|
133
|
+
check_totp text,
|
|
134
|
+
period int default 30,
|
|
135
|
+
digits int default 6,
|
|
136
|
+
time_from timestamptz DEFAULT NOW(),
|
|
137
|
+
hash text default 'sha1',
|
|
138
|
+
encoding text DEFAULT 'base32',
|
|
139
|
+
clock_offset int default 0
|
|
140
|
+
)
|
|
141
|
+
RETURNS boolean
|
|
142
|
+
AS $$
|
|
143
|
+
SELECT totp.timing_safe_equals(
|
|
144
|
+
totp.generate(
|
|
145
|
+
secret,
|
|
146
|
+
period,
|
|
147
|
+
digits,
|
|
148
|
+
time_from,
|
|
149
|
+
hash,
|
|
150
|
+
encoding,
|
|
151
|
+
clock_offset
|
|
152
|
+
),
|
|
153
|
+
check_totp
|
|
154
|
+
);
|
|
155
|
+
$$
|
|
156
|
+
LANGUAGE 'sql';
|
|
157
|
+
|
|
158
|
+
CREATE FUNCTION totp.url (email text, totp_secret text, totp_interval int, totp_issuer text)
|
|
159
|
+
RETURNS text
|
|
160
|
+
AS $$
|
|
161
|
+
SELECT
|
|
162
|
+
concat('otpauth://totp/', totp.urlencode (email), '?secret=', totp.urlencode (totp_secret), '&period=', totp.urlencode (totp_interval::text), '&issuer=', totp.urlencode (totp_issuer));
|
|
163
|
+
$$
|
|
164
|
+
LANGUAGE 'sql'
|
|
165
|
+
STRICT IMMUTABLE;
|
|
166
|
+
|
|
167
|
+
COMMIT;
|
|
168
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Uses pgcrypto's gen_random_bytes for cryptographically secure randomness; random() is not suitable for secrets. Preserves RFC 4648 base32 alphabet output.
|
|
2
|
+
|
|
3
|
+
-- Deploy schemas/totp/procedures/random_base32 to pg
|
|
4
|
+
-- requires: schemas/totp/schema
|
|
5
|
+
|
|
6
|
+
BEGIN;
|
|
7
|
+
|
|
8
|
+
CREATE FUNCTION totp.random_base32 (_length int DEFAULT 20)
|
|
9
|
+
RETURNS text
|
|
10
|
+
LANGUAGE sql
|
|
11
|
+
AS $$
|
|
12
|
+
SELECT
|
|
13
|
+
string_agg(
|
|
14
|
+
('{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,2,3,4,5,6,7}'::text[])
|
|
15
|
+
[ (get_byte(b, i) % 32) + 1 ],
|
|
16
|
+
''
|
|
17
|
+
)
|
|
18
|
+
FROM (SELECT gen_random_bytes(_length) AS b) t,
|
|
19
|
+
LATERAL generate_series(0, _length - 1) g(i);
|
|
20
|
+
$$;
|
|
21
|
+
|
|
22
|
+
CREATE FUNCTION totp.generate_secret(hash TEXT DEFAULT 'sha1') RETURNS BYTEA AS $$
|
|
23
|
+
BEGIN
|
|
24
|
+
-- See https://tools.ietf.org/html/rfc4868#section-2.1.2
|
|
25
|
+
-- The optimal key length for HMAC is the block size of the algorithm
|
|
26
|
+
CASE
|
|
27
|
+
WHEN hash = 'sha1' THEN RETURN totp.random_base32(20); -- = 160 bits
|
|
28
|
+
WHEN hash = 'sha256' THEN RETURN totp.random_base32(32); -- = 256 bits
|
|
29
|
+
WHEN hash = 'sha512' THEN RETURN totp.random_base32(64); -- = 512 bits
|
|
30
|
+
ELSE
|
|
31
|
+
RAISE EXCEPTION 'Unsupported hash algorithm for OTP (see RFC6238/4226).';
|
|
32
|
+
RETURN NULL;
|
|
33
|
+
END CASE;
|
|
34
|
+
END;
|
|
35
|
+
$$ LANGUAGE plpgsql VOLATILE;
|
|
36
|
+
|
|
37
|
+
COMMIT;
|
|
38
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Deploy schemas/totp/procedures/urlencode to pg
|
|
2
|
+
-- requires: schemas/totp/schema
|
|
3
|
+
|
|
4
|
+
-- https://stackoverflow.com/questions/10318014/javascript-encodeuri-like-function-in-postgresql/40762846
|
|
5
|
+
BEGIN;
|
|
6
|
+
CREATE FUNCTION totp.urlencode (in_str text)
|
|
7
|
+
RETURNS text
|
|
8
|
+
AS $$
|
|
9
|
+
DECLARE
|
|
10
|
+
_i int4;
|
|
11
|
+
_temp varchar;
|
|
12
|
+
_ascii int4;
|
|
13
|
+
_result text := '';
|
|
14
|
+
BEGIN
|
|
15
|
+
FOR _i IN 1..length(in_str)
|
|
16
|
+
LOOP
|
|
17
|
+
_temp := substr(in_str, _i, 1);
|
|
18
|
+
IF _temp ~ '[0-9a-zA-Z:/@._?#-]+' THEN
|
|
19
|
+
_result := _result || _temp;
|
|
20
|
+
ELSE
|
|
21
|
+
_ascii := ascii(_temp);
|
|
22
|
+
IF _ascii > x'07ff'::int4 THEN
|
|
23
|
+
RAISE exception 'won''t deal with 3 (or more) byte sequences.';
|
|
24
|
+
END IF;
|
|
25
|
+
IF _ascii <= x'07f'::int4 THEN
|
|
26
|
+
_temp := '%' || to_hex(_ascii);
|
|
27
|
+
ELSE
|
|
28
|
+
_temp := '%' || to_hex((_ascii & x'03f'::int4) + x'80'::int4);
|
|
29
|
+
_ascii := _ascii >> 6;
|
|
30
|
+
_temp := '%' || to_hex((_ascii & x'01f'::int4) + x'c0'::int4) || _temp;
|
|
31
|
+
END IF;
|
|
32
|
+
_result := _result || upper(_temp);
|
|
33
|
+
END IF;
|
|
34
|
+
END LOOP;
|
|
35
|
+
RETURN _result;
|
|
36
|
+
END;
|
|
37
|
+
$$
|
|
38
|
+
LANGUAGE 'plpgsql'
|
|
39
|
+
STRICT IMMUTABLE
|
|
40
|
+
;
|
|
41
|
+
|
|
42
|
+
COMMIT;
|
|
43
|
+
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
|
|
6
|
+
// Match both __tests__ and colocated test files
|
|
7
|
+
testMatch: ['**/?(*.)+(test|spec).{ts,tsx,js,jsx}'],
|
|
8
|
+
|
|
9
|
+
// Ignore build artifacts and type declarations
|
|
10
|
+
testPathIgnorePatterns: ['/dist/', '\\.d\\.ts$'],
|
|
11
|
+
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
|
12
|
+
watchPathIgnorePatterns: ['/dist/'],
|
|
13
|
+
|
|
14
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
15
|
+
};
|
package/launchql.plan
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
%syntax-version=1.0.0
|
|
2
|
+
%project=launchql-totp
|
|
3
|
+
%uri=launchql-totp
|
|
4
|
+
|
|
5
|
+
schemas/totp/schema 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/schema
|
|
6
|
+
schemas/totp/procedures/urlencode [schemas/totp/schema] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/urlencode
|
|
7
|
+
schemas/totp/procedures/generate_totp [schemas/totp/schema schemas/totp/procedures/urlencode] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/generate_totp
|
|
8
|
+
schemas/totp/procedures/random_base32 [schemas/totp/schema] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/random_base32
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pgpm/totp",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Time-based One-Time Password (TOTP) authentication",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"bundle": "lql package",
|
|
10
|
+
"test": "jest",
|
|
11
|
+
"test:watch": "jest --watch"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@pgpm/base32": "0.4.0",
|
|
15
|
+
"@pgpm/verify": "0.4.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@launchql/cli": "^4.9.0"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/launchql/extensions"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/launchql/extensions",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/launchql/extensions/issues"
|
|
27
|
+
},
|
|
28
|
+
"gitHead": "cc9f52a335caa6e21ee7751b04b77c84ce6cb809"
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Revert schemas/totp/procedures/generate_totp from pg
|
|
2
|
+
|
|
3
|
+
BEGIN;
|
|
4
|
+
|
|
5
|
+
DROP FUNCTION totp.url;
|
|
6
|
+
DROP FUNCTION totp.verify;
|
|
7
|
+
DROP FUNCTION totp.timing_safe_equals(a text, b text);
|
|
8
|
+
DROP FUNCTION totp.timing_safe_equals(a bytea, b bytea);
|
|
9
|
+
DROP FUNCTION totp.generate;
|
|
10
|
+
DROP FUNCTION totp.hotp;
|
|
11
|
+
DROP FUNCTION totp.base32_to_hex;
|
|
12
|
+
DROP FUNCTION totp.pad_secret;
|
|
13
|
+
|
|
14
|
+
COMMIT;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
\echo Use "CREATE EXTENSION launchql-totp" to load this file. \quit
|
|
2
|
+
CREATE SCHEMA totp;
|
|
3
|
+
|
|
4
|
+
CREATE FUNCTION totp.urlencode(in_str text) RETURNS text AS $EOFCODE$
|
|
5
|
+
DECLARE
|
|
6
|
+
_i int4;
|
|
7
|
+
_temp varchar;
|
|
8
|
+
_ascii int4;
|
|
9
|
+
_result text := '';
|
|
10
|
+
BEGIN
|
|
11
|
+
FOR _i IN 1..length(in_str)
|
|
12
|
+
LOOP
|
|
13
|
+
_temp := substr(in_str, _i, 1);
|
|
14
|
+
IF _temp ~ '[0-9a-zA-Z:/@._?#-]+' THEN
|
|
15
|
+
_result := _result || _temp;
|
|
16
|
+
ELSE
|
|
17
|
+
_ascii := ascii(_temp);
|
|
18
|
+
IF _ascii > x'07ff'::int4 THEN
|
|
19
|
+
RAISE exception 'won''t deal with 3 (or more) byte sequences.';
|
|
20
|
+
END IF;
|
|
21
|
+
IF _ascii <= x'07f'::int4 THEN
|
|
22
|
+
_temp := '%' || to_hex(_ascii);
|
|
23
|
+
ELSE
|
|
24
|
+
_temp := '%' || to_hex((_ascii & x'03f'::int4) + x'80'::int4);
|
|
25
|
+
_ascii := _ascii >> 6;
|
|
26
|
+
_temp := '%' || to_hex((_ascii & x'01f'::int4) + x'c0'::int4) || _temp;
|
|
27
|
+
END IF;
|
|
28
|
+
_result := _result || upper(_temp);
|
|
29
|
+
END IF;
|
|
30
|
+
END LOOP;
|
|
31
|
+
RETURN _result;
|
|
32
|
+
END;
|
|
33
|
+
$EOFCODE$ LANGUAGE plpgsql STRICT IMMUTABLE;
|
|
34
|
+
|
|
35
|
+
CREATE FUNCTION totp.pad_secret(input bytea, len int) RETURNS bytea AS $EOFCODE$
|
|
36
|
+
DECLARE
|
|
37
|
+
output bytea;
|
|
38
|
+
orig_length int = octet_length(input);
|
|
39
|
+
BEGIN
|
|
40
|
+
IF (orig_length = len) THEN
|
|
41
|
+
RETURN input;
|
|
42
|
+
END IF;
|
|
43
|
+
|
|
44
|
+
-- create blank bytea size of new length
|
|
45
|
+
output = lpad('', len, 'x')::bytea;
|
|
46
|
+
|
|
47
|
+
FOR i IN 0 .. len-1 LOOP
|
|
48
|
+
output = set_byte(output, i, get_byte(input, i % orig_length));
|
|
49
|
+
END LOOP;
|
|
50
|
+
|
|
51
|
+
RETURN output;
|
|
52
|
+
END;
|
|
53
|
+
$EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
|
|
54
|
+
|
|
55
|
+
CREATE FUNCTION totp.base32_to_hex(input text) RETURNS text AS $EOFCODE$
|
|
56
|
+
DECLARE
|
|
57
|
+
output text[];
|
|
58
|
+
decoded text = base32.decode(input);
|
|
59
|
+
len int = character_length(decoded);
|
|
60
|
+
hx text;
|
|
61
|
+
BEGIN
|
|
62
|
+
|
|
63
|
+
FOR i IN 1 .. len LOOP
|
|
64
|
+
hx = to_hex(ascii(substring(decoded from i for 1)))::text;
|
|
65
|
+
IF (character_length(hx) = 1) THEN
|
|
66
|
+
-- if it is odd number of digits, pad a 0 so it can later
|
|
67
|
+
hx = '0' || hx;
|
|
68
|
+
END IF;
|
|
69
|
+
output = array_append(output, hx);
|
|
70
|
+
END LOOP;
|
|
71
|
+
|
|
72
|
+
RETURN array_to_string(output, '');
|
|
73
|
+
END;
|
|
74
|
+
$EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
|
|
75
|
+
|
|
76
|
+
CREATE FUNCTION totp.hotp(key bytea, c int, digits int DEFAULT 6, hash text DEFAULT 'sha1') RETURNS text AS $EOFCODE$
|
|
77
|
+
DECLARE
|
|
78
|
+
c BYTEA := '\x' || LPAD(TO_HEX(c), 16, '0');
|
|
79
|
+
mac BYTEA := HMAC(c, key, hash);
|
|
80
|
+
trunc_offset INT := GET_BYTE(mac, length(mac) - 1) % 16;
|
|
81
|
+
result TEXT := SUBSTRING(SET_BIT(SUBSTRING(mac FROM 1 + trunc_offset FOR 4), 7, 0)::TEXT, 2)::BIT(32)::INT % (10 ^ digits)::INT;
|
|
82
|
+
BEGIN
|
|
83
|
+
RETURN LPAD(result, digits, '0');
|
|
84
|
+
END;
|
|
85
|
+
$EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
|
|
86
|
+
|
|
87
|
+
CREATE FUNCTION totp.generate(secret text, period int DEFAULT 30, digits int DEFAULT 6, time_from timestamptz DEFAULT now(), hash text DEFAULT 'sha1', encoding text DEFAULT 'base32', clock_offset int DEFAULT 0) RETURNS text AS $EOFCODE$
|
|
88
|
+
DECLARE
|
|
89
|
+
c int := FLOOR(EXTRACT(EPOCH FROM time_from) / period)::int + clock_offset;
|
|
90
|
+
key bytea;
|
|
91
|
+
BEGIN
|
|
92
|
+
|
|
93
|
+
IF (encoding = 'base32') THEN
|
|
94
|
+
key = ( '\x' || totp.base32_to_hex(secret) )::bytea;
|
|
95
|
+
ELSE
|
|
96
|
+
key = secret::bytea;
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
RETURN totp.hotp(key, c, digits, hash);
|
|
100
|
+
END;
|
|
101
|
+
$EOFCODE$ LANGUAGE plpgsql STABLE;
|
|
102
|
+
|
|
103
|
+
CREATE FUNCTION totp.timing_safe_equals(a bytea, b bytea) RETURNS boolean AS $EOFCODE$
|
|
104
|
+
DECLARE
|
|
105
|
+
la int := length(a);
|
|
106
|
+
lb int := length(b);
|
|
107
|
+
maxlen int := GREATEST(la, lb);
|
|
108
|
+
i int;
|
|
109
|
+
diff int := la # lb;
|
|
110
|
+
ca int;
|
|
111
|
+
cb int;
|
|
112
|
+
BEGIN
|
|
113
|
+
FOR i IN 0..(maxlen - 1) LOOP
|
|
114
|
+
ca := CASE WHEN i < la THEN get_byte(a, i) ELSE 0 END;
|
|
115
|
+
cb := CASE WHEN i < lb THEN get_byte(b, i) ELSE 0 END;
|
|
116
|
+
diff := diff | (ca # cb);
|
|
117
|
+
END LOOP;
|
|
118
|
+
RETURN diff = 0;
|
|
119
|
+
END;
|
|
120
|
+
$EOFCODE$ LANGUAGE plpgsql IMMUTABLE STRICT;
|
|
121
|
+
|
|
122
|
+
CREATE FUNCTION totp.timing_safe_equals(a text, b text) RETURNS boolean AS $EOFCODE$
|
|
123
|
+
-- Verify uses timing-safe equality to avoid leaking mismatch position via timing; do not use direct '=' here.
|
|
124
|
+
-- See HN discussion for background: https://news.ycombinator.com/item?id=26260667
|
|
125
|
+
|
|
126
|
+
SELECT totp.timing_safe_equals(convert_to(a, 'UTF8'), convert_to(b, 'UTF8'));
|
|
127
|
+
$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT;
|
|
128
|
+
|
|
129
|
+
CREATE FUNCTION totp.verify(secret text, check_totp text, period int DEFAULT 30, digits int DEFAULT 6, time_from timestamptz DEFAULT now(), hash text DEFAULT 'sha1', encoding text DEFAULT 'base32', clock_offset int DEFAULT 0) RETURNS boolean AS $EOFCODE$
|
|
130
|
+
SELECT totp.timing_safe_equals(
|
|
131
|
+
totp.generate(
|
|
132
|
+
secret,
|
|
133
|
+
period,
|
|
134
|
+
digits,
|
|
135
|
+
time_from,
|
|
136
|
+
hash,
|
|
137
|
+
encoding,
|
|
138
|
+
clock_offset
|
|
139
|
+
),
|
|
140
|
+
check_totp
|
|
141
|
+
);
|
|
142
|
+
$EOFCODE$ LANGUAGE sql;
|
|
143
|
+
|
|
144
|
+
CREATE FUNCTION totp.url(email text, totp_secret text, totp_interval int, totp_issuer text) RETURNS text AS $EOFCODE$
|
|
145
|
+
SELECT
|
|
146
|
+
concat('otpauth://totp/', totp.urlencode (email), '?secret=', totp.urlencode (totp_secret), '&period=', totp.urlencode (totp_interval::text), '&issuer=', totp.urlencode (totp_issuer));
|
|
147
|
+
$EOFCODE$ LANGUAGE sql STRICT IMMUTABLE;
|
|
148
|
+
|
|
149
|
+
CREATE FUNCTION totp.random_base32(_length int DEFAULT 20) RETURNS text LANGUAGE sql AS $EOFCODE$
|
|
150
|
+
SELECT
|
|
151
|
+
string_agg(
|
|
152
|
+
('{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,2,3,4,5,6,7}'::text[])
|
|
153
|
+
[ (get_byte(b, i) % 32) + 1 ],
|
|
154
|
+
''
|
|
155
|
+
)
|
|
156
|
+
FROM (SELECT gen_random_bytes(_length) AS b) t,
|
|
157
|
+
LATERAL generate_series(0, _length - 1) g(i);
|
|
158
|
+
$EOFCODE$;
|
|
159
|
+
|
|
160
|
+
CREATE FUNCTION totp.generate_secret(hash text DEFAULT 'sha1') RETURNS bytea AS $EOFCODE$
|
|
161
|
+
BEGIN
|
|
162
|
+
-- See https://tools.ietf.org/html/rfc4868#section-2.1.2
|
|
163
|
+
-- The optimal key length for HMAC is the block size of the algorithm
|
|
164
|
+
CASE
|
|
165
|
+
WHEN hash = 'sha1' THEN RETURN totp.random_base32(20); -- = 160 bits
|
|
166
|
+
WHEN hash = 'sha256' THEN RETURN totp.random_base32(32); -- = 256 bits
|
|
167
|
+
WHEN hash = 'sha512' THEN RETURN totp.random_base32(64); -- = 512 bits
|
|
168
|
+
ELSE
|
|
169
|
+
RAISE EXCEPTION 'Unsupported hash algorithm for OTP (see RFC6238/4226).';
|
|
170
|
+
RETURN NULL;
|
|
171
|
+
END CASE;
|
|
172
|
+
END;
|
|
173
|
+
$EOFCODE$ LANGUAGE plpgsql VOLATILE;
|