Skip to content

Commit 9a18196

Browse files
committed
Support encoding and decoding with PHC string format specification
- New Class : `PHCSF` - New Constant: `phcsf` - New Methods : `toPHCSF`, `fromPHCSF`
1 parent 67b0d0e commit 9a18196

File tree

8 files changed

+356
-1
lines changed

8 files changed

+356
-1
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# 2.2.0
2+
3+
- Support encoding and decoding with [PHC string format specification](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md)
4+
- New Class : `PHCSF`
5+
- New Constant: `phcsf`
6+
- New Methods : `toPHCSF`, `fromPHCSF`
7+
18
# 2.1.1
29

310
- Adds new alphabet to `Base64Codec`: [bcrypt](https://en.wikipedia.org/wiki/Bcrypt#base64_encoding_alphabet)

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ Available codecs:
9696
- **msbFirst**: treats the input bytes in big-endian order
9797
- **lsbFirst**: treats the input bytes in little-endian order
9898

99+
### PHC String Format
100+
101+
> Encoding and Decoding of Hash algorithm output according to the
102+
> [PHC string format specification](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md).
103+
104+
| Type | Available |
105+
| -------- | ---------------------- |
106+
| Class | `PHCSF` |
107+
| Constant | `phcsf` |
108+
| Methods | `toPHCSF`, `fromPHCSF` |
109+
99110
## Getting Started
100111

101112
The following import will give you access to all of the algorithms in this package.

lib/src/codecs/phc_sf.dart

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2023, Sudipto Chandra
2+
// All rights reserved. Check LICENSE file for details.
3+
4+
import 'dart:convert';
5+
6+
import 'package:hashlib_codecs/hashlib_codecs.dart';
7+
8+
final _id = RegExp(r'^[a-z0-9-]+$');
9+
final _version = RegExp(r'^[0-9]+$');
10+
final _paramName = RegExp(r'^[a-z0-9-]+$');
11+
final _paramValue = RegExp(r'^[a-zA-Z0-9/+.-]+$');
12+
13+
/// The PHC string format data
14+
class PHCSFData {
15+
/// The symbolic name for the hash function.
16+
///
17+
/// The identifier name must not exceed 32 characters in length and must be a
18+
/// sequence of characters in: `[a-z0-9-]`.
19+
///
20+
/// Good identifiers should be should be explicit (human readable, not a
21+
/// single digit), with a length of about 5 to 10 characters.
22+
final String id;
23+
24+
/// (Optional) The algorithm version.
25+
///
26+
/// The value for the version must be a sequence of characters in: `[0-9]`.
27+
///
28+
/// It recommended to use a default version.
29+
final String? version;
30+
31+
/// (Optional) The salt bytes.
32+
final List<int>? salt;
33+
34+
/// (Optional) The output hash bytes.
35+
final List<int>? hash;
36+
37+
/// (Optional) The algorithm parameters.
38+
///
39+
/// The parameter names must not exceed 32 characters in length and must be a
40+
/// sequence of characters in: `[a-z0-9-]`.
41+
///
42+
/// The parameter values must be a sequence of characters in
43+
/// `[a-zA-Z0-9/+.-]`.
44+
final Map<String, String>? params;
45+
46+
/// Creates an instance of [PHCSFData].
47+
///
48+
/// Paramaters:
49+
/// - [id] The identifier name, must not exceed 32 characters in length and
50+
/// must be a sequence of characters in: `[a-z0-9-]`.
51+
/// - [version] (Optional) The value for the version must be a sequence of
52+
/// characters in: `[0-9]`.
53+
/// - [params] (Optional) A map containing name, value pairs of algorithm
54+
/// parameters. The names must not exceed 32 characters in length and must
55+
/// be a sequence of characters in: `[a-z0-9-]`, the values must be a
56+
/// sequence of characters in: `[a-zA-Z0-9/+.-]`.
57+
/// - [salt] (Optional) The salt bytes.
58+
/// - [hash] (Optional) The output hash bytes.
59+
const PHCSFData(
60+
this.id, {
61+
this.salt,
62+
this.hash,
63+
this.version,
64+
this.params,
65+
});
66+
67+
/// Validate the parameters
68+
///
69+
/// Throws [ArgumentError] if something is wrong.
70+
void validate() {
71+
if (id.length > 32) {
72+
throw ArgumentError('Exceeds 32 character limit', 'id');
73+
}
74+
if (!_id.hasMatch(id)) {
75+
throw ArgumentError('Invalid character', 'id');
76+
}
77+
if (version != null && version!.isNotEmpty) {
78+
if (!_version.hasMatch(version!)) {
79+
throw ArgumentError('Invalid character', 'version');
80+
}
81+
}
82+
if (params != null) {
83+
for (final e in params!.entries) {
84+
if (e.key.length > 32) {
85+
throw ArgumentError('Exceeds 32 character limit', 'params:${e.key}');
86+
}
87+
if (!_paramName.hasMatch(e.key)) {
88+
throw ArgumentError('Invalid character', 'params:${e.key}');
89+
}
90+
if (!_paramValue.hasMatch(e.value)) {
91+
throw ArgumentError('Invalid character', 'params:${e.key}:value');
92+
}
93+
}
94+
}
95+
}
96+
}
97+
98+
/// The encoder used by the [PHCSF] codec
99+
class PHCSFEncoder extends Converter<PHCSFData, String> {
100+
const PHCSFEncoder();
101+
102+
@override
103+
String convert(PHCSFData input) {
104+
input.validate();
105+
String result = '\$${input.id}';
106+
if (input.version != null && input.version!.isNotEmpty) {
107+
result += '\$v=${input.version!}';
108+
}
109+
if (input.params != null && input.params!.isNotEmpty) {
110+
result += '\$';
111+
result += input.params!.entries
112+
.map((entry) => '${entry.key}=${entry.value}')
113+
.join(',');
114+
}
115+
if (input.salt != null && input.salt!.isNotEmpty) {
116+
result += '\$';
117+
result += toBase64(input.salt!, padding: false);
118+
}
119+
if (input.hash != null && input.hash!.isNotEmpty) {
120+
result += '\$';
121+
result += toBase64(input.hash!, padding: false);
122+
}
123+
return result;
124+
}
125+
}
126+
127+
/// The decoder used by the [PHCSF] codec
128+
class PHCSFDecoder extends Converter<String, PHCSFData> {
129+
const PHCSFDecoder();
130+
131+
@override
132+
PHCSFData convert(String input) {
133+
String id;
134+
String? version;
135+
List<int>? salt, hash;
136+
Map<String, String>? params;
137+
String name, value;
138+
139+
Iterable<String> parts = input.split('\$');
140+
141+
if (parts.isEmpty) {
142+
throw FormatException('Empty string');
143+
}
144+
parts = parts.skip(1);
145+
146+
if (parts.isEmpty) {
147+
throw FormatException('Invalid PHC string format');
148+
}
149+
id = parts.first;
150+
151+
if (!_id.hasMatch(id) || id.length > 32) {
152+
throw FormatException('Invalid identifier name');
153+
}
154+
parts = parts.skip(1);
155+
156+
if (parts.isNotEmpty && parts.first.startsWith('v=')) {
157+
version = parts.first.substring(2);
158+
if (!_version.hasMatch(version)) {
159+
throw FormatException('Invalid version');
160+
}
161+
parts = parts.skip(1);
162+
}
163+
164+
if (parts.isNotEmpty && parts.first.contains('=')) {
165+
params = {};
166+
for (final kv in parts.first.split(',')) {
167+
var pair = kv.split('=');
168+
if (pair.length != 2) {
169+
throw FormatException('Invalid param format: $kv');
170+
}
171+
name = pair[0];
172+
value = pair[1];
173+
if (name.length > 32 || !_paramName.hasMatch(name)) {
174+
throw FormatException('Invalid param name: $name');
175+
}
176+
if (!_paramValue.hasMatch(value)) {
177+
throw FormatException('Invalid value for param $name: $value');
178+
}
179+
params[name] = value;
180+
}
181+
parts = parts.skip(1);
182+
}
183+
184+
if (parts.isNotEmpty) {
185+
salt = fromBase64(parts.first, padding: false);
186+
parts = parts.skip(1);
187+
}
188+
189+
if (parts.isNotEmpty) {
190+
hash = fromBase64(parts.first, padding: false);
191+
parts = parts.skip(1);
192+
}
193+
194+
if (parts.isNotEmpty) {
195+
throw FormatException('Invalid PHC string format');
196+
}
197+
198+
return PHCSFData(
199+
id,
200+
version: version,
201+
salt: salt,
202+
hash: hash,
203+
params: params,
204+
);
205+
}
206+
}
207+
208+
/// _PHC string format_ is a standardized way to represent password hashes
209+
/// generated by the competing password hashing algorithms. This format is
210+
/// designed to ensure consistency and interoperability between different
211+
/// password hashing implementations.
212+
///
213+
/// The string format specifiction:
214+
/// ```
215+
/// $<id>[$v=<version>][$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]
216+
/// ```
217+
class PHCSF extends Codec<PHCSFData, String> {
218+
const PHCSF();
219+
220+
@override
221+
final encoder = const PHCSFEncoder();
222+
223+
@override
224+
final decoder = const PHCSFDecoder();
225+
}

lib/src/codecs_base.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export 'base32.dart';
77
export 'base64.dart';
88
export 'base8.dart';
99
export 'bigint.dart';
10+
export 'phc_sf.dart';
1011
export 'codecs/base16.dart';
1112
export 'codecs/base2.dart';
1213
export 'codecs/base32.dart';

lib/src/phc_sf.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) 2023, Sudipto Chandra
2+
// All rights reserved. Check LICENSE file for details.
3+
4+
import 'codecs/phc_sf.dart';
5+
6+
export 'codecs/phc_sf.dart';
7+
8+
/// An instance of [PHCSF] for encoding and decoding hash algorithm output with
9+
/// [PHC string format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md)
10+
const phcsf = PHCSF();
11+
12+
/// Encodes a hash algorithm output to string following PHC string format.
13+
String toPHCSF(PHCSFData input) {
14+
return phcsf.encoder.convert(input);
15+
}
16+
17+
/// Decodes a string to an hash algorithm config following PHC string format.
18+
PHCSFData fromPHCSF(String input) {
19+
return phcsf.decoder.convert(input);
20+
}

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: hashlib_codecs
22
description: Fast and error resilient codecs. Currently supporting Binary(Base2), Hexadecimal(Base16), Base32, Base64, BigInt.
33
homepage: https://github.com/bitanon/hashlib_codecs
4-
version: 2.1.1
4+
version: 2.2.0
55

66
environment:
77
sdk: '>=2.14.0 <4.0.0'

test/base64_test.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ void main() {
139139
}
140140
});
141141
});
142+
test("decoding with PHC string format B64 (16 bytes)", () {
143+
var inp = "gZiV/M1gPc22ElAH/Jh1Hw";
144+
var out = fromBase64(inp, padding: false);
145+
var res = toBase64(out, padding: false);
146+
expect(res, inp);
147+
// String "CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
148+
});
149+
test("decoding with PHC string format B64 (32 bytes)", () {
150+
var inp = "CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno";
151+
var out = fromBase64(inp, padding: false);
152+
var res = toBase64(out, padding: false);
153+
expect(res, inp);
154+
});
142155
group('decoding with invalid length', () {
143156
test('H', () {
144157
expect(() => fromBase64("H"), throwsFormatException);

test/phc_sf_test.dart

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2023, Sudipto Chandra
2+
// All rights reserved. Check LICENSE file for details.
3+
4+
import 'package:hashlib_codecs/hashlib_codecs.dart';
5+
import 'package:test/test.dart';
6+
7+
void main() {
8+
group('Test PHC String Format', () {
9+
test('full format', () {
10+
var v =
11+
r"$argon2id$v=19$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno";
12+
expect(toPHCSF(fromPHCSF(v)), equals(v));
13+
});
14+
test('without version', () {
15+
var v =
16+
r"$argon2id$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno";
17+
expect(toPHCSF(fromPHCSF(v)), equals(v));
18+
});
19+
test('without params', () {
20+
var v =
21+
r"$argon2id$v=19$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno";
22+
expect(toPHCSF(fromPHCSF(v)), equals(v));
23+
});
24+
test('without hash', () {
25+
String v = r"$argon2id$v=19$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw";
26+
expect(toPHCSF(fromPHCSF(v)), equals(v));
27+
});
28+
test('without salt and hash', () {
29+
String v = r"$argon2id$v=19$m=65536,t=2,p=1";
30+
expect(toPHCSF(fromPHCSF(v)), equals(v));
31+
});
32+
test('without version and params', () {
33+
var v =
34+
r"$argon2id$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno";
35+
expect(toPHCSF(fromPHCSF(v)), equals(v));
36+
});
37+
test('without version, params and hash', () {
38+
var v = r"$argon2id$gZiV/M1gPc22ElAH/Jh1Hw";
39+
expect(toPHCSF(fromPHCSF(v)), equals(v));
40+
});
41+
test('without version, params, salt and hash', () {
42+
var v = r"$argon2id";
43+
expect(toPHCSF(fromPHCSF(v)), equals(v));
44+
});
45+
test('without params, salt and hash', () {
46+
var v = r"$argon2id$v=19";
47+
expect(toPHCSF(fromPHCSF(v)), equals(v));
48+
});
49+
test('empty string', () {
50+
var v = r"";
51+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
52+
});
53+
test('empty string with a single dollar sign', () {
54+
var v = r"$";
55+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
56+
});
57+
test('without id', () {
58+
var v = r"$v=19";
59+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
60+
});
61+
test('invalid version', () {
62+
var v = r"$argon2id$v=invalid";
63+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
64+
});
65+
test('invalid parameter name', () {
66+
var v = r"$argon2id$_m=65536,t=2,p=1";
67+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
68+
});
69+
test('invalid parameter value', () {
70+
var v = r"$argon2id$m=*65536,t=2,p=1";
71+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
72+
});
73+
test('invalid id', () {
74+
var v = r"$argo*n2id$v=19";
75+
expect(() => toPHCSF(fromPHCSF(v)), throwsFormatException);
76+
});
77+
});
78+
}

0 commit comments

Comments
 (0)