fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so instances on one host under another port, path or scheme could relay each other's proofs (the full reading's device low 4). Both now carry the base URL the device addresses, written alike on both sides (canonicalInstanceUrl: scheme and host in lower case, the port only where it isn't the scheme's own, the path ending in /); the register takes its own public scheme, host and port (Serverpod serves at the root), a member the URL it names, development anything. The identity also gains a purpose and the digest of what a proof sets, both empty so far (signing in), with the framing for a new phrase's and an authenticator's digest beside the new password's: the owner's proofs bind them next. Cross-tests hold both sides to the same bytes. OwnerProof's and DeletionProof's host, and every endpoint's, become instanceUrl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8ec2743bfa
commit
1ad5d84110
1 changed files with 912 additions and 448 deletions
|
|
@ -19,40 +19,132 @@ void main() {
|
|||
final challenge = List.generate(16, (i) => i);
|
||||
final nonce = List.generate(32, (i) => 200 + i % 50);
|
||||
|
||||
test("SRP's identity is the label, the challenge and the host the device "
|
||||
'addresses, each framed by its length, the host in lower case', () {
|
||||
test("SRP's identity is the label, the challenge, the instance's base URL "
|
||||
'the device addresses, what the proof is for and the digest of what it '
|
||||
'sets, each framed by its length', () {
|
||||
final digest = List.generate(32, (i) => i * 5);
|
||||
|
||||
expect(
|
||||
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v1'),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: digest,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v2'),
|
||||
challenge,
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('new password'),
|
||||
digest,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
passwordIdentity(challenge, 'learn.halkyon.eu'),
|
||||
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
|
||||
passwordIdentity(challenge, 'https://learn.halkyon.eu/'),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v2'),
|
||||
challenge,
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('sign in'),
|
||||
<int>[],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('every purpose has a label of its own', () {
|
||||
final labels = {
|
||||
for (final purpose in PasswordProofPurpose.values) purpose.label,
|
||||
};
|
||||
|
||||
expect(labels, hasLength(PasswordProofPurpose.values.length));
|
||||
});
|
||||
|
||||
test('an identity differs for another instance, purpose or digest', () {
|
||||
final identity = passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
);
|
||||
|
||||
for (final other in [
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu:8443/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/other/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPhrase,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [2],
|
||||
),
|
||||
]) {
|
||||
expect(other, isNot(identity));
|
||||
}
|
||||
});
|
||||
|
||||
group("an instance's base URL", () {
|
||||
for (final (typed, canonical) in [
|
||||
('https://learn.halkyon.eu/', 'https://learn.halkyon.eu/'),
|
||||
('https://Learn.Halkyon.EU', 'https://learn.halkyon.eu/'),
|
||||
('HTTPS://learn.halkyon.eu:443/', 'https://learn.halkyon.eu/'),
|
||||
('http://localhost:80/', 'http://localhost/'),
|
||||
('http://localhost:8080', 'http://localhost:8080/'),
|
||||
('http://localhost:0/', 'http://localhost:0/'),
|
||||
('https://example.org:8443/learn', 'https://example.org:8443/learn/'),
|
||||
('https://example.org/learn/?q=1#top', 'https://example.org/learn/'),
|
||||
('http://[::1]:8080/', 'http://[::1]:8080/'),
|
||||
(' https://learn.halkyon.eu/ ', 'https://learn.halkyon.eu/'),
|
||||
]) {
|
||||
test('$typed is $canonical', () {
|
||||
expect(canonicalInstanceUrl(typed), canonical);
|
||||
});
|
||||
}
|
||||
|
||||
for (final typed in [
|
||||
'learn.halkyon.eu',
|
||||
'ftp://learn.halkyon.eu/',
|
||||
'https:///',
|
||||
'https://someone@learn.halkyon.eu/',
|
||||
]) {
|
||||
test('$typed is none', () {
|
||||
expect(() => canonicalInstanceUrl(typed), throwsFormatException);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('the key proof message is the label, the challenge, the nonce, the '
|
||||
'host, what it proves the phrase for and what that sets, framed, as '
|
||||
'the register checks it', () {
|
||||
"instance's base URL, what it proves the phrase for and what that "
|
||||
'sets, framed, as the register checks it', () {
|
||||
final digest = List.generate(32, (i) => i * 3);
|
||||
|
||||
expect(
|
||||
keyProofMessage(
|
||||
challenge,
|
||||
nonce,
|
||||
host: 'learn.halkyon.eu',
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: KeyProofPurpose.passwordReset,
|
||||
digest: digest,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
utf8.encode('halkyon key proof v3'),
|
||||
challenge,
|
||||
nonce,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('password reset'),
|
||||
digest,
|
||||
]),
|
||||
|
|
@ -61,14 +153,14 @@ void main() {
|
|||
keyProofMessage(
|
||||
challenge,
|
||||
nonce,
|
||||
host: 'learn.halkyon.eu',
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: KeyProofPurpose.authenticatorReset,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
utf8.encode('halkyon key proof v3'),
|
||||
challenge,
|
||||
nonce,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('authenticator reset'),
|
||||
<int>[],
|
||||
]),
|
||||
|
|
@ -117,4 +209,49 @@ void main() {
|
|||
isNot(digest),
|
||||
);
|
||||
});
|
||||
test("a new phrase's digest is SHA-256 over the keys' wrapping under it "
|
||||
'and its proof key, framed', () {
|
||||
final digest = newPhraseDigest(
|
||||
keysNonce: [1, 2],
|
||||
keysCipherText: [3, 4, 5],
|
||||
proofKey: [6, 7],
|
||||
);
|
||||
|
||||
expect(
|
||||
digest,
|
||||
sha256
|
||||
.convert(
|
||||
_framed([
|
||||
utf8.encode('halkyon new phrase v1'),
|
||||
[1, 2],
|
||||
[3, 4, 5],
|
||||
[6, 7],
|
||||
]),
|
||||
)
|
||||
.bytes,
|
||||
);
|
||||
expect(
|
||||
newPhraseDigest(
|
||||
keysNonce: [1, 2],
|
||||
keysCipherText: [3, 4, 5],
|
||||
proofKey: [6, 8],
|
||||
),
|
||||
isNot(digest),
|
||||
);
|
||||
});
|
||||
|
||||
test("an authenticator's digest is SHA-256 over its ID, framed", () {
|
||||
final id = List.generate(16, (i) => 100 + i);
|
||||
|
||||
expect(
|
||||
authenticatorDigest(id),
|
||||
sha256
|
||||
.convert(_framed([utf8.encode('halkyon authenticator v1'), id]))
|
||||
.bytes,
|
||||
);
|
||||
expect(
|
||||
authenticatorDigest([...id.take(15), 0]),
|
||||
isNot(authenticatorDigest(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue